diff --git a/docs/user/dashboard/lens-advanced.asciidoc b/docs/user/dashboard/lens-advanced.asciidoc index 33e0e362058f4..374d36e7586c4 100644 --- a/docs/user/dashboard/lens-advanced.asciidoc +++ b/docs/user/dashboard/lens-advanced.asciidoc @@ -301,9 +301,6 @@ image::images/lens_advanced_5_2.png[Line chart with cumulative sum of orders mad *Lens* allows you to compare the currently selected time range with historical data using the *Time shift* option. To calculate the percent change, use *Formula*. -Time shifts can be used on any metric. The special shift *previous* will show the time window preceding the currently selected one, spanning the same duration. -For example, if *Last 7 days* is selected in the time filter, *previous* will show data from 14 days ago to 7 days ago. - If multiple time shifts are used in a single chart, a multiple of the date histogram interval should be chosen - otherwise data points might not line up in the chart and empty spots can occur. For example, if a daily interval is used, shifting one series by *36h*, and another one by *1d*, is not recommended. In this scenario, either reduce the interval to *12h*, or create two separate charts. diff --git a/examples/expressions_explorer/public/render_expressions.tsx b/examples/expressions_explorer/public/render_expressions.tsx index 20a8b56edb3f8..d91db964c5352 100644 --- a/examples/expressions_explorer/public/render_expressions.tsx +++ b/examples/expressions_explorer/public/render_expressions.tsx @@ -34,7 +34,9 @@ interface Props { } export function RenderExpressionsExample({ expressions, inspector }: Props) { - const [expression, updateExpression] = useState('markdown "## expressions explorer rendering"'); + const [expression, updateExpression] = useState( + 'markdownVis "## expressions explorer rendering"' + ); const expressionChanged = (value: string) => { updateExpression(value); diff --git a/examples/expressions_explorer/public/run_expressions.tsx b/examples/expressions_explorer/public/run_expressions.tsx index a635fab7ec8ae..93cab0e9f2b6f 100644 --- a/examples/expressions_explorer/public/run_expressions.tsx +++ b/examples/expressions_explorer/public/run_expressions.tsx @@ -35,7 +35,7 @@ interface Props { } export function RunExpressionsExample({ expressions, inspector }: Props) { - const [expression, updateExpression] = useState('markdown "## expressions explorer"'); + const [expression, updateExpression] = useState('markdownVis "## expressions explorer"'); const [result, updateResult] = useState({}); const expressionChanged = (value: string) => { diff --git a/package.json b/package.json index 00da6c0bdb123..22eedde59c5e7 100644 --- a/package.json +++ b/package.json @@ -352,7 +352,7 @@ "react-moment-proptypes": "^1.7.0", "react-monaco-editor": "^0.41.2", "react-popper-tooltip": "^2.10.1", - "react-query": "^3.13.10", + "react-query": "^3.18.1", "react-redux": "^7.2.0", "react-resizable": "^1.7.5", "react-resize-detector": "^4.2.0", diff --git a/packages/kbn-rule-data-utils/src/technical_field_names.ts b/packages/kbn-rule-data-utils/src/technical_field_names.ts index 31779c9f08e81..6c45403fc0a13 100644 --- a/packages/kbn-rule-data-utils/src/technical_field_names.ts +++ b/packages/kbn-rule-data-utils/src/technical_field_names.ts @@ -19,6 +19,7 @@ const RULE_NAME = 'rule.name' as const; const RULE_CATEGORY = 'rule.category' as const; const TAGS = 'tags' as const; const PRODUCER = `${ALERT_NAMESPACE}.producer` as const; +const OWNER = `${ALERT_NAMESPACE}.owner` as const; const ALERT_ID = `${ALERT_NAMESPACE}.id` as const; const ALERT_UUID = `${ALERT_NAMESPACE}.uuid` as const; const ALERT_START = `${ALERT_NAMESPACE}.start` as const; @@ -40,6 +41,7 @@ const fields = { RULE_CATEGORY, TAGS, PRODUCER, + OWNER, ALERT_ID, ALERT_UUID, ALERT_START, @@ -62,6 +64,7 @@ export { RULE_CATEGORY, TAGS, PRODUCER, + OWNER, ALERT_ID, ALERT_UUID, ALERT_START, diff --git a/x-pack/plugins/lists/server/services/utils/decode_version.ts b/packages/kbn-securitysolution-es-utils/src/decode_version/index.ts similarity index 85% rename from x-pack/plugins/lists/server/services/utils/decode_version.ts rename to packages/kbn-securitysolution-es-utils/src/decode_version/index.ts index 8ed934204ed98..d58c7add67a27 100644 --- a/x-pack/plugins/lists/server/services/utils/decode_version.ts +++ b/packages/kbn-securitysolution-es-utils/src/decode_version/index.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ // Similar to the src/core/server/saved_objects/version/decode_version.ts diff --git a/x-pack/plugins/lists/server/services/utils/encode_hit_version.ts b/packages/kbn-securitysolution-es-utils/src/encode_hit_version/index.ts similarity index 84% rename from x-pack/plugins/lists/server/services/utils/encode_hit_version.ts rename to packages/kbn-securitysolution-es-utils/src/encode_hit_version/index.ts index 4c55d858d283b..29b5a18f7c303 100644 --- a/x-pack/plugins/lists/server/services/utils/encode_hit_version.ts +++ b/packages/kbn-securitysolution-es-utils/src/encode_hit_version/index.ts @@ -1,8 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. */ /** diff --git a/packages/kbn-securitysolution-es-utils/src/index.ts b/packages/kbn-securitysolution-es-utils/src/index.ts index cfa6820e9aac5..8dead7f899ba2 100644 --- a/packages/kbn-securitysolution-es-utils/src/index.ts +++ b/packages/kbn-securitysolution-es-utils/src/index.ts @@ -8,10 +8,12 @@ export * from './bad_request_error'; export * from './create_boostrap_index'; +export * from './decode_version'; export * from './delete_all_index'; export * from './delete_policy'; export * from './delete_template'; export * from './elasticsearch_client'; +export * from './encode_hit_version'; export * from './get_index_aliases'; export * from './get_index_count'; export * from './get_index_exists'; diff --git a/packages/kbn-utils/src/path/index.ts b/packages/kbn-utils/src/path/index.ts index 2d6d0e9e919eb..9835179a61e9d 100644 --- a/packages/kbn-utils/src/path/index.ts +++ b/packages/kbn-utils/src/path/index.ts @@ -18,6 +18,7 @@ const CONFIG_PATHS = [ process.env.KIBANA_PATH_CONF && join(process.env.KIBANA_PATH_CONF, 'kibana.yml'), process.env.CONFIG_PATH, // deprecated join(REPO_ROOT, 'config/kibana.yml'), + '/etc/kibana/kibana.yml', ].filter(isString); const CONFIG_DIRECTORIES = [ diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 2187d5a0a33be..7a843a41cc4ca 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -244,14 +244,14 @@ export class DocLinksService { anomalyDetectionJobTips: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#job-tips`, anomalyDetectionModelMemoryLimits: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/create-jobs.html#model-memory-limits`, calendars: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-calendars.html`, - classificationEvaluation: `${ELASTICSEARCH_DOCS}evaluate-dfanalytics.html`, + classificationEvaluation: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfa-classification.html#ml-dfanalytics-classification-evaluation`, customRules: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-rules.html`, customUrls: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-url.html`, dataFrameAnalytics: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics.html`, featureImportance: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-feature-importance.html`, outlierDetectionRoc: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfa-finding-outliers.html#ml-dfanalytics-roc`, - regressionEvaluation: `${ELASTICSEARCH_DOCS}evaluate-dfanalytics.html`, - classificationAucRoc: `${ELASTICSEARCH_DOCS}evaluate-dfanalytics.html`, + regressionEvaluation: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfa-regression.html#ml-dfanalytics-regression-evaluation`, + classificationAucRoc: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfa-classification.html#ml-dfanalytics-class-aucroc`, }, transforms: { guide: `${ELASTICSEARCH_DOCS}transforms.html`, diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.ts b/src/core/server/saved_objects/migrations/core/elastic_index.ts index 09c2935422b79..8bda77563be8c 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.ts @@ -40,6 +40,7 @@ export const REMOVED_TYPES: string[] = [ 'fleet-agent-events', // Was removed in 7.12 'ml-telemetry', + 'server', // https://github.com/elastic/kibana/issues/95617 'tsvb-validation-telemetry', ].sort(); diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/type_registrations.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/type_registrations.test.ts index 47c492622fa3f..e8871586cdfb7 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/type_registrations.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/type_registrations.test.ts @@ -79,6 +79,7 @@ const previouslyRegisteredTypes = [ 'search-telemetry', 'security-rule', 'security-solution-signals-migration', + 'server', 'siem-detection-engine-rule-actions', 'siem-detection-engine-rule-status', 'siem-ui-timeline', diff --git a/src/dev/build/tasks/install_chromium.js b/src/dev/build/tasks/install_chromium.js index 37abcbad4466e..95e0df8984f9d 100644 --- a/src/dev/build/tasks/install_chromium.js +++ b/src/dev/build/tasks/install_chromium.js @@ -19,7 +19,6 @@ export const InstallChromium = { log.info(`Installing Chromium for ${platform.getName()}-${platform.getArchitecture()}`); const { binaryPath$ } = installBrowser( - // TODO: https://github.com/elastic/kibana/issues/72496 log, build.resolvePathForPlatform(platform, 'x-pack/plugins/reporting/chromium'), platform.getName(), diff --git a/src/plugins/kibana_react/common/eui_styled_components.tsx b/src/plugins/kibana_react/common/eui_styled_components.tsx index 10cd168da6faa..62876a03c7d83 100644 --- a/src/plugins/kibana_react/common/eui_styled_components.tsx +++ b/src/plugins/kibana_react/common/eui_styled_components.tsx @@ -6,15 +6,14 @@ * Side Public License, v 1. */ +import type { DecoratorFn } from '@storybook/react'; import React from 'react'; import * as styledComponents from 'styled-components'; import { ThemedStyledComponentsModule, ThemeProvider, ThemeProviderProps } from 'styled-components'; - -import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { euiThemeVars, euiLightVars, euiDarkVars } from '@kbn/ui-shared-deps/theme'; export interface EuiTheme { - eui: typeof euiLightVars | typeof euiDarkVars; + eui: typeof euiThemeVars; darkMode: boolean; } @@ -36,6 +35,16 @@ const EuiThemeProvider = < /> ); +/** + * Storybook decorator using the EUI theme provider. Uses the value from + * `globals` provided by the Storybook theme switcher. + */ +export const EuiThemeProviderDecorator: DecoratorFn = (storyFn, { globals }) => { + const darkMode = globals.euiTheme === 'v8.dark' || globals.euiTheme === 'v7.dark'; + + return {storyFn()}; +}; + const { default: euiStyled, css, diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/cumulative_sum.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/cumulative_sum.js index f167bc35c06e9..a232a1dc03ae3 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/cumulative_sum.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/cumulative_sum.js @@ -23,6 +23,7 @@ import { EuiFormRow, EuiSpacer, } from '@elastic/eui'; +import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; export function CumulativeSumAgg(props) { const { model, siblings, fields, indexPattern } = props; @@ -70,7 +71,7 @@ export function CumulativeSumAgg(props) { onChange={handleSelectChange('field')} metrics={siblings} metric={model} - fields={fields[indexPattern]} + fields={fields[getIndexPatternKey(indexPattern)]} value={model.field} exclude={[METRIC_TYPES.TOP_HIT]} /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/derivative.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/derivative.js index 9bed7015b0245..616f40128ff22 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/derivative.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/derivative.js @@ -25,6 +25,7 @@ import { EuiSpacer, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; export const DerivativeAgg = (props) => { const { siblings, fields, indexPattern } = props; @@ -80,7 +81,7 @@ export const DerivativeAgg = (props) => { onChange={handleSelectChange('field')} metrics={siblings} metric={model} - fields={fields[indexPattern]} + fields={fields[getIndexPatternKey(indexPattern)]} value={model.field} exclude={[METRIC_TYPES.TOP_HIT]} fullWidth diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/moving_average.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/moving_average.js index 79f70f45d6256..a3ce43f97a36a 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/moving_average.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/moving_average.js @@ -26,6 +26,7 @@ import { EuiFieldNumber, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; const DEFAULTS = { model_type: MODEL_TYPES.UNWEIGHTED, @@ -141,7 +142,7 @@ export const MovingAverageAgg = (props) => { onChange={handleSelectChange('field')} metrics={siblings} metric={model} - fields={fields[indexPattern]} + fields={fields[getIndexPatternKey(indexPattern)]} value={model.field} exclude={[METRIC_TYPES.TOP_HIT]} /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_only.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_only.js index 156a042abb4e2..c974f5d5f05f5 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_only.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_only.js @@ -23,6 +23,7 @@ import { EuiSpacer, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; export const PositiveOnlyAgg = (props) => { const { siblings, fields, indexPattern } = props; @@ -74,7 +75,7 @@ export const PositiveOnlyAgg = (props) => { onChange={handleSelectChange('field')} metrics={siblings} metric={model} - fields={fields[indexPattern]} + fields={fields[getIndexPatternKey(indexPattern)]} value={model.field} exclude={[METRIC_TYPES.TOP_HIT]} /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/serial_diff.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/serial_diff.js index a553b1a4c6671..efc2a72c3dd67 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/serial_diff.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/serial_diff.js @@ -24,6 +24,7 @@ import { EuiSpacer, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; export const SerialDiffAgg = (props) => { const { siblings, fields, indexPattern, model } = props; @@ -74,7 +75,7 @@ export const SerialDiffAgg = (props) => { onChange={handleSelectChange('field')} metrics={siblings} metric={model} - fields={fields[indexPattern]} + fields={fields[getIndexPatternKey(indexPattern)]} value={model.field} exclude={[METRIC_TYPES.TOP_HIT]} /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/std_sibling.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/std_sibling.js index 9a30988d252e5..d2b3f45a70164 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/std_sibling.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/std_sibling.js @@ -27,6 +27,7 @@ import { EuiSpacer, } from '@elastic/eui'; import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; +import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; const StandardSiblingAggUi = (props) => { const { siblings, intl, fields, indexPattern } = props; @@ -147,7 +148,7 @@ const StandardSiblingAggUi = (props) => { onChange={handleSelectChange('field')} exclude={[METRIC_TYPES.PERCENTILE, METRIC_TYPES.TOP_HIT]} metrics={siblings} - fields={fields[indexPattern]} + fields={fields[getIndexPatternKey(indexPattern)]} metric={model} value={model.field} /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/vars.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/vars.js index b9d554e254bcc..ba06b0fffd307 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/vars.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/vars.js @@ -15,6 +15,7 @@ import { AddDeleteButtons } from '../add_delete_buttons'; import { collectionActions } from '../lib/collection_actions'; import { MetricSelect } from './metric_select'; import { EuiFlexGroup, EuiFlexItem, EuiFieldText } from '@elastic/eui'; +import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; export const newVariable = (opts) => ({ id: uuid.v1(), name: '', field: '', ...opts }); @@ -59,7 +60,7 @@ export class CalculationVars extends Component { metrics={this.props.metrics} metric={this.props.model} value={row.field} - fields={this.props.fields[this.props.indexPattern]} + fields={this.props.fields[getIndexPatternKey(this.props.indexPattern)]} includeSiblings={this.props.includeSiblings} exclude={this.props.exclude} /> diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx index 9ba0822402562..3633f8add7457 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.tsx @@ -207,6 +207,7 @@ export class TablePanelConfig extends Component< diff --git a/src/plugins/vis_type_timeseries/public/application/components/splits/__snapshots__/terms.test.js.snap b/src/plugins/vis_type_timeseries/public/application/components/splits/__snapshots__/terms.test.js.snap index 562c463f6c83c..ce381a0e539d0 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/splits/__snapshots__/terms.test.js.snap +++ b/src/plugins/vis_type_timeseries/public/application/components/splits/__snapshots__/terms.test.js.snap @@ -78,6 +78,7 @@ exports[`src/legacy/core_plugins/metrics/public/components/splits/terms.test.js labelType="label" > @@ -100,6 +101,7 @@ exports[`src/legacy/core_plugins/metrics/public/components/splits/terms.test.js labelType="label" > diff --git a/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js b/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js index 7db6a75e2392c..9c097de38d56a 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js +++ b/src/plugins/vis_type_timeseries/public/application/components/splits/terms.js @@ -27,6 +27,7 @@ import { import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; import { KBN_FIELD_TYPES } from '../../../../../data/public'; import { STACKED_OPTIONS } from '../../visualizations/constants'; +import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; const DEFAULTS = { terms_direction: 'desc', terms_size: 10, terms_order_by: '_count' }; @@ -75,10 +76,11 @@ export const SplitByTermsUI = ({ }), }, ]; + const fieldsSelector = getIndexPatternKey(indexPattern); const selectedDirectionOption = dirOptions.find((option) => { return model.terms_direction === option.value; }); - const selectedField = find(fields[indexPattern], ({ name }) => name === model.terms_field); + const selectedField = find(fields[fieldsSelector], ({ name }) => name === model.terms_field); const selectedFieldType = get(selectedField, 'type'); if ( @@ -144,6 +146,7 @@ export const SplitByTermsUI = ({ @@ -160,6 +163,7 @@ export const SplitByTermsUI = ({ @@ -198,7 +202,7 @@ export const SplitByTermsUI = ({ metrics={metrics} clearable={false} additionalOptions={[defaultCount, terms]} - fields={fields[indexPattern]} + fields={fields[fieldsSelector]} onChange={handleSelectChange('terms_order_by')} restrict="basic" value={model.terms_order_by} diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js index 4dd8f672c9ea3..4db038de912f5 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js @@ -58,7 +58,11 @@ class TableVis extends Component { renderRow = (row) => { const { model } = this.props; - let rowDisplay = model.pivot_type === 'date' ? this.dateFormatter.convert(row.key) : row.key; + + let rowDisplay = getValueOrEmpty( + model.pivot_type === 'date' ? this.dateFormatter.convert(row.key) : row.key + ); + if (model.drilldown_url) { const url = replaceVars(model.drilldown_url, {}, { key: row.key }); rowDisplay = {rowDisplay}; @@ -98,7 +102,7 @@ class TableVis extends Component { }); return ( - {getValueOrEmpty(rowDisplay)} + {rowDisplay} {columns} ); diff --git a/test/examples/expressions_explorer/expressions.ts b/test/examples/expressions_explorer/expressions.ts index 4c240653b5fdd..9aef64a392a7b 100644 --- a/test/examples/expressions_explorer/expressions.ts +++ b/test/examples/expressions_explorer/expressions.ts @@ -22,7 +22,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { await retry.try(async () => { const text = await testSubjects.getVisibleText('expressionResult'); expect(text).to.be( - '{\n "type": "render",\n "as": "markdown",\n "value": {\n "content": "## expressions explorer",\n "font": {\n "type": "style",\n "spec": {\n "fontFamily": "\'Open Sans\', Helvetica, Arial, sans-serif",\n "fontWeight": "normal",\n "fontStyle": "normal",\n "textDecoration": "none",\n "textAlign": "left",\n "fontSize": "14px",\n "lineHeight": "1"\n },\n "css": "font-family:\'Open Sans\', Helvetica, Arial, sans-serif;font-weight:normal;font-style:normal;text-decoration:none;text-align:left;font-size:14px;line-height:1"\n },\n "openLinksInNewTab": false\n }\n}' + '{\n "type": "render",\n "as": "markdown_vis",\n "value": {\n "visType": "markdown",\n "visParams": {\n "markdown": "## expressions explorer",\n "openLinksInNewTab": false,\n "fontSize": 12\n }\n }\n}' ); }); }); diff --git a/test/functional/apps/visualize/_tsvb_table.ts b/test/functional/apps/visualize/_tsvb_table.ts index abe3b799e4711..de0771d3c8ec5 100644 --- a/test/functional/apps/visualize/_tsvb_table.ts +++ b/test/functional/apps/visualize/_tsvb_table.ts @@ -10,12 +10,14 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getPageObjects }: FtrProviderContext) { +export default function ({ getPageObjects, getService }: FtrProviderContext) { const { visualBuilder, visualize, visChart } = getPageObjects([ 'visualBuilder', 'visualize', 'visChart', ]); + const findService = getService('find'); + const retry = getService('retry'); describe('visual builder', function describeIndexTests() { before(async () => { @@ -43,6 +45,19 @@ export default function ({ getPageObjects }: FtrProviderContext) { expect(tableData).to.be(EXPECTED); }); + it('should display drilldown urls', async () => { + const baseURL = 'http://elastic.co/foo/'; + + await visualBuilder.clickPanelOptions('table'); + await visualBuilder.setDrilldownUrl(`${baseURL}{{key}}`); + + await retry.try(async () => { + const links = await findService.allByCssSelector(`a[href="${baseURL}ios"]`); + + expect(links.length).to.be(1); + }); + }); + it('should display correct values on changing metrics aggregation', async () => { const EXPECTED = 'OS Cardinality\nwin 8 12\nwin xp 9\nwin 7 8\nios 5\nosx 3'; diff --git a/test/functional/apps/visualize/_tsvb_time_series.ts b/test/functional/apps/visualize/_tsvb_time_series.ts index a0c9d806facc6..cc57d58348180 100644 --- a/test/functional/apps/visualize/_tsvb_time_series.ts +++ b/test/functional/apps/visualize/_tsvb_time_series.ts @@ -155,7 +155,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('Clicking on the chart', () => { it(`should create a filter`, async () => { - await visualBuilder.setMetricsGroupByTerms('machine.os.raw'); + await visualBuilder.setMetricsGroupByTerms('machine.os.raw', { + include: 'win 7', + exclude: 'ios', + }); await visualBuilder.clickSeriesOption(); await testSubjects.click('visualizeSaveButton'); diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index ea11560e37b6f..8e28ffab6c9c3 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -277,6 +277,13 @@ export class VisualBuilderPageObject extends FtrService { await this.comboBox.setElement(formatterEl, formatter, { clickWithMouse: true }); } + public async setDrilldownUrl(value: string) { + const drilldownEl = await this.testSubjects.find('drilldownUrl'); + + await drilldownEl.clearValue(); + await drilldownEl.type(value); + } + /** * set duration formatter additional settings * @@ -628,7 +635,10 @@ export class VisualBuilderPageObject extends FtrService { return await this.find.allByCssSelector('.tvbSeriesEditor'); } - public async setMetricsGroupByTerms(field: string) { + public async setMetricsGroupByTerms( + field: string, + filtering: { include?: string; exclude?: string } = {} + ) { const groupBy = await this.find.byCssSelector( '.tvbAggRow--split [data-test-subj="comboBoxInput"]' ); @@ -636,6 +646,22 @@ export class VisualBuilderPageObject extends FtrService { await this.common.sleep(1000); const byField = await this.testSubjects.find('groupByField'); await this.comboBox.setElement(byField, field); + + await this.setMetricsGroupByFiltering(filtering.include, filtering.exclude); + } + + public async setMetricsGroupByFiltering(include?: string, exclude?: string) { + const setFilterValue = async (value: string | undefined, subjectKey: string) => { + if (typeof value === 'string') { + const valueSubject = await this.testSubjects.find(subjectKey); + + await valueSubject.clearValue(); + await valueSubject.type(value); + } + }; + + await setFilterValue(include, 'groupByInclude'); + await setFilterValue(exclude, 'groupByExclude'); } public async checkSelectedMetricsGroupByValue(value: string) { diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.mock.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.mock.ts index 4e4cd4419a5a2..5e3dd2019d0a0 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.mock.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.mock.ts @@ -16,12 +16,13 @@ const createAlertingAuthorizationMock = () => { ensureAuthorized: jest.fn(), filterByRuleTypeAuthorization: jest.fn(), getFindAuthorizationFilter: jest.fn(), + getAugmentedRuleTypesWithAuthorization: jest.fn(), }; return mocked; }; export const alertingAuthorizationMock: { - create: () => AlertingAuthorizationMock; + create: () => jest.Mocked>; } = { create: createAlertingAuthorizationMock, }; diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts index c07148f03c684..4b1fc7f1a7ccb 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts @@ -1944,4 +1944,184 @@ describe('AlertingAuthorization', () => { `); }); }); + + describe('getAugmentedRuleTypesWithAuthorization', () => { + const myOtherAppAlertType: RegistryAlertType = { + actionGroups: [], + actionVariables: undefined, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + recoveryActionGroup: RecoveredActionGroup, + id: 'myOtherAppAlertType', + name: 'myOtherAppAlertType', + producer: 'alerts', + enabledInLicense: true, + isExportable: true, + }; + const myAppAlertType: RegistryAlertType = { + actionGroups: [], + actionVariables: undefined, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + recoveryActionGroup: RecoveredActionGroup, + id: 'myAppAlertType', + name: 'myAppAlertType', + producer: 'myApp', + enabledInLicense: true, + isExportable: true, + }; + const mySecondAppAlertType: RegistryAlertType = { + actionGroups: [], + actionVariables: undefined, + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + recoveryActionGroup: RecoveredActionGroup, + id: 'mySecondAppAlertType', + name: 'mySecondAppAlertType', + producer: 'myApp', + enabledInLicense: true, + isExportable: true, + }; + const setOfAlertTypes = new Set([myAppAlertType, myOtherAppAlertType, mySecondAppAlertType]); + + test('it returns authorized rule types given a set of feature ids', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction< + ReturnType + > = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: false, + privileges: { + kibana: [ + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'alert', 'find'), + authorized: true, + }, + ], + }, + }); + const alertAuthorization = new AlertingAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + exemptConsumerIds, + }); + alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + + await expect( + alertAuthorization.getAugmentedRuleTypesWithAuthorization( + ['myApp'], + [ReadOperations.Find, ReadOperations.Get, WriteOperations.Update], + AlertingAuthorizationEntity.Alert + ) + ).resolves.toMatchInlineSnapshot(` + Object { + "authorizedRuleTypes": Set { + Object { + "actionGroups": Array [], + "actionVariables": undefined, + "authorizedConsumers": Object { + "myApp": Object { + "all": false, + "read": true, + }, + }, + "defaultActionGroupId": "default", + "enabledInLicense": true, + "id": "myOtherAppAlertType", + "isExportable": true, + "minimumLicenseRequired": "basic", + "name": "myOtherAppAlertType", + "producer": "alerts", + "recoveryActionGroup": Object { + "id": "recovered", + "name": "Recovered", + }, + }, + }, + "hasAllRequested": false, + "username": "some-user", + } + `); + }); + + test('it returns all authorized if user has read, get and update alert privileges', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction< + ReturnType + > = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: false, + privileges: { + kibana: [ + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'alert', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'alert', 'get'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'alert', 'update'), + authorized: true, + }, + ], + }, + }); + const alertAuthorization = new AlertingAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + exemptConsumerIds, + }); + alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + + await expect( + alertAuthorization.getAugmentedRuleTypesWithAuthorization( + ['myApp'], + [ReadOperations.Find, ReadOperations.Get, WriteOperations.Update], + AlertingAuthorizationEntity.Alert + ) + ).resolves.toMatchInlineSnapshot(` + Object { + "authorizedRuleTypes": Set { + Object { + "actionGroups": Array [], + "actionVariables": undefined, + "authorizedConsumers": Object { + "myApp": Object { + "all": true, + "read": true, + }, + }, + "defaultActionGroupId": "default", + "enabledInLicense": true, + "id": "myOtherAppAlertType", + "isExportable": true, + "minimumLicenseRequired": "basic", + "name": "myOtherAppAlertType", + "producer": "alerts", + "recoveryActionGroup": Object { + "id": "recovered", + "name": "Recovered", + }, + }, + }, + "hasAllRequested": false, + "username": "some-user", + } + `); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts index 52cef9a402e35..50a1b9d84ff6d 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts @@ -124,20 +124,41 @@ export class AlertingAuthorization { return new Set(); }); - this.allPossibleConsumers = this.featuresIds.then((featuresIds) => - featuresIds.size + this.allPossibleConsumers = this.featuresIds.then((featuresIds) => { + return featuresIds.size ? asAuthorizedConsumers([...this.exemptConsumerIds, ...featuresIds], { read: true, all: true, }) - : {} - ); + : {}; + }); } private shouldCheckAuthorization(): boolean { return this.authorization?.mode?.useRbacForRequest(this.request) ?? false; } + /* + * This method exposes the private 'augmentRuleTypesWithAuthorization' to be + * used by the RAC/Alerts client + */ + public async getAugmentedRuleTypesWithAuthorization( + featureIds: readonly string[], + operations: Array, + authorizationEntity: AlertingAuthorizationEntity + ): Promise<{ + username?: string; + hasAllRequested: boolean; + authorizedRuleTypes: Set; + }> { + return this.augmentRuleTypesWithAuthorization( + this.alertTypeRegistry.list(), + operations, + authorizationEntity, + new Set(featureIds) + ); + } + public async ensureAuthorized({ ruleTypeId, consumer, operation, entity }: EnsureAuthorizedOpts) { const { authorization } = this; @@ -339,13 +360,14 @@ export class AlertingAuthorization { private async augmentRuleTypesWithAuthorization( ruleTypes: Set, operations: Array, - authorizationEntity: AlertingAuthorizationEntity + authorizationEntity: AlertingAuthorizationEntity, + featuresIds?: Set ): Promise<{ username?: string; hasAllRequested: boolean; authorizedRuleTypes: Set; }> { - const featuresIds = await this.featuresIds; + const fIds = featuresIds ?? (await this.featuresIds); if (this.authorization && this.shouldCheckAuthorization()) { const checkPrivileges = this.authorization.checkPrivilegesDynamicallyWithRequest( this.request @@ -363,7 +385,7 @@ export class AlertingAuthorization { // as we can't ask ES for the user's individual privileges we need to ask for each feature // and ruleType in the system whether this user has this privilege for (const ruleType of ruleTypesWithAuthorization) { - for (const feature of featuresIds) { + for (const feature of fIds) { for (const operation of operations) { privilegeToRuleType.set( this.authorization!.actions.alerting.get( @@ -420,7 +442,7 @@ export class AlertingAuthorization { return { hasAllRequested: true, authorizedRuleTypes: this.augmentWithAuthorizedConsumers( - new Set([...ruleTypes].filter((ruleType) => featuresIds.has(ruleType.producer))), + new Set([...ruleTypes].filter((ruleType) => fIds.has(ruleType.producer))), await this.allPossibleConsumers ), }; diff --git a/x-pack/plugins/alerting/server/index.ts b/x-pack/plugins/alerting/server/index.ts index 72e3325107f31..957bd89f52f36 100644 --- a/x-pack/plugins/alerting/server/index.ts +++ b/x-pack/plugins/alerting/server/index.ts @@ -34,6 +34,13 @@ export { FindResult } from './alerts_client'; export { PublicAlertInstance as AlertInstance } from './alert_instance'; export { parseDuration } from './lib'; export { getEsErrorMessage } from './lib/errors'; +export { + ReadOperations, + AlertingAuthorizationFilterType, + AlertingAuthorization, + WriteOperations, + AlertingAuthorizationEntity, +} from './authorization'; export const plugin = (initContext: PluginInitializerContext) => new AlertingPlugin(initContext); diff --git a/x-pack/plugins/apm/.storybook/jest_setup.js b/x-pack/plugins/apm/.storybook/jest_setup.js new file mode 100644 index 0000000000000..32071b8aa3f62 --- /dev/null +++ b/x-pack/plugins/apm/.storybook/jest_setup.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setGlobalConfig } from '@storybook/testing-react'; +import * as globalStorybookConfig from './preview'; + +setGlobalConfig(globalStorybookConfig); diff --git a/x-pack/plugins/apm/.storybook/preview.js b/x-pack/plugins/apm/.storybook/preview.js new file mode 100644 index 0000000000000..18343c15a6465 --- /dev/null +++ b/x-pack/plugins/apm/.storybook/preview.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiThemeProviderDecorator } from '../../../../src/plugins/kibana_react/common'; + +export const decorators = [EuiThemeProviderDecorator]; diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts index ad233c7f6df92..9476396a7aefa 100644 --- a/x-pack/plugins/apm/common/alert_types.ts +++ b/x-pack/plugins/apm/common/alert_types.ts @@ -10,6 +10,8 @@ import type { ValuesType } from 'utility-types'; import type { ActionGroup } from '../../alerting/common'; import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from './ml_constants'; +export const APM_SERVER_FEATURE_ID = 'apm'; + export enum AlertType { ErrorCount = 'apm.error_rate', // ErrorRate was renamed to ErrorCount but the key is kept as `error_rate` for backwards-compat. TransactionErrorRate = 'apm.transaction_error_rate', @@ -44,7 +46,7 @@ export const ALERT_TYPES_CONFIG: Record< actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: THRESHOLD_MET_GROUP_ID, minimumLicenseRequired: 'basic', - producer: 'apm', + producer: APM_SERVER_FEATURE_ID, isExportable: true, }, [AlertType.TransactionDuration]: { @@ -54,7 +56,7 @@ export const ALERT_TYPES_CONFIG: Record< actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: THRESHOLD_MET_GROUP_ID, minimumLicenseRequired: 'basic', - producer: 'apm', + producer: APM_SERVER_FEATURE_ID, isExportable: true, }, [AlertType.TransactionDurationAnomaly]: { @@ -64,7 +66,7 @@ export const ALERT_TYPES_CONFIG: Record< actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: THRESHOLD_MET_GROUP_ID, minimumLicenseRequired: 'basic', - producer: 'apm', + producer: APM_SERVER_FEATURE_ID, isExportable: true, }, [AlertType.TransactionErrorRate]: { @@ -74,7 +76,7 @@ export const ALERT_TYPES_CONFIG: Record< actionGroups: [THRESHOLD_MET_GROUP], defaultActionGroupId: THRESHOLD_MET_GROUP_ID, minimumLicenseRequired: 'basic', - producer: 'apm', + producer: APM_SERVER_FEATURE_ID, isExportable: true, }, }; diff --git a/x-pack/plugins/apm/jest.config.js b/x-pack/plugins/apm/jest.config.js index caa8256cdb7ea..5bce9bbfb5b1b 100644 --- a/x-pack/plugins/apm/jest.config.js +++ b/x-pack/plugins/apm/jest.config.js @@ -11,4 +11,6 @@ module.exports = { preset: '@kbn/test', rootDir: path.resolve(__dirname, '../../..'), roots: ['/x-pack/plugins/apm'], + setupFiles: ['/x-pack/plugins/apm/.storybook/jest_setup.js'], + testPathIgnorePatterns: ['/x-pack/plugins/apm/e2e/'], }; diff --git a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx index 35863d8099394..eef3271d5932d 100644 --- a/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/alerting_flyout/index.tsx @@ -6,11 +6,15 @@ */ import React, { useCallback, useMemo } from 'react'; -import { useParams } from 'react-router-dom'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { AlertType } from '../../../../common/alert_types'; +import { + AlertType, + APM_SERVER_FEATURE_ID, +} from '../../../../common/alert_types'; import { getInitialAlertValues } from '../get_initial_alert_values'; import { ApmPluginStartDeps } from '../../../plugin'; +import { useServiceName } from '../../../hooks/use_service_name'; +import { ApmServiceContextProvider } from '../../../context/apm_service/apm_service_context'; interface Props { addFlyoutVisible: boolean; setAddFlyoutVisibility: React.Dispatch>; @@ -19,7 +23,7 @@ interface Props { export function AlertingFlyout(props: Props) { const { addFlyoutVisible, setAddFlyoutVisibility, alertType } = props; - const { serviceName } = useParams<{ serviceName?: string }>(); + const serviceName = useServiceName(); const { services } = useKibana(); const initialValues = getInitialAlertValues(alertType, serviceName); @@ -31,7 +35,7 @@ export function AlertingFlyout(props: Props) { () => alertType && services.triggersActionsUi.getAddAlertFlyout({ - consumer: 'apm', + consumer: APM_SERVER_FEATURE_ID, onClose: onCloseAddFlyout, alertTypeId: alertType, canChangeTrigger: false, @@ -40,5 +44,11 @@ export function AlertingFlyout(props: Props) { /* eslint-disable-next-line react-hooks/exhaustive-deps */ [alertType, onCloseAddFlyout, services.triggersActionsUi] ); - return <>{addFlyoutVisible && addAlertFlyout}; + return ( + <> + {addFlyoutVisible && ( + {addAlertFlyout} + )} + + ); } 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/error_count_alert_trigger/index.stories.tsx index 83874e9584510..23afb9646dea7 100644 --- a/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.stories.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { ErrorCountAlertTrigger } from '.'; -import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { ApmPluginContextValue } from '../../../context/apm_plugin/apm_plugin_context'; import { mockApmPluginContextValue, @@ -20,19 +19,15 @@ export default { component: ErrorCountAlertTrigger, decorators: [ (Story: React.ComponentClass) => ( - - - -
- -
-
-
-
+ + +
+ +
+
+
), ], }; diff --git a/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx index fdfed6eb0d685..811353067ab60 100644 --- a/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/error_count_alert_trigger/index.tsx @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; -import { useParams } from 'react-router-dom'; +import { defaults } from 'lodash'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { asInteger } from '../../../../common/utils/formatters'; @@ -18,6 +18,7 @@ import { ChartPreview } from '../chart_preview'; import { EnvironmentField, IsAboveField, ServiceField } from '../fields'; import { getAbsoluteTimeRange } from '../helper'; import { ServiceAlertTrigger } from '../service_alert_trigger'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; export interface AlertParams { windowSize: number; @@ -35,49 +36,55 @@ interface Props { export function ErrorCountAlertTrigger(props: Props) { const { setAlertParams, setAlertProperty, alertParams } = props; - const { serviceName } = useParams<{ serviceName?: string }>(); + + const { serviceName: serviceNameFromContext } = useApmServiceContext(); + const { urlParams } = useUrlParams(); - const { start, end } = urlParams; + const { start, end, environment: environmentFromUrl } = urlParams; const { environmentOptions } = useEnvironmentsFetcher({ - serviceName, + serviceName: serviceNameFromContext, start, end, }); - const { threshold, windowSize, windowUnit, environment } = alertParams; + const params = defaults( + { + ...alertParams, + }, + { + threshold: 25, + windowSize: 1, + windowUnit: 'm', + environment: environmentFromUrl || ENVIRONMENT_ALL.value, + serviceName: serviceNameFromContext, + } + ); const { data } = useFetcher( (callApmApi) => { - if (windowSize && windowUnit) { + if (params.windowSize && params.windowUnit) { return callApmApi({ endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_count', params: { query: { - ...getAbsoluteTimeRange(windowSize, windowUnit), - environment, - serviceName, + ...getAbsoluteTimeRange(params.windowSize, params.windowUnit), + environment: params.environment, + serviceName: params.serviceName, }, }, }); } }, - [windowSize, windowUnit, environment, serviceName] + [ + params.windowSize, + params.windowUnit, + params.environment, + params.serviceName, + ] ); - const defaults = { - threshold: 25, - windowSize: 1, - windowUnit: 'm', - environment: urlParams.environment || ENVIRONMENT_ALL.value, - }; - - const params = { - ...defaults, - ...alertParams, - }; - const fields = [ - , + , ); return ( void; @@ -18,13 +17,10 @@ interface Props { } export function ServiceAlertTrigger(props: Props) { - const { serviceName } = useParams<{ serviceName?: string }>(); - const { fields, setAlertParams, defaults, chartPreview } = props; const params: Record = { ...defaults, - serviceName, }; useEffect(() => { diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx index b4c78b54f329b..8f2713685127e 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.tsx @@ -7,9 +7,8 @@ import { EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { map } from 'lodash'; +import { map, defaults } from 'lodash'; import React from 'react'; -import { useParams } from 'react-router-dom'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { getDurationFormatter } from '../../../../common/utils/formatters'; @@ -72,46 +71,60 @@ interface Props { export function TransactionDurationAlertTrigger(props: Props) { const { setAlertParams, alertParams, setAlertProperty } = props; const { urlParams } = useUrlParams(); - const { transactionTypes, transactionType } = useApmServiceContext(); - const { serviceName } = useParams<{ serviceName?: string }>(); - const { start, end } = urlParams; + + const { start, end, environment: environmentFromUrl } = urlParams; + + const { + transactionTypes, + transactionType: transactionTypeFromContext, + serviceName: serviceNameFromContext, + } = useApmServiceContext(); + + const params = defaults( + { + ...alertParams, + }, + { + aggregationType: 'avg', + environment: environmentFromUrl || ENVIRONMENT_ALL.value, + threshold: 1500, + windowSize: 5, + windowUnit: 'm', + transactionType: transactionTypeFromContext, + serviceName: serviceNameFromContext, + } + ); + const { environmentOptions } = useEnvironmentsFetcher({ - serviceName, + serviceName: params.serviceName, start, end, }); - const { - aggregationType, - environment, - threshold, - windowSize, - windowUnit, - } = alertParams; const { data } = useFetcher( (callApmApi) => { - if (windowSize && windowUnit) { + if (params.windowSize && params.windowUnit) { return callApmApi({ endpoint: 'GET /api/apm/alerts/chart_preview/transaction_duration', params: { query: { - ...getAbsoluteTimeRange(windowSize, windowUnit), - aggregationType, - environment, - serviceName, - transactionType: alertParams.transactionType, + ...getAbsoluteTimeRange(params.windowSize, params.windowUnit), + aggregationType: params.aggregationType, + environment: params.environment, + serviceName: params.serviceName, + transactionType: params.transactionType, }, }, }); } }, [ - aggregationType, - environment, - serviceName, - alertParams.transactionType, - windowSize, - windowUnit, + params.aggregationType, + params.environment, + params.serviceName, + params.transactionType, + params.windowSize, + params.windowUnit, ] ); @@ -122,7 +135,7 @@ export function TransactionDurationAlertTrigger(props: Props) { const yTickFormat = getResponseTimeTickFormatter(formatter); // The threshold from the form is in ms. Convert to µs. - const thresholdMs = threshold * 1000; + const thresholdMs = params.threshold * 1000; const chartPreview = ( ); - if (!transactionTypes.length || !serviceName) { + if (!transactionTypes.length || !params.serviceName) { return null; } - const defaults = { - threshold: 1500, - aggregationType: 'avg', - windowSize: 5, - windowUnit: 'm', - transactionType, - environment: urlParams.environment || ENVIRONMENT_ALL.value, - }; - - const params = { - ...defaults, - ...alertParams, - }; - const fields = [ - , + , ({ text: key, value: key }))} @@ -206,7 +205,7 @@ export function TransactionDurationAlertTrigger(props: Props) { return ( void; setAlertProperty: (key: string, value: any) => void; } @@ -47,35 +47,36 @@ interface Props { export function TransactionDurationAnomalyAlertTrigger(props: Props) { const { setAlertParams, alertParams, setAlertProperty } = props; const { urlParams } = useUrlParams(); - const { transactionTypes } = useApmServiceContext(); - const { serviceName } = useParams<{ serviceName?: string }>(); - const { start, end, transactionType } = urlParams; + const { + serviceName: serviceNameFromContext, + transactionType: transactionTypeFromContext, + transactionTypes, + } = useApmServiceContext(); + + const { start, end, environment: environmentFromUrl } = urlParams; + + const params = defaults( + { + ...alertParams, + }, + { + windowSize: 15, + windowUnit: 'm', + transactionType: transactionTypeFromContext, + environment: environmentFromUrl || ENVIRONMENT_ALL.value, + anomalySeverityType: ANOMALY_SEVERITY.CRITICAL, + serviceName: serviceNameFromContext, + } + ); + const { environmentOptions } = useEnvironmentsFetcher({ - serviceName, + serviceName: params.serviceName, start, end, }); - if (serviceName && !transactionTypes.length) { - return null; - } - - const defaults: Params = { - windowSize: 15, - windowUnit: 'm', - transactionType: transactionType || transactionTypes[0], - serviceName, - environment: urlParams.environment || ENVIRONMENT_ALL.value, - anomalySeverityType: ANOMALY_SEVERITY.CRITICAL, - }; - - const params = { - ...defaults, - ...alertParams, - }; - const fields = [ - , + , ({ text: key, value: key }))} @@ -107,7 +108,7 @@ export function TransactionDurationAnomalyAlertTrigger(props: Props) { return ( 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/transaction_error_rate_alert_trigger/index.tsx index c6f9c4efd98b6..4eb0b0e797571 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_error_rate_alert_trigger/index.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { useParams } from 'react-router-dom'; +import { defaults } from 'lodash'; import { ForLastExpression } from '../../../../../triggers_actions_ui/public'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { asPercent } from '../../../../common/utils/formatters'; @@ -42,63 +42,65 @@ interface Props { export function TransactionErrorRateAlertTrigger(props: Props) { const { setAlertParams, alertParams, setAlertProperty } = props; const { urlParams } = useUrlParams(); - const { transactionTypes } = useApmServiceContext(); - const { serviceName } = useParams<{ serviceName?: string }>(); - const { start, end, transactionType } = urlParams; + const { + transactionType: transactionTypeFromContext, + transactionTypes, + serviceName: serviceNameFromContext, + } = useApmServiceContext(); + + const { start, end, environment: environmentFromUrl } = urlParams; + + const params = defaults, AlertParams>( + { + threshold: 30, + windowSize: 5, + windowUnit: 'm', + transactionType: transactionTypeFromContext, + environment: environmentFromUrl || ENVIRONMENT_ALL.value, + serviceName: serviceNameFromContext, + }, + alertParams + ); + const { environmentOptions } = useEnvironmentsFetcher({ - serviceName, + serviceName: serviceNameFromContext, start, end, }); - const { threshold, windowSize, windowUnit, environment } = alertParams; - - const thresholdAsPercent = (threshold ?? 0) / 100; + const thresholdAsPercent = (params.threshold ?? 0) / 100; const { data } = useFetcher( (callApmApi) => { - if (windowSize && windowUnit) { + if (params.windowSize && params.windowUnit) { return callApmApi({ endpoint: 'GET /api/apm/alerts/chart_preview/transaction_error_rate', params: { query: { - ...getAbsoluteTimeRange(windowSize, windowUnit), - environment, - serviceName, - transactionType: alertParams.transactionType, + ...getAbsoluteTimeRange(params.windowSize, params.windowUnit), + environment: params.environment, + serviceName: params.serviceName, + transactionType: params.transactionType, }, }, }); } }, [ - alertParams.transactionType, - environment, - serviceName, - windowSize, - windowUnit, + params.transactionType, + params.environment, + params.serviceName, + params.windowSize, + params.windowUnit, ] ); - if (serviceName && !transactionTypes.length) { + if (params.serviceName && !transactionTypes.length) { return null; } - const defaultParams = { - threshold: 30, - windowSize: 5, - windowUnit: 'm', - transactionType: transactionType || transactionTypes[0], - environment: urlParams.environment || ENVIRONMENT_ALL.value, - }; - - const params = { - ...defaultParams, - ...alertParams, - }; - const fields = [ - , + , ({ text: key, value: key }))} @@ -141,7 +143,7 @@ export function TransactionErrorRateAlertTrigger(props: Props) { return ( {storyFn()}) - .add( - 'Tooltip', - () => { - const loadFeatureProps = async () => { - return [ +storiesOf('app/RumDashboard/VisitorsRegionMap', module).add( + 'Tooltip', + () => { + const loadFeatureProps = async () => { + return [ + { + getPropertyKey: () => COUNTRY_NAME, + getRawValue: () => 'United States', + }, + { + getPropertyKey: () => TRANSACTION_DURATION_COUNTRY, + getRawValue: () => 2434353, + }, + ]; + }; + return ( + COUNTRY_NAME, - getRawValue: () => 'United States', - }, - { - getPropertyKey: () => TRANSACTION_DURATION_COUNTRY, - getRawValue: () => 2434353, - }, - ]; - }; - return ( - - ); + actions: [], + }, + ]} + /> + ); + }, + { + info: { + propTables: false, + source: false, }, - { - info: { - propTables: false, - source: false, - }, - } - ); + } +); diff --git a/x-pack/plugins/apm/public/components/app/Settings/agent_configurations/AgentConfigurationCreateEdit/index.stories.tsx b/x-pack/plugins/apm/public/components/app/Settings/agent_configurations/AgentConfigurationCreateEdit/index.stories.tsx index cd5fa5db89a31..02ecf902f00a3 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/agent_configurations/AgentConfigurationCreateEdit/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/agent_configurations/AgentConfigurationCreateEdit/index.stories.tsx @@ -8,7 +8,6 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; import { CoreStart } from 'kibana/public'; -import { EuiThemeProvider } from '../../../../../../../../../src/plugins/kibana_react/common'; import { AgentConfiguration } from '../../../../../../common/agent_configuration/configuration_types'; import { FETCH_STATUS } from '../../../../../hooks/use_fetcher'; import { createCallApmApi } from '../../../../../services/rest/createCallApmApi'; @@ -37,13 +36,11 @@ storiesOf( }; return ( - - - {storyFn()} - - + + {storyFn()} + ); }) .add( @@ -67,7 +64,6 @@ storiesOf( propTablesExclude: [ AgentConfigurationCreateEdit, ApmPluginContext.Provider, - EuiThemeProvider, ], source: false, }, diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/confirm_switch_modal.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/confirm_switch_modal.tsx index 9900093253d2a..04817aaf84f3e 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/schema/confirm_switch_modal.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/confirm_switch_modal.tsx @@ -21,13 +21,11 @@ interface Props { onConfirm: () => void; onCancel: () => void; unsupportedConfigs: Array<{ key: string; value: string }>; - isLoading: boolean; } export function ConfirmSwitchModal({ onConfirm, onCancel, unsupportedConfigs, - isLoading, }: Props) { const [isConfirmChecked, setIsConfirmChecked] = useState(false); const hasUnsupportedConfigs = !!unsupportedConfigs.length; @@ -52,7 +50,6 @@ export function ConfirmSwitchModal({ defaultFocusedButton="confirm" onConfirm={onConfirm} confirmButtonDisabled={!isConfirmChecked} - isLoading={isLoading} >

{i18n.translate('xpack.apm.settings.schema.confirm.descriptionText', { @@ -135,7 +132,6 @@ export function ConfirmSwitchModal({ onChange={(e) => { setIsConfirmChecked(e.target.checked); }} - disabled={isLoading} />

diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/index.tsx index fee072470f05a..5a67ce28e9e1a 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/schema/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/index.tsx @@ -8,6 +8,8 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { NotificationsStart } from 'kibana/public'; +import moment from 'moment'; +import { useLocalStorage } from '../../../../hooks/useLocalStorage'; import { SchemaOverview } from './schema_overview'; import { ConfirmSwitchModal } from './confirm_switch_modal'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; @@ -19,10 +21,22 @@ import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plug type FleetMigrationCheckResponse = APIReturnType<'GET /api/apm/fleet/migration_check'>; +const APM_DATA_STREAMS_MIGRATION_STATUS_LS = { + value: '', + expiry: '', +}; + export function Schema() { + const [ + apmDataStreamsMigrationStatus, + setApmDataStreamsMigrationStatus, + ] = useLocalStorage( + 'apm.dataStreamsMigrationStatus', + APM_DATA_STREAMS_MIGRATION_STATUS_LS + ); + const { toasts } = useApmPluginContext().core.notifications; const [isSwitchActive, setIsSwitchActive] = useState(false); - const [isLoadingMigration, setIsLoadingMigration] = useState(false); const [isLoadingConfirmation, setIsLoadingConfirmation] = useState(false); const [unsupportedConfigs, setUnsupportedConfigs] = useState< Array<{ key: string; value: any }> @@ -42,6 +56,19 @@ export function Schema() { const hasCloudAgentPolicy = !!data.has_cloud_agent_policy; const hasCloudApmPackagePolicy = !!data.has_cloud_apm_package_policy; const hasRequiredRole = !!data.has_required_role; + + function updateLocalStorage(newStatus: FETCH_STATUS) { + setApmDataStreamsMigrationStatus({ + value: newStatus, + expiry: moment().add(5, 'minutes').toISOString(), + }); + } + + const { value: localStorageValue, expiry } = apmDataStreamsMigrationStatus; + const isMigrating = + localStorageValue === FETCH_STATUS.LOADING && + moment(expiry).valueOf() > moment.now(); + return ( <> {isSwitchActive && ( { - setIsLoadingMigration(true); - const apmPackagePolicy = await createCloudApmPackagePolicy(toasts); + setIsSwitchActive(false); + const apmPackagePolicy = await createCloudApmPackagePolicy( + toasts, + updateLocalStorage + ); if (!apmPackagePolicy) { - setIsLoadingMigration(false); return; } - setIsSwitchActive(false); refetch(); }} onCancel={() => { - if (isLoadingMigration) { - return; - } setIsSwitchActive(false); }} unsupportedConfigs={unsupportedConfigs} @@ -112,8 +137,10 @@ async function getUnsupportedApmServerConfigs( } async function createCloudApmPackagePolicy( - toasts: NotificationsStart['toasts'] + toasts: NotificationsStart['toasts'], + updateLocalStorage: (status: FETCH_STATUS) => void ) { + updateLocalStorage(FETCH_STATUS.LOADING); try { const { cloud_apm_package_policy: cloudApmPackagePolicy, @@ -121,8 +148,10 @@ async function createCloudApmPackagePolicy( endpoint: 'POST /api/apm/fleet/cloud_apm_package_policy', signal: null, }); + updateLocalStorage(FETCH_STATUS.SUCCESS); return cloudApmPackagePolicy; } catch (error) { + updateLocalStorage(FETCH_STATUS.FAILURE); toasts.addDanger({ title: i18n.translate( 'xpack.apm.settings.createApmPackagePolicy.errorToast.title', diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/migration_in_progress_panel.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/migration_in_progress_panel.tsx new file mode 100644 index 0000000000000..854d1dd823d23 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/migration_in_progress_panel.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup } from '@elastic/eui'; +import { EuiFlexItem } from '@elastic/eui'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { EuiCard } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export function MigrationInProgressPanel() { + return ( + + + } + title={i18n.translate( + 'xpack.apm.settings.schema.migrationInProgressPanelTitle', + { defaultMessage: 'Switching to data streams...' } + )} + description={i18n.translate( + 'xpack.apm.settings.schema.migrationInProgressPanelDescription', + { + defaultMessage: + "We're now creating a Fleet Server instance to contain the new APM Server while shutting down the old APM server instance. Within minutes you should see your data pour into the app again.", + } + )} + /> + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/schema.stories.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/schema.stories.tsx index 7cac4ba97e723..b22260ffabe46 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/schema/schema.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/schema.stories.tsx @@ -5,31 +5,99 @@ * 2.0. */ -import type { Story } from '@storybook/react'; +import type { Meta, Story } from '@storybook/react'; import React, { ComponentType } from 'react'; +import { MemoryRouter } from 'react-router-dom'; import { CoreStart } from '../../../../../../../../src/core/public'; import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; import { createCallApmApi } from '../../../../services/rest/createCallApmApi'; import { Schema } from './'; +interface Args { + hasCloudAgentPolicy: boolean; + hasCloudApmPackagePolicy: boolean; + cloudApmMigrationEnabled: boolean; + hasRequiredRole: boolean; + isMigrating: boolean; +} + export default { title: 'app/Settings/Schema', component: Schema, + argTypes: { + hasCloudAgentPolicy: { + control: { + type: 'boolean', + options: [true, false], + defaultValue: true, + }, + }, + hasCloudApmPackagePolicy: { + control: { + type: 'boolean', + options: [true, false], + defaultValue: false, + }, + }, + cloudApmMigrationEnabled: { + control: { + type: 'boolean', + options: [true, false], + defaultValue: true, + }, + }, + hasRequiredRole: { + control: { + type: 'boolean', + options: [true, false], + defaultValue: true, + }, + }, + isMigrating: { + control: { + type: 'boolean', + options: [true, false], + defaultValue: false, + }, + }, + }, decorators: [ - (StoryComponent: ComponentType) => { + (StoryComponent: ComponentType, { args }: Meta) => { + if (args?.isMigrating) { + const expiryDate = new Date(); + expiryDate.setMinutes(expiryDate.getMinutes() + 5); + window.localStorage.setItem( + 'apm.dataStreamsMigrationStatus', + JSON.stringify({ + value: 'loading', + expiry: expiryDate.toISOString(), + }) + ); + } else { + window.localStorage.removeItem('apm.dataStreamsMigrationStatus'); + } const coreMock = ({ http: { + basePath: { prepend: () => {} }, get: () => { - return {}; + return { + has_cloud_agent_policy: args?.hasCloudAgentPolicy, + has_cloud_apm_package_policy: args?.hasCloudApmPackagePolicy, + cloud_apm_migration_enabled: args?.cloudApmMigrationEnabled, + has_required_role: args?.hasRequiredRole, + }; }, }, + uiSettings: { get: () => '' }, } as unknown) as CoreStart; createCallApmApi(coreMock); return ( - + + + ); }, diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx index 7a874ed5b8037..a9a0b824cc828 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx @@ -7,7 +7,6 @@ import { EuiButton, - EuiCallOut, EuiCard, EuiFlexGroup, EuiFlexItem, @@ -24,9 +23,11 @@ import { APMLink } from '../../../shared/Links/apm/APMLink'; import { ElasticDocsLink } from '../../../shared/Links/ElasticDocsLink'; import { useFleetCloudAgentPolicyHref } from '../../../shared/Links/kibana'; import rocketLaunchGraphic from './blog-rocket-720x420.png'; +import { MigrationInProgressPanel } from './migration_in_progress_panel'; interface Props { onSwitch: () => void; + isMigrating: boolean; isMigrated: boolean; isLoading: boolean; isLoadingConfirmation: boolean; @@ -36,6 +37,7 @@ interface Props { } export function SchemaOverview({ onSwitch, + isMigrating, isMigrated, isLoading, isLoadingConfirmation, @@ -58,6 +60,15 @@ export function SchemaOverview({ ); } + if (isMigrating && !isMigrated) { + return ( + <> + + + + ); + } + if (isMigrated) { return ( <> @@ -125,7 +136,7 @@ export function SchemaOverview({ - + } title={i18n.translate( @@ -154,7 +165,7 @@ export function SchemaOverview({ } /> - + - - - - - -

- {i18n.translate( - 'xpack.apm.settings.schema.migrate.calloutNote.message', - { - defaultMessage: - 'If you have custom dashboards, machine learning jobs, or source maps that use classic APM indices, you must reconfigure them for data streams.', - } - )} -

-
-
- -
); } diff --git a/x-pack/plugins/apm/public/components/app/error_group_details/Distribution/index.stories.tsx b/x-pack/plugins/apm/public/components/app/error_group_details/Distribution/index.stories.tsx index 8cc16dd801c25..d434a155c9cf4 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_details/Distribution/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_details/Distribution/index.stories.tsx @@ -11,7 +11,6 @@ import { ApmPluginContext, ApmPluginContextValue, } from '../../../../context/apm_plugin/apm_plugin_context'; -import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; import { ErrorDistribution } from './'; export default { @@ -28,13 +27,11 @@ export default { }; return ( - - - - - - - + + + + + ); }, ], diff --git a/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/exception_stacktrace.stories.tsx b/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/exception_stacktrace.stories.tsx index f21c189584d31..9468202edf4d6 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/exception_stacktrace.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_details/detail_view/exception_stacktrace.stories.tsx @@ -7,7 +7,6 @@ import { Story } from '@storybook/react'; import React, { ComponentProps, ComponentType } from 'react'; -import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; import { ExceptionStacktrace } from './exception_stacktrace'; type Args = ComponentProps; @@ -15,13 +14,6 @@ type Args = ComponentProps; export default { title: 'app/ErrorGroupDetails/DetailView/ExceptionStacktrace', component: ExceptionStacktrace, - decorators: [ - (StoryComponent: ComponentType) => ( - - - - ), - ], }; export const JavaWithLongLines: Story = (args) => ( diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx index 6b7626514d03f..324a38ea5db39 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/Popover.stories.tsx @@ -8,7 +8,6 @@ import cytoscape from 'cytoscape'; import { CoreStart } from 'kibana/public'; import React, { ComponentType } from 'react'; -import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; import { MockUrlParamsContextProvider } from '../../../../context/url_params_context/mock_url_params_context_provider'; import { createCallApmApi } from '../../../../services/rest/createCallApmApi'; @@ -38,15 +37,13 @@ export default { createCallApmApi(coreMock); return ( - - - -
- -
-
-
-
+ + +
+ +
+
+
); }, ], diff --git a/x-pack/plugins/apm/public/components/app/service_map/Popover/service_stats_list.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/Popover/service_stats_list.stories.tsx index a8f004a7295d9..f1a89043f826e 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/Popover/service_stats_list.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/Popover/service_stats_list.stories.tsx @@ -5,20 +5,12 @@ * 2.0. */ -import React, { ComponentType } from 'react'; -import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; +import React from 'react'; import { ServiceStatsList } from './ServiceStatsList'; export default { title: 'app/ServiceMap/Popover/ServiceStatsList', component: ServiceStatsList, - decorators: [ - (Story: ComponentType) => ( - - - - ), - ], }; export function Example() { diff --git a/x-pack/plugins/apm/public/components/app/service_map/__stories__/Cytoscape.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/__stories__/Cytoscape.stories.tsx index 8bc0d7239e9c5..7ce9c3e943613 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/__stories__/Cytoscape.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/__stories__/Cytoscape.stories.tsx @@ -6,21 +6,13 @@ */ import cytoscape from 'cytoscape'; -import React, { ComponentType } from 'react'; -import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; +import React from 'react'; import { Cytoscape } from '../Cytoscape'; import { Centerer } from './centerer'; export default { title: 'app/ServiceMap/Cytoscape', component: Cytoscape, - decorators: [ - (Story: ComponentType) => ( - - - - ), - ], }; export function Example() { diff --git a/x-pack/plugins/apm/public/components/app/service_map/__stories__/cytoscape_example_data.stories.tsx b/x-pack/plugins/apm/public/components/app/service_map/__stories__/cytoscape_example_data.stories.tsx index 45de632a152d4..192447ef7591a 100644 --- a/x-pack/plugins/apm/public/components/app/service_map/__stories__/cytoscape_example_data.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/service_map/__stories__/cytoscape_example_data.stories.tsx @@ -16,8 +16,7 @@ import { EuiSpacer, EuiToolTip, } from '@elastic/eui'; -import React, { ComponentType, useEffect, useState } from 'react'; -import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; +import React, { useEffect, useState } from 'react'; import { Cytoscape } from '../Cytoscape'; import { Centerer } from './centerer'; import exampleResponseHipsterStore from './example_response_hipster_store.json'; @@ -42,13 +41,6 @@ function getHeight() { export default { title: 'app/ServiceMap/Example data', component: Cytoscape, - decorators: [ - (Story: ComponentType) => ( - - - - ), - ], }; export function GenerateMap() { diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/WaterfallContainer.stories.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/WaterfallContainer.stories.tsx index 5ea2fca2dfa32..20ca3194fbfdf 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/WaterfallContainer.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/WaterfallContainer.stories.tsx @@ -7,7 +7,6 @@ import React, { ComponentType } from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { EuiThemeProvider } from '../../../../../../../../../src/plugins/kibana_react/common'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { TraceAPIResponse } from '../../../../../../server/lib/traces/get_trace'; import { MockApmPluginContextWrapper } from '../../../../../context/apm_plugin/mock_apm_plugin_context'; @@ -27,11 +26,9 @@ export default { decorators: [ (Story: ComponentType) => ( - - - - - + + + ), ], diff --git a/x-pack/plugins/apm/public/components/shared/agent_icon/agent_icon.stories.tsx b/x-pack/plugins/apm/public/components/shared/agent_icon/agent_icon.stories.tsx index bc41fd58ea5d2..68c3edabfa44e 100644 --- a/x-pack/plugins/apm/public/components/shared/agent_icon/agent_icon.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/agent_icon/agent_icon.stories.tsx @@ -7,30 +7,22 @@ import { EuiCard, + EuiCodeBlock, EuiFlexGroup, - EuiImage, EuiFlexItem, + EuiImage, EuiSpacer, EuiToolTip, - EuiCodeBlock, } from '@elastic/eui'; -import React, { ComponentType } from 'react'; -import { AgentIcon } from './index'; -import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; +import React from 'react'; import { AGENT_NAMES } from '../../../../common/agent_name'; -import { getAgentIcon } from './get_agent_icon'; import { useTheme } from '../../../hooks/use_theme'; +import { getAgentIcon } from './get_agent_icon'; +import { AgentIcon } from './index'; export default { title: 'shared/icons', component: AgentIcon, - decorators: [ - (Story: ComponentType) => ( - - - - ), - ], }; export function AgentIcons() { diff --git a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx index 86f0d3fde1cd5..af207f54200f9 100644 --- a/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/apm_header_action_menu/index.tsx @@ -8,16 +8,16 @@ import { EuiHeaderLink, EuiHeaderLinks } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { useParams } from 'react-router-dom'; import { getAlertingCapabilities } from '../../alerting/get_alerting_capabilities'; import { getAPMHref } from '../Links/apm/APMLink'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { AlertingPopoverAndFlyout } from './alerting_popover_flyout'; import { AnomalyDetectionSetupLink } from './anomaly_detection_setup_link'; +import { useServiceName } from '../../../hooks/use_service_name'; export function ApmHeaderActionMenu() { const { core, plugins } = useApmPluginContext(); - const { serviceName } = useParams<{ serviceName?: string }>(); + const serviceName = useServiceName(); const { search } = window.location; const { application, http } = core; const { basePath } = http; diff --git a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.stories.tsx b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.stories.tsx index 0eb5b0e84ff39..6128526c577e4 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/custom_tooltip.stories.tsx @@ -6,8 +6,7 @@ */ import { TooltipInfo } from '@elastic/charts'; -import React, { ComponentType } from 'react'; -import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; +import React from 'react'; import { getDurationFormatter } from '../../../../../common/utils/formatters'; import { MainStatsServiceInstanceItem } from '../../../app/service_overview/service_overview_instances_chart_and_table'; import { CustomTooltip } from './custom_tooltip'; @@ -25,13 +24,6 @@ function getLatencyFormatter(props: TooltipInfo) { export default { title: 'shared/charts/InstancesLatencyDistributionChart/CustomTooltip', component: CustomTooltip, - decorators: [ - (Story: ComponentType) => ( - - - - ), - ], }; export function Example(props: TooltipInfo) { diff --git a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/instances_latency_distribution_chart.stories.tsx b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/instances_latency_distribution_chart.stories.tsx index c574645d485d5..80bfcca05aabc 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/instances_latency_distribution_chart.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/instances_latency_distribution_chart.stories.tsx @@ -5,8 +5,7 @@ * 2.0. */ -import React, { ComponentType } from 'react'; -import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; +import React from 'react'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { InstancesLatencyDistributionChart, @@ -16,13 +15,6 @@ import { export default { title: 'shared/charts/InstancesLatencyDistributionChart', component: InstancesLatencyDistributionChart, - decorators: [ - (Story: ComponentType) => ( - - - - ), - ], }; export function Example({ items }: InstancesLatencyDistributionChartProps) { diff --git a/x-pack/plugins/apm/public/components/shared/charts/latency_chart/latency_chart.stories.tsx b/x-pack/plugins/apm/public/components/shared/charts/latency_chart/latency_chart.stories.tsx index d1dcd831eadd7..ff2b95667a63a 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/latency_chart/latency_chart.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/latency_chart/latency_chart.stories.tsx @@ -8,7 +8,6 @@ import { StoryContext } from '@storybook/react'; import React, { ComponentType } from 'react'; import { MemoryRouter, Route } from 'react-router-dom'; -import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public'; import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; import { @@ -76,20 +75,18 @@ export default { - - - - - - - - - + + + + + + + diff --git a/x-pack/plugins/apm/public/components/shared/span_icon/span_icon.stories.tsx b/x-pack/plugins/apm/public/components/shared/span_icon/span_icon.stories.tsx index b053f441e9632..7d2e2fbefc359 100644 --- a/x-pack/plugins/apm/public/components/shared/span_icon/span_icon.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/span_icon/span_icon.stories.tsx @@ -6,32 +6,23 @@ */ import { - EuiImage, EuiCard, + EuiCodeBlock, EuiFlexGroup, EuiFlexItem, + EuiImage, EuiSpacer, - EuiCodeBlock, EuiToolTip, } from '@elastic/eui'; -import React, { ComponentType } from 'react'; -import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; +import React from 'react'; +import { getSpanIcon, spanTypeIcons } from './get_span_icon'; import { SpanIcon } from './index'; -import { getSpanIcon } from './get_span_icon'; -import { spanTypeIcons } from './get_span_icon'; const spanTypes = Object.keys(spanTypeIcons); export default { title: 'shared/icons', component: SpanIcon, - decorators: [ - (Story: ComponentType) => ( - - - - ), - ], }; export function SpanIcons() { diff --git a/x-pack/plugins/apm/public/context/apm_service/apm_service_context.tsx b/x-pack/plugins/apm/public/context/apm_service/apm_service_context.tsx index 54914580aefbd..cb826763425c2 100644 --- a/x-pack/plugins/apm/public/context/apm_service/apm_service_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_service/apm_service_context.tsx @@ -18,6 +18,7 @@ import { useServiceAgentNameFetcher } from './use_service_agent_name_fetcher'; import { IUrlParams } from '../url_params_context/types'; import { APIReturnType } from '../../services/rest/createCallApmApi'; import { useServiceAlertsFetcher } from './use_service_alerts_fetcher'; +import { useServiceName } from '../../hooks/use_service_name'; export type APMServiceAlert = ValuesType< APIReturnType<'GET /api/apm/services/{serviceName}/alerts'>['alerts'] @@ -28,6 +29,7 @@ export const APMServiceContext = createContext<{ transactionType?: string; transactionTypes: string[]; alerts: APMServiceAlert[]; + serviceName?: string; }>({ transactionTypes: [], alerts: [] }); export function ApmServiceContextProvider({ @@ -36,9 +38,12 @@ export function ApmServiceContextProvider({ children: ReactNode; }) { const { urlParams } = useUrlParams(); - const { agentName } = useServiceAgentNameFetcher(); - const transactionTypes = useServiceTransactionTypesFetcher(); + const serviceName = useServiceName(); + + const { agentName } = useServiceAgentNameFetcher(serviceName); + + const transactionTypes = useServiceTransactionTypesFetcher(serviceName); const transactionType = getTransactionType({ urlParams, @@ -46,7 +51,7 @@ export function ApmServiceContextProvider({ agentName, }); - const { alerts } = useServiceAlertsFetcher(transactionType); + const { alerts } = useServiceAlertsFetcher({ serviceName, transactionType }); return ( diff --git a/x-pack/plugins/apm/public/context/apm_service/use_service_agent_name_fetcher.ts b/x-pack/plugins/apm/public/context/apm_service/use_service_agent_name_fetcher.ts index ceb6767898f06..82198eb73b3cb 100644 --- a/x-pack/plugins/apm/public/context/apm_service/use_service_agent_name_fetcher.ts +++ b/x-pack/plugins/apm/public/context/apm_service/use_service_agent_name_fetcher.ts @@ -5,12 +5,10 @@ * 2.0. */ -import { useParams } from 'react-router-dom'; import { useFetcher } from '../../hooks/use_fetcher'; import { useUrlParams } from '../url_params_context/use_url_params'; -export function useServiceAgentNameFetcher() { - const { serviceName } = useParams<{ serviceName?: string }>(); +export function useServiceAgentNameFetcher(serviceName?: string) { const { urlParams } = useUrlParams(); const { start, end } = urlParams; const { data, error, status } = useFetcher( diff --git a/x-pack/plugins/apm/public/context/apm_service/use_service_alerts_fetcher.tsx b/x-pack/plugins/apm/public/context/apm_service/use_service_alerts_fetcher.tsx index b07e6562a2154..54c95319afea2 100644 --- a/x-pack/plugins/apm/public/context/apm_service/use_service_alerts_fetcher.tsx +++ b/x-pack/plugins/apm/public/context/apm_service/use_service_alerts_fetcher.tsx @@ -5,13 +5,18 @@ * 2.0. */ -import { useParams } from 'react-router-dom'; import { useApmPluginContext } from '../apm_plugin/use_apm_plugin_context'; import { useUrlParams } from '../url_params_context/use_url_params'; import { useFetcher } from '../../hooks/use_fetcher'; import type { APMServiceAlert } from './apm_service_context'; -export function useServiceAlertsFetcher(transactionType?: string) { +export function useServiceAlertsFetcher({ + serviceName, + transactionType, +}: { + serviceName?: string; + transactionType?: string; +}) { const { plugins: { observability }, } = useApmPluginContext(); @@ -19,7 +24,6 @@ export function useServiceAlertsFetcher(transactionType?: string) { const { urlParams: { start, end, environment }, } = useUrlParams(); - const { serviceName } = useParams<{ serviceName?: string }>(); const experimentalAlertsEnabled = observability.isAlertingExperienceEnabled(); diff --git a/x-pack/plugins/apm/public/context/apm_service/use_service_transaction_types_fetcher.tsx b/x-pack/plugins/apm/public/context/apm_service/use_service_transaction_types_fetcher.tsx index ba70295ae70ca..b22c233b0c24b 100644 --- a/x-pack/plugins/apm/public/context/apm_service/use_service_transaction_types_fetcher.tsx +++ b/x-pack/plugins/apm/public/context/apm_service/use_service_transaction_types_fetcher.tsx @@ -5,14 +5,12 @@ * 2.0. */ -import { useParams } from 'react-router-dom'; import { useFetcher } from '../../hooks/use_fetcher'; import { useUrlParams } from '../url_params_context/use_url_params'; const INITIAL_DATA = { transactionTypes: [] }; -export function useServiceTransactionTypesFetcher() { - const { serviceName } = useParams<{ serviceName?: string }>(); +export function useServiceTransactionTypesFetcher(serviceName?: string) { const { urlParams } = useUrlParams(); const { start, end } = urlParams; const { data = INITIAL_DATA } = useFetcher( diff --git a/x-pack/plugins/apm/public/hooks/use_service_name.tsx b/x-pack/plugins/apm/public/hooks/use_service_name.tsx new file mode 100644 index 0000000000000..c003bf5223a32 --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_service_name.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useRouteMatch } from 'react-router-dom'; + +export function useServiceName(): string | undefined { + const match = useRouteMatch<{ serviceName?: string }>( + '/services/:serviceName' + ); + + return match ? match.params.serviceName : undefined; +} diff --git a/x-pack/plugins/apm/server/feature.ts b/x-pack/plugins/apm/server/feature.ts index fb0610dffb92e..f3e2bba2d9789 100644 --- a/x-pack/plugins/apm/server/feature.ts +++ b/x-pack/plugins/apm/server/feature.ts @@ -6,8 +6,9 @@ */ import { i18n } from '@kbn/i18n'; +import { SubFeaturePrivilegeGroupType } from '../../features/common'; import { LicenseType } from '../../licensing/common/types'; -import { AlertType } from '../common/alert_types'; +import { AlertType, APM_SERVER_FEATURE_ID } from '../common/alert_types'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; import { LicensingPluginSetup, @@ -15,14 +16,14 @@ import { } from '../../licensing/server'; export const APM_FEATURE = { - id: 'apm', + id: APM_SERVER_FEATURE_ID, name: i18n.translate('xpack.apm.featureRegistry.apmFeatureName', { defaultMessage: 'APM and User Experience', }), order: 900, category: DEFAULT_APP_CATEGORIES.observability, - app: ['apm', 'ux', 'kibana'], - catalogue: ['apm'], + app: [APM_SERVER_FEATURE_ID, 'ux', 'kibana'], + catalogue: [APM_SERVER_FEATURE_ID], management: { insightsAndAlerting: ['triggersActions'], }, @@ -30,9 +31,9 @@ export const APM_FEATURE = { // see x-pack/plugins/features/common/feature_kibana_privileges.ts privileges: { all: { - app: ['apm', 'ux', 'kibana'], - api: ['apm', 'apm_write'], - catalogue: ['apm'], + app: [APM_SERVER_FEATURE_ID, 'ux', 'kibana'], + api: [APM_SERVER_FEATURE_ID, 'apm_write', 'rac'], + catalogue: [APM_SERVER_FEATURE_ID], savedObject: { all: [], read: [], @@ -41,9 +42,6 @@ export const APM_FEATURE = { rule: { all: Object.values(AlertType), }, - alert: { - all: Object.values(AlertType), - }, }, management: { insightsAndAlerting: ['triggersActions'], @@ -51,9 +49,9 @@ export const APM_FEATURE = { ui: ['show', 'save', 'alerting:show', 'alerting:save'], }, read: { - app: ['apm', 'ux', 'kibana'], - api: ['apm'], - catalogue: ['apm'], + app: [APM_SERVER_FEATURE_ID, 'ux', 'kibana'], + api: [APM_SERVER_FEATURE_ID, 'rac'], + catalogue: [APM_SERVER_FEATURE_ID], savedObject: { all: [], read: [], @@ -62,9 +60,6 @@ export const APM_FEATURE = { rule: { read: Object.values(AlertType), }, - alert: { - read: Object.values(AlertType), - }, }, management: { insightsAndAlerting: ['triggersActions'], @@ -72,6 +67,60 @@ export const APM_FEATURE = { ui: ['show', 'alerting:show', 'alerting:save'], }, }, + subFeatures: [ + { + name: i18n.translate('xpack.apm.featureRegistry.manageAlertsName', { + defaultMessage: 'Alerts', + }), + privilegeGroups: [ + { + groupType: 'mutually_exclusive' as SubFeaturePrivilegeGroupType, + privileges: [ + { + id: 'alerts_all', + name: i18n.translate( + 'xpack.apm.featureRegistry.subfeature.alertsAllName', + { + defaultMessage: 'All', + } + ), + includeIn: 'all' as 'all', + alerting: { + alert: { + all: Object.values(AlertType), + }, + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + { + id: 'alerts_read', + name: i18n.translate( + 'xpack.apm.featureRegistry.subfeature.alertsReadName', + { + defaultMessage: 'Read', + } + ), + includeIn: 'read' as 'read', + alerting: { + alert: { + read: Object.values(AlertType), + }, + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + ], + }, + ], + }, + ], }; interface Feature { diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 8ec92bfa7a1b5..f14894a76edb4 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -125,6 +125,7 @@ export function mergeConfigs( export const plugin = (initContext: PluginInitializerContext) => new APMPlugin(initContext); +export { APM_SERVER_FEATURE_ID } from '../common/alert_types'; export { APMPlugin } from './plugin'; export { APMPluginSetup } from './types'; export { APMServerRouteRepository } from './routes/get_global_apm_server_route_repository'; diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts index 7548d6eba060a..35c80df2ca31c 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts @@ -17,7 +17,11 @@ import { getEnvironmentEsField, getEnvironmentLabel, } from '../../../common/environment_filter_values'; -import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; +import { + AlertType, + APM_SERVER_FEATURE_ID, + ALERT_TYPES_CONFIG, +} from '../../../common/alert_types'; import { PROCESSOR_EVENT, SERVICE_ENVIRONMENT, @@ -69,7 +73,7 @@ export function registerErrorCountAlertType({ apmActionVariables.interval, ], }, - producer: 'apm', + producer: APM_SERVER_FEATURE_ID, minimumLicenseRequired: 'basic', isExportable: true, executor: async ({ services, params }) => { diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts index ca7806251f75e..ff202669fe1da 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts @@ -17,7 +17,11 @@ import { getEnvironmentLabel, getEnvironmentEsField, } from '../../../common/environment_filter_values'; -import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; +import { + AlertType, + APM_SERVER_FEATURE_ID, + ALERT_TYPES_CONFIG, +} from '../../../common/alert_types'; import { PROCESSOR_EVENT, SERVICE_NAME, @@ -77,7 +81,7 @@ export function registerTransactionDurationAlertType({ apmActionVariables.interval, ], }, - producer: 'apm', + producer: APM_SERVER_FEATURE_ID, minimumLicenseRequired: 'basic', isExportable: true, executor: async ({ services, params }) => { diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts index 718ffd9c92167..36fd9c3fac58d 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts @@ -17,7 +17,11 @@ import { getEnvironmentLabel, } from '../../../common/environment_filter_values'; import { createLifecycleRuleTypeFactory } from '../../../../rule_registry/server'; -import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types'; +import { + AlertType, + ALERT_TYPES_CONFIG, + APM_SERVER_FEATURE_ID, +} from '../../../common/alert_types'; import { EVENT_OUTCOME, PROCESSOR_EVENT, @@ -75,7 +79,7 @@ export function registerTransactionErrorRateAlertType({ apmActionVariables.interval, ], }, - producer: 'apm', + producer: APM_SERVER_FEATURE_ID, minimumLicenseRequired: 'basic', isExportable: true, executor: async ({ services, params: alertParams }) => { diff --git a/x-pack/plugins/apm/server/lib/alerts/test_utils/index.ts b/x-pack/plugins/apm/server/lib/alerts/test_utils/index.ts index 9dc22844bb629..1366503ea1428 100644 --- a/x-pack/plugins/apm/server/lib/alerts/test_utils/index.ts +++ b/x-pack/plugins/apm/server/lib/alerts/test_utils/index.ts @@ -10,7 +10,7 @@ import { of } from 'rxjs'; import { elasticsearchServiceMock } from 'src/core/server/mocks'; import type { RuleDataClient } from '../../../../../rule_registry/server'; import { PluginSetupContract as AlertingPluginSetupContract } from '../../../../../alerting/server'; -import { APMConfig } from '../../..'; +import { APMConfig, APM_SERVER_FEATURE_ID } from '../../..'; export const createRuleTypeMocks = () => { let alertExecutor: (...args: any[]) => Promise; @@ -38,6 +38,9 @@ export const createRuleTypeMocks = () => { const services = { scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), + savedObjectsClient: { + get: () => ({ attributes: { consumer: APM_SERVER_FEATURE_ID } }), + }, alertInstanceFactory: jest.fn(() => ({ scheduleActions })), alertWithLifecycle: jest.fn(), logger: loggerMock, @@ -67,6 +70,7 @@ export const createRuleTypeMocks = () => { executor: async ({ params }: { params: Record }) => { return alertExecutor({ services, + rule: { consumer: APM_SERVER_FEATURE_ID }, params, startedAt: new Date(), }); diff --git a/x-pack/plugins/apm/server/lib/fleet/create_cloud_apm_package_policy.ts b/x-pack/plugins/apm/server/lib/fleet/create_cloud_apm_package_policy.ts index 9e3095a8d1bca..c336e5dc95ba6 100644 --- a/x-pack/plugins/apm/server/lib/fleet/create_cloud_apm_package_policy.ts +++ b/x-pack/plugins/apm/server/lib/fleet/create_cloud_apm_package_policy.ts @@ -14,15 +14,20 @@ import { APM_SERVER_SCHEMA_SAVED_OBJECT_TYPE, APM_SERVER_SCHEMA_SAVED_OBJECT_ID, } from '../../../common/apm_saved_object_constants'; -import { APMPluginStartDependencies } from '../../types'; +import { + APMPluginSetupDependencies, + APMPluginStartDependencies, +} from '../../types'; import { getApmPackagePolicyDefinition } from './get_apm_package_policy_definition'; export async function createCloudApmPackgePolicy({ + cloudPluginSetup, fleetPluginStart, savedObjectsClient, esClient, logger, }: { + cloudPluginSetup: APMPluginSetupDependencies['cloud']; fleetPluginStart: NonNullable; savedObjectsClient: SavedObjectsClientContract; esClient: ElasticsearchClient; @@ -35,9 +40,10 @@ export async function createCloudApmPackgePolicy({ const apmServerSchema: Record = JSON.parse( (attributes as { schemaJson: string }).schemaJson ); - const apmPackagePolicyDefinition = getApmPackagePolicyDefinition( - apmServerSchema - ); + const apmPackagePolicyDefinition = getApmPackagePolicyDefinition({ + apmServerSchema, + cloudPluginSetup, + }); logger.info(`Fleet migration on Cloud - apmPackagePolicy create start`); const apmPackagePolicy = await fleetPluginStart.packagePolicyService.create( savedObjectsClient, diff --git a/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts b/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts index fb88a092cb265..82e85e7da9bb3 100644 --- a/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts +++ b/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts @@ -5,16 +5,21 @@ * 2.0. */ +import { APMPluginSetupDependencies } from '../../types'; import { POLICY_ELASTIC_AGENT_ON_CLOUD, APM_PACKAGE_NAME, } from './get_cloud_apm_package_policy'; +interface GetApmPackagePolicyDefinitionOptions { + apmServerSchema: Record; + cloudPluginSetup: APMPluginSetupDependencies['cloud']; +} export function getApmPackagePolicyDefinition( - apmServerSchema: Record + options: GetApmPackagePolicyDefinitionOptions ) { return { - name: 'apm', + name: 'Elastic APM', namespace: 'default', enabled: true, policy_id: POLICY_ELASTIC_AGENT_ON_CLOUD, @@ -24,7 +29,7 @@ export function getApmPackagePolicyDefinition( type: 'apm', enabled: true, streams: [], - vars: getApmPackageInputVars(apmServerSchema), + vars: getApmPackageInputVars(options), }, ], package: { @@ -35,16 +40,17 @@ export function getApmPackagePolicyDefinition( }; } -function getApmPackageInputVars(apmServerSchema: Record) { +function getApmPackageInputVars(options: GetApmPackagePolicyDefinitionOptions) { + const { apmServerSchema } = options; const apmServerConfigs = Object.entries( apmConfigMapping - ).map(([key, { name, type }]) => ({ key, name, type })); + ).map(([key, { name, type, getValue }]) => ({ key, name, type, getValue })); const inputVars: Record< string, { type: string; value: any } - > = apmServerConfigs.reduce((acc, { key, name, type }) => { - const value = apmServerSchema[key] ?? ''; // defaults to an empty string to be edited in Fleet UI + > = apmServerConfigs.reduce((acc, { key, name, type, getValue }) => { + const value = (getValue ? getValue(options) : apmServerSchema[key]) ?? ''; // defaults to an empty string to be edited in Fleet UI return { ...acc, [name]: { type, value }, @@ -55,7 +61,11 @@ function getApmPackageInputVars(apmServerSchema: Record) { export const apmConfigMapping: Record< string, - { name: string; type: string } + { + name: string; + type: string; + getValue?: (options: GetApmPackagePolicyDefinitionOptions) => any; + } > = { 'apm-server.host': { name: 'host', @@ -64,6 +74,7 @@ export const apmConfigMapping: Record< 'apm-server.url': { name: 'url', type: 'text', + getValue: ({ cloudPluginSetup }) => cloudPluginSetup?.apm?.url, }, 'apm-server.secret_token': { name: 'secret_token', diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 647330eade1f5..f260971c3bdcb 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -18,7 +18,7 @@ import { import { mapValues, once } from 'lodash'; import { TECHNICAL_COMPONENT_TEMPLATE_NAME } from '../../rule_registry/common/assets'; import { mappingFromFieldMap } from '../../rule_registry/common/mapping_from_field_map'; -import { APMConfig, APMXPackConfig } from '.'; +import { APMConfig, APMXPackConfig, APM_SERVER_FEATURE_ID } from '.'; import { mergeConfigs } from './index'; import { UI_SETTINGS } from '../../../../src/plugins/data/common'; import { APM_FEATURE, registerFeaturesUsage } from './feature'; @@ -188,6 +188,7 @@ export class APMPlugin ); const ruleDataClient = ruleDataService.getRuleDataClient( + APM_SERVER_FEATURE_ID, ruleDataService.getFullAssetName('observability-apm'), () => initializeRuleDataTemplatesPromise ); @@ -206,7 +207,7 @@ export class APMPlugin }) as APMRouteHandlerResources['plugins']; const telemetryUsageCounter = resourcePlugins.usageCollection?.setup.createUsageCounter( - 'apm' + APM_SERVER_FEATURE_ID ); registerRoutes({ diff --git a/x-pack/plugins/apm/server/routes/fleet.ts b/x-pack/plugins/apm/server/routes/fleet.ts index 6208b42e844fa..b83bfd54b93cd 100644 --- a/x-pack/plugins/apm/server/routes/fleet.ts +++ b/x-pack/plugins/apm/server/routes/fleet.ts @@ -163,6 +163,7 @@ const createCloudApmPackagePolicyRoute = createApmServerRoute({ const coreStart = await resources.core.start(); const esClient = coreStart.elasticsearch.client.asScoped(resources.request) .asCurrentUser; + const cloudPluginSetup = plugins.cloud?.setup; const fleetPluginStart = await plugins.fleet.start(); const securityPluginStart = await plugins.security.start(); const hasRequiredRole = isSuperuser({ securityPluginStart, request }); @@ -171,6 +172,7 @@ const createCloudApmPackagePolicyRoute = createApmServerRoute({ } return { cloud_apm_package_policy: await createCloudApmPackgePolicy({ + cloudPluginSetup, fleetPluginStart, savedObjectsClient, esClient, diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index 98c2ee47b5633..56a5950c27367 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -14,6 +14,7 @@ import { } from 'src/core/server'; import { RuleDataClient } from '../../../rule_registry/server'; import { AlertingApiRequestHandlerContext } from '../../../alerting/server'; +import type { RacApiRequestHandlerContext } from '../../../rule_registry/server'; import { LicensingApiRequestHandlerContext } from '../../../licensing/server'; import { APMConfig } from '..'; import { APMPluginDependencies } from '../types'; @@ -21,6 +22,7 @@ import { APMPluginDependencies } from '../types'; export interface ApmPluginRequestHandlerContext extends RequestHandlerContext { licensing: LicensingApiRequestHandlerContext; alerting: AlertingApiRequestHandlerContext; + rac: RacApiRequestHandlerContext; } export type InspectResponse = Array<{ diff --git a/x-pack/plugins/apm/server/tutorial/envs/elastic_cloud.ts b/x-pack/plugins/apm/server/tutorial/envs/elastic_cloud.ts index 55adc756f31af..a595ae1dc8a8b 100644 --- a/x-pack/plugins/apm/server/tutorial/envs/elastic_cloud.ts +++ b/x-pack/plugins/apm/server/tutorial/envs/elastic_cloud.ts @@ -46,7 +46,8 @@ export function createElasticCloudInstructions( function getApmServerInstructionSet( cloudSetup?: CloudSetup ): InstructionSetSchema { - const cloudId = cloudSetup?.cloudId; + const deploymentId = cloudSetup?.deploymentId; + return { title: i18n.translate('xpack.apm.tutorial.apmServer.title', { defaultMessage: 'APM Server', @@ -59,8 +60,8 @@ function getApmServerInstructionSet( title: 'Enable the APM Server in the ESS console', textPre: i18n.translate('xpack.apm.tutorial.elasticCloud.textPre', { defaultMessage: - 'To enable the APM Server go to [the Elastic Cloud console](https://cloud.elastic.co/deployments?q={cloudId}) and enable APM in the deployment settings. Once enabled, refresh this page.', - values: { cloudId }, + 'To enable the APM Server go to [the Elastic Cloud console](https://cloud.elastic.co/deployments/{deploymentId}/edit) and enable APM in the deployment settings. Once enabled, refresh this page.', + values: { deploymentId }, }), }, ], diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/urlparam.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/urlparam.ts index c8ce7717cd2d2..9d56278e2c324 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/urlparam.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/urlparam.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { parse } from 'url'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; import { getFunctionHelp } from '../../../i18n'; @@ -43,8 +42,9 @@ export function urlparam(): ExpressionFunctionDefinition< }, }, fn: (input, args) => { - const query = parse(window.location.href, true).query; - return query[args.param] || args.default; + const url = new URL(window.location.href); + const query = url.searchParams; + return query.get(args.param) || args.default; }, }; } diff --git a/x-pack/plugins/canvas/public/lib/modify_url.ts b/x-pack/plugins/canvas/public/lib/modify_url.ts deleted file mode 100644 index 3a275853116d8..0000000000000 --- a/x-pack/plugins/canvas/public/lib/modify_url.ts +++ /dev/null @@ -1,90 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ParsedQuery } from 'query-string'; -import { format as formatUrl, parse as parseUrl, UrlObject } from 'url'; - -/** - * We define our own typings because the current version of @types/node - * declares properties to be optional "hostname?: string". - * Although, parse call returns "hostname: null | string". - */ -export interface URLMeaningfulParts { - auth?: string | null; - hash?: string | null; - hostname?: string | null; - pathname?: string | null; - protocol?: string | null; - slashes?: boolean | null; - port?: string | null; - query: ParsedQuery; -} - -/** - * Takes a URL and a function that takes the meaningful parts - * of the URL as a key-value object, modifies some or all of - * the parts, and returns the modified parts formatted again - * as a url. - * - * Url Parts sent: - * - protocol - * - slashes (does the url have the //) - * - auth - * - hostname (just the name of the host, no port or auth information) - * - port - * - pathname (the path after the hostname, no query or hash, starts - * with a slash if there was a path) - * - query (always an object, even when no query on original url) - * - hash - * - * Why? - * - The default url library in node produces several conflicting - * properties on the "parsed" output. Modifying any of these might - * lead to the modifications being ignored (depending on which - * property was modified) - * - It's not always clear whether to use path/pathname, host/hostname, - * so this tries to add helpful constraints - * - * @param url The string url to parse. - * @param urlModifier A function that will modify the parsed url, or return a new one. - * @returns The modified and reformatted url - */ -export function modifyUrl( - url: string, - urlModifier: (urlParts: URLMeaningfulParts) => Partial | void -) { - const parsed = parseUrl(url, true) as URLMeaningfulParts; - - // Copy over the most specific version of each property. By default, the parsed url includes several - // conflicting properties (like path and pathname + search, or search and query) and keeping track - // of which property is actually used when they are formatted is harder than necessary. - const meaningfulParts: URLMeaningfulParts = { - auth: parsed.auth, - hash: parsed.hash, - hostname: parsed.hostname, - pathname: parsed.pathname, - port: parsed.port, - protocol: parsed.protocol, - query: parsed.query || {}, - slashes: parsed.slashes, - }; - - // The urlModifier modifies the meaningfulParts object, or returns a new one. - const modifiedParts = urlModifier(meaningfulParts) || meaningfulParts; - - // Format the modified/replaced meaningfulParts back into a url. - return formatUrl({ - auth: modifiedParts.auth, - hash: modifiedParts.hash, - hostname: modifiedParts.hostname, - pathname: modifiedParts.pathname, - port: modifiedParts.port, - protocol: modifiedParts.protocol, - query: modifiedParts.query, - slashes: modifiedParts.slashes, - } as UrlObject); -} diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx index 543c159bae145..101f64e53b685 100644 --- a/x-pack/plugins/canvas/public/plugin.tsx +++ b/x-pack/plugins/canvas/public/plugin.tsx @@ -30,8 +30,6 @@ import { Start as InspectorStart } from '../../../../src/plugins/inspector/publi import { BfetchPublicSetup } from '../../../../src/plugins/bfetch/public'; import { PresentationUtilPluginStart } from '../../../../src/plugins/presentation_util/public'; import { getPluginApi, CanvasApi } from './plugin_api'; -import { CanvasSrcPlugin } from '../canvas_plugin_src/plugin'; -import { pluginServices } from './services'; import { pluginServiceRegistry } from './services/kibana'; export { CoreStart, CoreSetup }; @@ -75,14 +73,10 @@ export type CanvasStart = void; export class CanvasPlugin implements Plugin { private appUpdater = new BehaviorSubject(() => ({})); - // TODO: Do we want to completely move canvas_plugin_src into it's own plugin? - private srcPlugin = new CanvasSrcPlugin(); public setup(coreSetup: CoreSetup, setupPlugins: CanvasSetupDeps) { const { api: canvasApi, registries } = getPluginApi(setupPlugins.expressions); - this.srcPlugin.setup(coreSetup, { canvas: canvasApi }); - // Set the nav link to the last saved url if we have one in storage const lastPath = getSessionStorage().get( `${SESSIONSTORAGE_LASTPATH}:${coreSetup.http.basePath.get()}` @@ -101,12 +95,21 @@ export class CanvasPlugin order: 3000, updater$: this.appUpdater, mount: async (params: AppMountParameters) => { - // Load application bundle - const { renderApp, initializeCanvas, teardownCanvas } = await import('./application'); + const { CanvasSrcPlugin } = await import('../canvas_plugin_src/plugin'); + const srcPlugin = new CanvasSrcPlugin(); + srcPlugin.setup(coreSetup, { canvas: canvasApi }); // Get start services const [coreStart, startPlugins] = await coreSetup.getStartServices(); + srcPlugin.start(coreStart, startPlugins); + + const { pluginServices } = await import('./services'); + pluginServices.setRegistry(pluginServiceRegistry.start({ coreStart, startPlugins })); + + // Load application bundle + const { renderApp, initializeCanvas, teardownCanvas } = await import('./application'); + const canvasStore = await initializeCanvas( coreSetup, coreStart, @@ -145,8 +148,6 @@ export class CanvasPlugin } public start(coreStart: CoreStart, startPlugins: CanvasStartDeps) { - this.srcPlugin.start(coreStart, startPlugins); - pluginServices.setRegistry(pluginServiceRegistry.start({ coreStart, startPlugins })); initLoadingIndicator(coreStart.http.addLoadingCountSource); } } diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx index a6d8afc3b8b23..477ea27be99bf 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx @@ -6,8 +6,7 @@ */ import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { EuiProgress } from '@elastic/eui'; -import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types'; +import { EuiProgress, EuiBasicTable, EuiTableSelectionType } from '@elastic/eui'; import { difference, head, isEmpty, memoize } from 'lodash/fp'; import styled, { css } from 'styled-components'; import classnames from 'classnames'; @@ -113,6 +112,7 @@ export const AllCasesGeneric = React.memo( ); const filterRefetch = useRef<() => void>(); + const tableRef = useRef(); const setFilterRefetch = useCallback( (refetchFilter: () => void) => { filterRefetch.current = refetchFilter; @@ -182,10 +182,13 @@ export const AllCasesGeneric = React.memo( ) { setQueryParams({ sortField: SortFieldCase.createdAt }); } + + setSelectedCases([]); + tableRef.current?.setSelection([]); setFilters(newFilterOptions); refreshCases(false); }, - [refreshCases, setQueryParams, setFilters] + [setSelectedCases, setFilters, refreshCases, setQueryParams] ); const showActions = userCanCrud && !isSelectorView; @@ -319,6 +322,7 @@ export const AllCasesGeneric = React.memo( selection={euiBasicTableSelectionProps} showActions={showActions} sorting={sorting} + tableRef={tableRef} tableRowProps={tableRowProps} userCanCrud={userCanCrud} /> diff --git a/x-pack/plugins/cases/public/components/all_cases/table.tsx b/x-pack/plugins/cases/public/components/all_cases/table.tsx index bc779336b8c01..876007494d276 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, MutableRefObject } from 'react'; import { EuiEmptyPrompt, EuiLoadingContent, @@ -40,6 +40,7 @@ interface CasesTableProps { selection: EuiTableSelectionType; showActions: boolean; sorting: EuiBasicTableProps['sorting']; + tableRef: MutableRefObject<_EuiBasicTable | undefined>; tableRowProps: EuiBasicTableProps['rowProps']; userCanCrud: boolean; } @@ -92,6 +93,7 @@ export const CasesTable: FunctionComponent = ({ selection, showActions, sorting, + tableRef, tableRowProps, userCanCrud, }) => @@ -110,6 +112,7 @@ export const CasesTable: FunctionComponent = ({ refreshCases={refreshCases} /> = ({ } onChange={onChange} pagination={pagination} + ref={tableRef} rowProps={tableRowProps} selection={showActions ? selection : undefined} sorting={sorting} - className={classnames({ isSelectorView })} /> ); diff --git a/x-pack/plugins/data_visualizer/common/constants.ts b/x-pack/plugins/data_visualizer/common/constants.ts index 7e0fe65632ae3..55ebdf9a196d6 100644 --- a/x-pack/plugins/data_visualizer/common/constants.ts +++ b/x-pack/plugins/data_visualizer/common/constants.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { KBN_FIELD_TYPES } from '../../../../src/plugins/data/common'; + export const UI_SETTING_MAX_FILE_SIZE = 'fileUpload:maxFileSize'; export const MB = Math.pow(2, 20); @@ -27,7 +29,26 @@ export const JOB_FIELD_TYPES = { KEYWORD: 'keyword', NUMBER: 'number', TEXT: 'text', + HISTOGRAM: 'histogram', UNKNOWN: 'unknown', } as const; +export const JOB_FIELD_TYPES_OPTIONS = { + [JOB_FIELD_TYPES.BOOLEAN]: { name: 'Boolean', icon: 'tokenBoolean' }, + [JOB_FIELD_TYPES.DATE]: { name: 'Date', icon: 'tokenDate' }, + [JOB_FIELD_TYPES.GEO_POINT]: { name: 'Geo point', icon: 'tokenGeo' }, + [JOB_FIELD_TYPES.GEO_SHAPE]: { name: 'Geo shape', icon: 'tokenGeo' }, + [JOB_FIELD_TYPES.IP]: { name: 'IP address', icon: 'tokenIP' }, + [JOB_FIELD_TYPES.KEYWORD]: { name: 'Keyword', icon: 'tokenKeyword' }, + [JOB_FIELD_TYPES.NUMBER]: { name: 'Number', icon: 'tokenNumber' }, + [JOB_FIELD_TYPES.TEXT]: { name: 'Text', icon: 'tokenString' }, + [JOB_FIELD_TYPES.HISTOGRAM]: { name: 'Histogram', icon: 'tokenNumber' }, + [JOB_FIELD_TYPES.UNKNOWN]: { name: 'Unknown' }, +}; + export const OMIT_FIELDS: string[] = ['_source', '_type', '_index', '_id', '_version', '_score']; + +export const NON_AGGREGATABLE_FIELD_TYPES = new Set([ + KBN_FIELD_TYPES.GEO_SHAPE, + KBN_FIELD_TYPES.HISTOGRAM, +]); diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/field_type_icon.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/field_type_icon.tsx index 50823006db3b6..ee4b4f8171d7d 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/field_type_icon.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/field_type_icon.tsx @@ -72,6 +72,9 @@ export const FieldTypeIcon: FC = ({ iconType = 'tokenNumber'; color = fieldName !== undefined ? 'euiColorVis1' : 'euiColorVis2'; break; + case JOB_FIELD_TYPES.HISTOGRAM: + iconType = 'tokenHistogram'; + color = 'euiColorVis7'; case JOB_FIELD_TYPES.UNKNOWN: // Use defaults break; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_types_filter/field_types_filter.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/field_types_filter/field_types_filter.tsx index 152926ad84ba7..511a068f305f9 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_types_filter/field_types_filter.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_types_filter/field_types_filter.tsx @@ -14,19 +14,7 @@ import type { FileBasedUnknownFieldVisConfig, } from '../stats_table/types/field_vis_config'; import { FieldTypeIcon } from '../field_type_icon'; -import { JOB_FIELD_TYPES } from '../../../../../common'; - -const JOB_FIELD_TYPES_OPTIONS = { - [JOB_FIELD_TYPES.BOOLEAN]: { name: 'Boolean', icon: 'tokenBoolean' }, - [JOB_FIELD_TYPES.DATE]: { name: 'Date', icon: 'tokenDate' }, - [JOB_FIELD_TYPES.GEO_POINT]: { name: 'Geo point', icon: 'tokenGeo' }, - [JOB_FIELD_TYPES.GEO_SHAPE]: { name: 'Geo shape', icon: 'tokenGeo' }, - [JOB_FIELD_TYPES.IP]: { name: 'IP address', icon: 'tokenIP' }, - [JOB_FIELD_TYPES.KEYWORD]: { name: 'Keyword', icon: 'tokenKeyword' }, - [JOB_FIELD_TYPES.NUMBER]: { name: 'Number', icon: 'tokenNumber' }, - [JOB_FIELD_TYPES.TEXT]: { name: 'Text', icon: 'tokenString' }, - [JOB_FIELD_TYPES.UNKNOWN]: { name: 'Unknown' }, -}; +import { JOB_FIELD_TYPES_OPTIONS } from '../../../../../common'; interface Props { fields: Array; diff --git a/x-pack/plugins/data_visualizer/public/application/common/util/field_types_utils.ts b/x-pack/plugins/data_visualizer/public/application/common/util/field_types_utils.ts index e0a6c8ebf85c9..98d43059e5ee3 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/util/field_types_utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/util/field_types_utils.ts @@ -78,6 +78,9 @@ export function kbnTypeToJobType(field: IndexPatternField) { case KBN_FIELD_TYPES.GEO_SHAPE: type = JOB_FIELD_TYPES.GEO_SHAPE; break; + case KBN_FIELD_TYPES.HISTOGRAM: + type = JOB_FIELD_TYPES.HISTOGRAM; + break; default: break; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx index c9ae3cf7f69a7..ec55eddec2a79 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/index_data_visualizer_view/index_data_visualizer_view.tsx @@ -56,7 +56,7 @@ import { useDataVisualizerKibana } from '../../../kibana_context'; import { FieldCountPanel } from '../../../common/components/field_count_panel'; import { DocumentCountContent } from '../../../common/components/document_count_content'; import { DataLoader } from '../../data_loader/data_loader'; -import { JOB_FIELD_TYPES } from '../../../../../common'; +import { JOB_FIELD_TYPES, OMIT_FIELDS } from '../../../../../common'; import { useTimefilter } from '../../hooks/use_time_filter'; import { kbnTypeToJobType } from '../../../common/util/field_types_utils'; import { SearchPanel } from '../search_panel'; @@ -204,18 +204,21 @@ export const IndexDataVisualizerView: FC = (dataVi } }, [currentIndexPattern, toasts]); - // Obtain the list of non metric field types which appear in the index pattern. - let indexedFieldTypes: JobFieldType[] = []; const indexPatternFields: IndexPatternField[] = currentIndexPattern.fields; - indexPatternFields.forEach((field) => { - if (field.scripted !== true) { - const dataVisualizerType: JobFieldType | undefined = kbnTypeToJobType(field); - if (dataVisualizerType !== undefined && !indexedFieldTypes.includes(dataVisualizerType)) { - indexedFieldTypes.push(dataVisualizerType); + + const fieldTypes = useMemo(() => { + // Obtain the list of non metric field types which appear in the index pattern. + const indexedFieldTypes: JobFieldType[] = []; + indexPatternFields.forEach((field) => { + if (!OMIT_FIELDS.includes(field.name) && field.scripted !== true) { + const dataVisualizerType: JobFieldType | undefined = kbnTypeToJobType(field); + if (dataVisualizerType !== undefined && !indexedFieldTypes.includes(dataVisualizerType)) { + indexedFieldTypes.push(dataVisualizerType); + } } - } - }); - indexedFieldTypes = indexedFieldTypes.sort(); + }); + return indexedFieldTypes.sort(); + }, [indexPatternFields]); const defaults = getDefaultPageState(); @@ -859,7 +862,7 @@ export const IndexDataVisualizerView: FC = (dataVi samplerShardSize={samplerShardSize} setSamplerShardSize={setSamplerShardSize} overallStats={overallStats} - indexedFieldTypes={indexedFieldTypes} + indexedFieldTypes={fieldTypes} setVisibleFieldTypes={setVisibleFieldTypes} visibleFieldTypes={visibleFieldTypes} visibleFieldNames={visibleFieldNames} diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/field_type_filter.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/field_type_filter.tsx index 4f9de09dc670e..a4286bc4e09d1 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/field_type_filter.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/field_type_filter.tsx @@ -8,22 +8,10 @@ import React, { FC, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { JOB_FIELD_TYPES, JobFieldType } from '../../../../../common'; +import { JOB_FIELD_TYPES_OPTIONS, JobFieldType } from '../../../../../common'; import { FieldTypeIcon } from '../../../common/components/field_type_icon'; import { MultiSelectPicker, Option } from '../../../common/components/multi_select_picker'; -const ML_JOB_FIELD_TYPES_OPTIONS = { - [JOB_FIELD_TYPES.BOOLEAN]: { name: 'Boolean', icon: 'tokenBoolean' }, - [JOB_FIELD_TYPES.DATE]: { name: 'Date', icon: 'tokenDate' }, - [JOB_FIELD_TYPES.GEO_POINT]: { name: 'Geo point', icon: 'tokenGeo' }, - [JOB_FIELD_TYPES.GEO_SHAPE]: { name: 'Geo shape', icon: 'tokenGeo' }, - [JOB_FIELD_TYPES.IP]: { name: 'IP address', icon: 'tokenIP' }, - [JOB_FIELD_TYPES.KEYWORD]: { name: 'Keyword', icon: 'tokenKeyword' }, - [JOB_FIELD_TYPES.NUMBER]: { name: 'Number', icon: 'tokenNumber' }, - [JOB_FIELD_TYPES.TEXT]: { name: 'Text', icon: 'tokenString' }, - [JOB_FIELD_TYPES.UNKNOWN]: { name: 'Unknown' }, -}; - export const DatavisualizerFieldTypeFilter: FC<{ indexedFieldTypes: JobFieldType[]; setVisibleFieldTypes(q: string[]): void; @@ -31,7 +19,7 @@ export const DatavisualizerFieldTypeFilter: FC<{ }> = ({ indexedFieldTypes, setVisibleFieldTypes, visibleFieldTypes }) => { const options: Option[] = useMemo(() => { return indexedFieldTypes.map((indexedFieldName) => { - const item = ML_JOB_FIELD_TYPES_OPTIONS[indexedFieldName]; + const item = JOB_FIELD_TYPES_OPTIONS[indexedFieldName]; return { value: indexedFieldName, diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/data_loader/data_loader.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/data_loader/data_loader.ts index 468bd3a2bd7ee..4a3c971cc57cd 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/data_loader/data_loader.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/data_loader/data_loader.ts @@ -10,8 +10,7 @@ import { CoreSetup } from 'kibana/public'; import { estypes } from '@elastic/elasticsearch'; import { i18n } from '@kbn/i18n'; import { IndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; -import { KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/common'; -import { OMIT_FIELDS } from '../../../../common/constants'; +import { NON_AGGREGATABLE_FIELD_TYPES, OMIT_FIELDS } from '../../../../common/constants'; import { FieldRequestConfig } from '../../../../common/types'; import { getVisualizerFieldStats, getVisualizerOverallStats } from '../services/visualizer_stats'; @@ -49,7 +48,7 @@ export class DataLoader { this._indexPattern.fields.forEach((field) => { const fieldName = field.displayName !== undefined ? field.displayName : field.name; if (this.isDisplayField(fieldName) === true) { - if (field.aggregatable === true && field.type !== KBN_FIELD_TYPES.GEO_SHAPE) { + if (field.aggregatable === true && !NON_AGGREGATABLE_FIELD_TYPES.has(field.type)) { aggregatableFields.push(field.name); } else { nonAggregatableFields.push(field.name); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_table/field_number.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_table/field_number.test.tsx index 3ac50d906e9c4..c012167f67818 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_table/field_number.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_table/field_number.test.tsx @@ -54,7 +54,7 @@ describe('FieldNumber', () => { }} /> ); - expect(wrapper.find(EuiFieldNumber).prop('value')).toEqual(''); + expect(wrapper.find(EuiFieldNumber).prop('value')).toEqual(' '); }); it('is disabled if the [fieldEnabledProperty] in fieldSettings is false', () => { @@ -90,10 +90,10 @@ describe('FieldNumber', () => { expect(props.updateAction).toHaveBeenCalledWith('foo', 21); }); - it('will call updateAction on blur using the minimum possible value if the current value is something other than a number', () => { + it('will call clearAction on blur if the current value is something other than a number', () => { const wrapper = shallow(); wrapper.simulate('blur', { target: { value: '' } }); - expect(props.updateAction).toHaveBeenCalledWith('foo', 20); + expect(props.clearAction).toHaveBeenCalledWith('foo'); }); it('will call updateAction on blur using the minimum possible value if the value is something lower than the minimum', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_table/field_number.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_table/field_number.tsx index cd7bab3c6f594..f16bab5234ab1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_table/field_number.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_table/field_number.tsx @@ -43,11 +43,10 @@ const handleFieldNumberBlur = ( clearAction: (fieldName: string) => void ) => { return (e: FocusEvent) => { - const value = parseInt(e.target.value, 10); - const fieldValue = Math.min( - SIZE_FIELD_MAXIMUM, - Math.max(SIZE_FIELD_MINIMUM, isNaN(value) ? 0 : value) - ); + let fieldValue = parseInt(e.target.value, 10); + if (!isNaN(fieldValue)) { + fieldValue = Math.min(SIZE_FIELD_MAXIMUM, Math.max(SIZE_FIELD_MINIMUM, fieldValue)); + } updateOrClearSizeForField(fieldName, fieldValue, updateAction, clearAction); }; }; @@ -74,7 +73,7 @@ export const FieldNumber: React.FC = ({ value={ typeof fieldSettings[fieldSizeProperty] === 'number' ? (fieldSettings[fieldSizeProperty] as number) - : '' + : ' ' // Without the space, invalid non-numbers don't get cleared for some reason } placeholder={i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.resultSettings.numberFieldPlaceholder', diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_table/text_fields_body.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_table/text_fields_body.tsx index 3a2eb20fecdf0..c3b46f5852724 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_table/text_fields_body.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/result_settings/result_settings_table/text_fields_body.tsx @@ -56,7 +56,7 @@ export const TextFieldsBody: React.FC = () => { }} /> - + { }} /> - + { { defaultMessage: 'Raw' } )} - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.resultSettings.table.column.maxSizeTitle', { defaultMessage: 'Max size' } @@ -48,7 +48,7 @@ export const TextFieldsHeader: React.FC = () => { { defaultMessage: 'Fallback' } )} - + {i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.resultSettings.table.column.maxSizeTitle', { defaultMessage: 'Max size' } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx index 74f32cc22c2a8..da4346d54727c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.test.tsx @@ -125,5 +125,15 @@ describe('SourceSettings', () => { '/api/workplace_search/account/sources/123/download_diagnostics' ); }); + + it('renders with the correct download file name', () => { + jest.spyOn(global.Date, 'now').mockImplementationOnce(() => new Date('1970-01-01').valueOf()); + + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="DownloadDiagnosticsButton"]').prop('download')).toEqual( + '123_custom_0_diagnostics.json' + ); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index 667e7fd4dbfb4..e4f52d94ad9e7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -6,14 +6,12 @@ */ import React, { useEffect, useState, ChangeEvent, FormEvent } from 'react'; -import { Link } from 'react-router-dom'; import { useActions, useValues } from 'kea'; import { isEmpty } from 'lodash'; import { EuiButton, - EuiButtonEmpty, EuiConfirmModal, EuiFieldText, EuiFlexGroup, @@ -22,6 +20,8 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { HttpLogic } from '../../../../shared/http'; +import { EuiButtonEmptyTo } from '../../../../shared/react_router_helpers'; import { AppLogic } from '../../../app_logic'; import { ContentSection } from '../../../components/shared/content_section'; import { SourceConfigFields } from '../../../components/shared/source_config_fields'; @@ -57,6 +57,8 @@ import { SourceLogic } from '../source_logic'; import { SourceLayout } from './source_layout'; export const SourceSettings: React.FC = () => { + const { http } = useValues(HttpLogic); + const { updateContentSource, removeContentSource } = useActions(SourceLogic); const { getSourceConfigData } = useActions(AddSourceLogic); @@ -90,8 +92,8 @@ export const SourceSettings: React.FC = () => { const { clientId, clientSecret, publicKey, consumerKey, baseUrl } = configuredFields || {}; const diagnosticsPath = isOrganization - ? `/api/workplace_search/org/sources/${id}/download_diagnostics` - : `/api/workplace_search/account/sources/${id}/download_diagnostics`; + ? http.basePath.prepend(`/api/workplace_search/org/sources/${id}/download_diagnostics`) + : http.basePath.prepend(`/api/workplace_search/account/sources/${id}/download_diagnostics`); const handleNameChange = (e: ChangeEvent) => setValue(e.target.value); @@ -172,9 +174,9 @@ export const SourceSettings: React.FC = () => { baseUrl={baseUrl} /> - - {SOURCE_CONFIG_LINK} - + + {SOURCE_CONFIG_LINK} + )} @@ -184,7 +186,7 @@ export const SourceSettings: React.FC = () => { href={diagnosticsPath} isLoading={buttonLoading} data-test-subj="DownloadDiagnosticsButton" - download + download={`${id}_${serviceType}_${Date.now()}_diagnostics.json`} > {SYNC_DIAGNOSTICS_BUTTON} diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts index e6524151b0a6c..d0e74f3234c14 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts @@ -209,17 +209,23 @@ describe('EnterpriseSearchRequestHandler', () => { headers: mockExpectedResponseHeaders, }); }); - }); - it('works if response contains no json data', async () => { - EnterpriseSearchAPI.mockReturn(); + it('passes back the response body as-is if hasJsonResponse is false', async () => { + const mockFile = new File(['mockFile'], 'mockFile.json'); + EnterpriseSearchAPI.mockReturn(mockFile); - const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/prep' }); - await makeAPICall(requestHandler); + const requestHandler = enterpriseSearchRequestHandler.createRequest({ + path: '/api/file', + hasJsonResponse: false, + }); + await makeAPICall(requestHandler); - expect(responseMock.custom).toHaveBeenCalledWith({ - statusCode: 200, - headers: mockExpectedResponseHeaders, + EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/file'); + expect(responseMock.custom).toHaveBeenCalledWith({ + body: expect.any(Buffer), // Unfortunately Response() buffers the body so we can't actually inspect/equality assert on it + statusCode: 200, + headers: mockExpectedResponseHeaders, + }); }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts index b4768c1a9ee15..8031fc724f7b3 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts @@ -34,6 +34,7 @@ interface ConstructorDependencies { interface RequestParams { path: string; params?: object; + hasJsonResponse?: boolean; hasValidData?: Function; } interface ErrorResponse { @@ -64,7 +65,12 @@ export class EnterpriseSearchRequestHandler { this.enterpriseSearchUrl = config.host as string; } - createRequest({ path, params = {}, hasValidData = () => true }: RequestParams) { + createRequest({ + path, + params = {}, + hasJsonResponse = true, + hasValidData = () => true, + }: RequestParams) { return async ( _context: RequestHandlerContext, request: KibanaRequest, @@ -119,7 +125,7 @@ export class EnterpriseSearchRequestHandler { // Check returned data let responseBody; - try { + if (hasJsonResponse) { const json = await apiResponse.json(); if (!hasValidData(json)) { @@ -134,8 +140,8 @@ export class EnterpriseSearchRequestHandler { } else { responseBody = json; } - } catch (e) { - responseBody = undefined; + } else { + responseBody = apiResponse.body; } // Pass successful responses back to the front-end diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/onboarding.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/onboarding.test.ts index c26f8dbaf5213..47b480f61341a 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/onboarding.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/onboarding.test.ts @@ -30,6 +30,7 @@ describe('engine routes', () => { mockRouter.callRoute({ body: {} }); expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ path: '/as/onboarding/complete', + hasJsonResponse: false, }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/onboarding.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/onboarding.ts index 9a46c75555969..147f935a56aed 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/onboarding.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/onboarding.ts @@ -24,6 +24,7 @@ export function registerOnboardingRoutes({ }, enterpriseSearchRequestHandler.createRequest({ path: '/as/onboarding/complete', + hasJsonResponse: false, }) ); } diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts index 4043f9daddaa7..a68a7716933f8 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts @@ -559,6 +559,7 @@ describe('sources routes', () => { it('creates a request handler', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ path: '/ws/sources/:sourceId/download_diagnostics', + hasJsonResponse: false, }); }); }); @@ -1057,6 +1058,7 @@ describe('sources routes', () => { it('creates a request handler', () => { expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ path: '/ws/org/sources/:sourceId/download_diagnostics', + hasJsonResponse: false, }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index b393ab9d1f26a..5de4387f2c0d9 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -400,6 +400,7 @@ export function registerAccountSourceDownloadDiagnosticsRoute({ }, enterpriseSearchRequestHandler.createRequest({ path: '/ws/sources/:sourceId/download_diagnostics', + hasJsonResponse: false, }) ); } @@ -748,6 +749,7 @@ export function registerOrgSourceDownloadDiagnosticsRoute({ }, enterpriseSearchRequestHandler.createRequest({ path: '/ws/org/sources/:sourceId/download_diagnostics', + hasJsonResponse: false, }) ); } diff --git a/x-pack/plugins/fleet/common/services/agent_status.ts b/x-pack/plugins/fleet/common/services/agent_status.ts index b8a59e6447723..e4b227b79536c 100644 --- a/x-pack/plugins/fleet/common/services/agent_status.ts +++ b/x-pack/plugins/fleet/common/services/agent_status.ts @@ -54,7 +54,7 @@ export function buildKueryForOnlineAgents() { } export function buildKueryForErrorAgents() { - return 'last_checkin_status:error or last_checkin_status:degraded'; + return `(last_checkin_status:error or last_checkin_status:degraded) AND not (${buildKueryForUpdatingAgents()})`; } export function buildKueryForOfflineAgents() { diff --git a/x-pack/plugins/fleet/public/applications/integrations/hooks/use_package_install.tsx b/x-pack/plugins/fleet/public/applications/integrations/hooks/use_package_install.tsx index 342d6b54c2613..edbe06f33b18e 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/hooks/use_package_install.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/hooks/use_package_install.tsx @@ -118,7 +118,12 @@ function usePackageInstall({ notifications }: { notifications: NotificationsStar ); const uninstallPackage = useCallback( - async ({ name, version, title }: Pick) => { + async ({ + name, + version, + title, + redirectToVersion, + }: Pick & { redirectToVersion: string }) => { setPackageInstallStatus({ name, status: InstallStatus.uninstalling, version }); const pkgkey = `${name}-${version}`; @@ -160,9 +165,15 @@ function usePackageInstall({ notifications }: { notifications: NotificationsStar /> ), }); + if (redirectToVersion !== version) { + const settingsPath = getPath('integration_details_settings', { + pkgkey: `${name}-${redirectToVersion}`, + }); + history.push(settingsPath); + } } }, - [notifications.toasts, setPackageInstallStatus] + [notifications.toasts, setPackageInstallStatus, getPath, history] ); return { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx index 96e4071e9b464..63372e435cfa1 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/package_policies.tsx @@ -151,7 +151,10 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps /> - + {i18n.translate('xpack.fleet.epm.agentEnrollment.viewDataAssetsLabel', { defaultMessage: 'View assets', })} diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/installation_button.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/installation_button.tsx index 8cf8466e6d9b0..eab28a051f061 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/installation_button.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/installation_button.tsx @@ -24,9 +24,10 @@ import { ConfirmPackageInstall } from './confirm_package_install'; type InstallationButtonProps = Pick & { disabled?: boolean; isUpdate?: boolean; + latestVersion?: string; }; export function InstallationButton(props: InstallationButtonProps) { - const { assets, name, title, version, disabled = true, isUpdate = false } = props; + const { assets, name, title, version, disabled = true, isUpdate = false, latestVersion } = props; const hasWriteCapabilites = useCapabilities().write; const installPackage = useInstallPackage(); const uninstallPackage = useUninstallPackage(); @@ -52,9 +53,9 @@ export function InstallationButton(props: InstallationButtonProps) { }, [installPackage, name, title, version]); const handleClickUninstall = useCallback(() => { - uninstallPackage({ name, version, title }); + uninstallPackage({ name, version, title, redirectToVersion: latestVersion ?? version }); toggleModal(); - }, [uninstallPackage, name, title, toggleModal, version]); + }, [uninstallPackage, name, title, toggleModal, version, latestVersion]); // counts the number of assets in the package const numOfAssets = useMemo( diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx index 9e8d200344b01..14f378bc379a6 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx @@ -10,11 +10,11 @@ import styled from 'styled-components'; import { FormattedMessage } from '@kbn/i18n/react'; import semverLt from 'semver/functions/lt'; -import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer } from '@elastic/eui'; +import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer, EuiLink } from '@elastic/eui'; import type { PackageInfo } from '../../../../../types'; import { InstallStatus } from '../../../../../types'; -import { useGetPackagePolicies, useGetPackageInstallStatus } from '../../../../../hooks'; +import { useGetPackagePolicies, useGetPackageInstallStatus, useLink } from '../../../../../hooks'; import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../../constants'; import { UpdateIcon } from '../components'; @@ -47,6 +47,21 @@ const UpdatesAvailableMsg = () => ( ); +const LatestVersionLink = ({ name, version }: { name: string; version: string }) => { + const { getPath } = useLink(); + const settingsPath = getPath('integration_details_settings', { + pkgkey: `${name}-${version}`, + }); + return ( + + + + ); +}; + interface Props { packageInfo: PackageInfo; } @@ -72,6 +87,7 @@ export const SettingsPage: React.FC = memo(({ packageInfo }: Props) => { (installationStatus === InstallStatus.installed && installedVersion !== version); const isUpdating = installationStatus === InstallStatus.installing && installedVersion; + return ( @@ -206,6 +222,7 @@ export const SettingsPage: React.FC = memo(({ packageInfo }: Props) => {

@@ -244,6 +261,37 @@ export const SettingsPage: React.FC = memo(({ packageInfo }: Props) => { )} )} + {hideInstallOptions && isViewingOldPackage && !isUpdating && ( +
+ +
+ +

+ +

+
+ +

+ + , + }} + /> + +

+
+
+ )}
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index d58d9e593e101..5eb530e50f190 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -497,6 +497,57 @@ describe('loader', () => { }); }); + it('should initialize all the embeddable references without local storage', async () => { + const savedState: IndexPatternPersistedState = { + layers: { + layerb: { + columnOrder: ['col1', 'col2'], + columns: { + col1: { + dataType: 'date', + isBucketed: true, + label: 'My date', + operationType: 'date_histogram', + params: { + interval: 'm', + }, + sourceField: 'timestamp', + }, + col2: { + dataType: 'number', + isBucketed: false, + label: 'Sum of bytes', + operationType: 'sum', + sourceField: 'bytes', + }, + }, + }, + }, + }; + const storage = createMockStorage({}); + const state = await loadInitialState({ + persistedState: savedState, + references: [ + { name: 'indexpattern-datasource-current-indexpattern', id: '2', type: 'index-pattern' }, + { name: 'indexpattern-datasource-layer-layerb', id: '2', type: 'index-pattern' }, + { name: 'another-reference', id: 'c', type: 'index-pattern' }, + ], + indexPatternsService: mockIndexPatternsService(), + storage, + options: { isFullEditor: false }, + }); + + expect(state).toMatchObject({ + currentIndexPatternId: undefined, + indexPatternRefs: [], + indexPatterns: { + '2': sampleIndexPatterns['2'], + }, + layers: { layerb: { ...savedState.layers.layerb, indexPatternId: '2' } }, + }); + expect(storage.set).not.toHaveBeenCalled(); + }); + it('should initialize from saved state', async () => { const savedState: IndexPatternPersistedState = { layers: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index 82c27a76bb483..3db17a94d58b5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -227,12 +227,14 @@ export async function loadInitialState({ const state = persistedState && references ? injectReferences(persistedState, references) : undefined; + const fallbackId = lastUsedIndexPatternId || defaultIndexPatternId || indexPatternRefs[0]?.id; + const requiredPatterns: string[] = uniq( state ? Object.values(state.layers) .map((l) => l.indexPatternId) .concat(state.currentIndexPatternId) - : [lastUsedIndexPatternId || defaultIndexPatternId || indexPatternRefs[0]?.id] + : [fallbackId] ) // take out the undefined from the list .filter(Boolean); @@ -248,15 +250,16 @@ export async function loadInitialState({ indexPatternRefs[0]?.id, ].filter((id) => id != null && availableIndexPatterns.has(id)); - const currentIndexPatternId = availableIndexPatternIds[0]!; + const currentIndexPatternId = availableIndexPatternIds[0]; if (currentIndexPatternId) { setLastUsedIndexPatternId(storage, currentIndexPatternId); - } - if (!requiredPatterns.includes(currentIndexPatternId)) { - requiredPatterns.push(currentIndexPatternId); + if (!requiredPatterns.includes(currentIndexPatternId)) { + requiredPatterns.push(currentIndexPatternId); + } } + const indexPatterns = await loadIndexPatterns({ indexPatternsService, cache: {}, @@ -265,7 +268,7 @@ export async function loadInitialState({ if (state) { return { ...state, - currentIndexPatternId, + currentIndexPatternId: currentIndexPatternId ?? fallbackId, indexPatternRefs, indexPatterns, existingFields: {}, @@ -274,7 +277,7 @@ export async function loadInitialState({ } return { - currentIndexPatternId, + currentIndexPatternId: currentIndexPatternId ?? fallbackId, indexPatternRefs, indexPatterns, layers: {}, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/time_shift_utils.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/time_shift_utils.tsx index a1bc643c3bd93..8cfd25914f59c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/time_shift_utils.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/time_shift_utils.tsx @@ -81,12 +81,6 @@ export const timeShiftOptions = [ }), value: '1y', }, - { - label: i18n.translate('xpack.lens.indexPattern.timeShift.previous', { - defaultMessage: 'Previous time range', - }), - value: 'previous', - }, ]; export const timeShiftOptionOrder = timeShiftOptions.reduce<{ [key: string]: number }>( diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.tsx index 82347f6212442..e5a5e76f8cc5d 100644 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.tsx @@ -164,7 +164,6 @@ export const AutocompleteFieldMatchAnyComponent: React.FC diff --git a/x-pack/plugins/lists/server/services/items/create_list_item.ts b/x-pack/plugins/lists/server/services/items/create_list_item.ts index b4203f000b7b9..ccdb8ab4779b6 100644 --- a/x-pack/plugins/lists/server/services/items/create_list_item.ts +++ b/x-pack/plugins/lists/server/services/items/create_list_item.ts @@ -15,9 +15,9 @@ import { SerializerOrUndefined, Type, } from '@kbn/securitysolution-io-ts-list-types'; +import { encodeHitVersion } from '@kbn/securitysolution-es-utils'; import { transformListItemToElasticQuery } from '../utils'; -import { encodeHitVersion } from '../utils/encode_hit_version'; import { IndexEsListItemSchema } from '../../schemas/elastic_query'; export interface CreateListItemOptions { diff --git a/x-pack/plugins/lists/server/services/items/update_list_item.ts b/x-pack/plugins/lists/server/services/items/update_list_item.ts index c73149019f416..78651bb83d73b 100644 --- a/x-pack/plugins/lists/server/services/items/update_list_item.ts +++ b/x-pack/plugins/lists/server/services/items/update_list_item.ts @@ -12,10 +12,9 @@ import type { MetaOrUndefined, _VersionOrUndefined, } from '@kbn/securitysolution-io-ts-list-types'; +import { decodeVersion, encodeHitVersion } from '@kbn/securitysolution-es-utils'; import { transformListItemToElasticQuery } from '../utils'; -import { decodeVersion } from '../utils/decode_version'; -import { encodeHitVersion } from '../utils/encode_hit_version'; import { UpdateEsListItemSchema } from '../../schemas/elastic_query'; import { getListItem } from './get_list_item'; diff --git a/x-pack/plugins/lists/server/services/lists/create_list.ts b/x-pack/plugins/lists/server/services/lists/create_list.ts index 6c7081d7c701e..521a38a51d6eb 100644 --- a/x-pack/plugins/lists/server/services/lists/create_list.ts +++ b/x-pack/plugins/lists/server/services/lists/create_list.ts @@ -19,8 +19,8 @@ import type { Type, } from '@kbn/securitysolution-io-ts-list-types'; import type { Version } from '@kbn/securitysolution-io-ts-types'; +import { encodeHitVersion } from '@kbn/securitysolution-es-utils'; -import { encodeHitVersion } from '../utils/encode_hit_version'; import { IndexEsListSchema } from '../../schemas/elastic_query'; export interface CreateListOptions { diff --git a/x-pack/plugins/lists/server/services/lists/update_list.ts b/x-pack/plugins/lists/server/services/lists/update_list.ts index 22235341ca075..11868a6187bbf 100644 --- a/x-pack/plugins/lists/server/services/lists/update_list.ts +++ b/x-pack/plugins/lists/server/services/lists/update_list.ts @@ -15,9 +15,8 @@ import type { _VersionOrUndefined, } from '@kbn/securitysolution-io-ts-list-types'; import { VersionOrUndefined } from '@kbn/securitysolution-io-ts-types'; +import { decodeVersion, encodeHitVersion } from '@kbn/securitysolution-es-utils'; -import { decodeVersion } from '../utils/decode_version'; -import { encodeHitVersion } from '../utils/encode_hit_version'; import { UpdateEsListSchema } from '../../schemas/elastic_query'; import { getList } from '.'; diff --git a/x-pack/plugins/lists/server/services/utils/index.ts b/x-pack/plugins/lists/server/services/utils/index.ts index 0cd2720bd199b..64e7c50d0e7b0 100644 --- a/x-pack/plugins/lists/server/services/utils/index.ts +++ b/x-pack/plugins/lists/server/services/utils/index.ts @@ -6,9 +6,7 @@ */ export * from './calculate_scroll_math'; -export * from './decode_version'; export * from './encode_decode_cursor'; -export * from './encode_hit_version'; export * from './escape_query'; export * from './find_source_type'; export * from './find_source_value'; diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list.ts index 19177c1c2785f..5b0949d7b79b7 100644 --- a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list.ts +++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list.ts @@ -7,11 +7,10 @@ import type { estypes } from '@elastic/elasticsearch'; import type { ListArraySchema } from '@kbn/securitysolution-io-ts-list-types'; +import { encodeHitVersion } from '@kbn/securitysolution-es-utils'; import { SearchEsListSchema } from '../../schemas/elastic_response'; -import { encodeHitVersion } from './encode_hit_version'; - export interface TransformElasticToListOptions { response: estypes.SearchResponse; } diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts index 79db56f9a7fe9..65392f8c379d9 100644 --- a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts +++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list_item.ts @@ -7,11 +7,11 @@ import type { estypes } from '@elastic/elasticsearch'; import type { ListItemArraySchema, Type } from '@kbn/securitysolution-io-ts-list-types'; +import { encodeHitVersion } from '@kbn/securitysolution-es-utils'; import { ErrorWithStatusCode } from '../../error_with_status_code'; import { SearchEsListItemSchema } from '../../schemas/elastic_response'; -import { encodeHitVersion } from './encode_hit_version'; import { findSourceValue } from './find_source_value'; export interface TransformElasticToListItemOptions { diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js index 7d461c4ec8572..ed603357206ad 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js +++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js @@ -128,6 +128,12 @@ class AnnotationsTableUI extends Component { jobId: undefined, }); }); + } else { + this.setState({ + annotations: [], + isLoading: false, + jobId: undefined, + }); } } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx index d5a62a91d3bd5..8736e27619ee5 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx @@ -63,7 +63,7 @@ export const JobFilterBar: FC = ({ queryText, setFilters }) = }, [queryText]); const onChange: EuiSearchBarProps['onChange'] = ({ query, error: queryError }) => { - if (error) { + if (queryError) { setError(queryError); } else { setFilters(query); diff --git a/x-pack/plugins/monitoring/public/alerts/enable_alerts_modal.tsx b/x-pack/plugins/monitoring/public/alerts/enable_alerts_modal.tsx index 914446c42aaa7..fadf4c5872507 100644 --- a/x-pack/plugins/monitoring/public/alerts/enable_alerts_modal.tsx +++ b/x-pack/plugins/monitoring/public/alerts/enable_alerts_modal.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect, useState, useContext } from 'react'; +import React, { useEffect, useState } from 'react'; import { EuiButton, @@ -22,14 +22,16 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { AlertsContext } from './context'; import { Legacy } from '../legacy_shims'; -export const EnableAlertsModal: React.FC<{}> = () => { +interface Props { + alerts: {}; +} + +export const EnableAlertsModal: React.FC = ({ alerts }: Props) => { const [isModalVisible, setIsModalVisible] = useState(false); const $injector = Legacy.shims.getAngularInjector(); const alertsEnableModalProvider: any = $injector.get('enableAlertsModal'); - const alertsContext = useContext(AlertsContext); const closeModal = () => { setIsModalVisible(false); @@ -58,10 +60,10 @@ export const EnableAlertsModal: React.FC<{}> = () => { }; useEffect(() => { - if (alertsEnableModalProvider.shouldShowAlertsModal(alertsContext)) { + if (alertsEnableModalProvider.shouldShowAlertsModal(alerts)) { setIsModalVisible(true); } - }, [alertsEnableModalProvider, alertsContext]); + }, [alertsEnableModalProvider, alerts]); const confirmButtonClick = () => { if (radioIdSelected === 'create-alerts') { diff --git a/x-pack/plugins/monitoring/public/services/enable_alerts_modal.js b/x-pack/plugins/monitoring/public/services/enable_alerts_modal.js index 0232e302517af..438c5ab83f5e3 100644 --- a/x-pack/plugins/monitoring/public/services/enable_alerts_modal.js +++ b/x-pack/plugins/monitoring/public/services/enable_alerts_modal.js @@ -12,12 +12,10 @@ export function enableAlertsModalProvider($http, $window, $injector) { const modalHasBeenShown = $window.sessionStorage.getItem('ALERTS_MODAL_HAS_BEEN_SHOWN'); const decisionMade = $window.localStorage.getItem('ALERTS_MODAL_DECISION_MADE'); - if (Object.keys(alerts.allAlerts).length > 0) { + if (Object.keys(alerts).length > 0) { $window.localStorage.setItem('ALERTS_MODAL_DECISION_MADE', true); return false; - } - - if (!modalHasBeenShown && !decisionMade) { + } else if (!modalHasBeenShown && !decisionMade) { return true; } diff --git a/x-pack/plugins/monitoring/public/views/cluster/listing/index.js b/x-pack/plugins/monitoring/public/views/cluster/listing/index.js index 9f9eec3848604..8b365292aeb13 100644 --- a/x-pack/plugins/monitoring/public/views/cluster/listing/index.js +++ b/x-pack/plugins/monitoring/public/views/cluster/listing/index.js @@ -13,6 +13,7 @@ import { MonitoringViewBaseEuiTableController } from '../../'; import template from './index.html'; import { Listing } from '../../../components/cluster/listing'; import { CODE_PATH_ALL } from '../../../../common/constants'; +import { EnableAlertsModal } from '../../../alerts/enable_alerts_modal.tsx'; const CODE_PATHS = [CODE_PATH_ALL]; @@ -21,6 +22,10 @@ const getPageData = ($injector) => { return monitoringClusters(undefined, undefined, CODE_PATHS); }; +const getAlerts = (clusters) => { + return clusters.reduce((alerts, cluster) => ({ ...alerts, ...cluster.alerts.list }), {}); +}; + uiRoutes .when('/home', { template, @@ -71,18 +76,21 @@ uiRoutes () => this.data, (data) => { this.renderReact( - + <> + + + ); } ); diff --git a/x-pack/plugins/monitoring/public/views/cluster/overview/index.js b/x-pack/plugins/monitoring/public/views/cluster/overview/index.js index bf34650bdb700..20e694ad8548f 100644 --- a/x-pack/plugins/monitoring/public/views/cluster/overview/index.js +++ b/x-pack/plugins/monitoring/public/views/cluster/overview/index.js @@ -83,7 +83,7 @@ uiRoutes.when('/overview', { setupMode={setupMode} showLicenseExpiration={showLicenseExpiration} /> - + {bottomBarComponent} )} diff --git a/x-pack/plugins/monitoring/server/types.ts b/x-pack/plugins/monitoring/server/types.ts index c4a0687bef497..b920f2bfacf80 100644 --- a/x-pack/plugins/monitoring/server/types.ts +++ b/x-pack/plugins/monitoring/server/types.ts @@ -20,6 +20,7 @@ import type { ActionsApiRequestHandlerContext, } from '../../actions/server'; import type { AlertingApiRequestHandlerContext } from '../../alerting/server'; +import type { RacApiRequestHandlerContext } from '../../rule_registry/server'; import { PluginStartContract as AlertingPluginStartContract, PluginSetupContract as AlertingPluginSetupContract, @@ -57,6 +58,7 @@ export interface RequestHandlerContextMonitoringPlugin extends RequestHandlerCon actions?: ActionsApiRequestHandlerContext; alerting?: AlertingApiRequestHandlerContext; infra: InfraRequestHandlerContext; + ruleRegistry?: RacApiRequestHandlerContext; } export interface PluginsStart { diff --git a/x-pack/plugins/observability/.storybook/jest_setup.js b/x-pack/plugins/observability/.storybook/jest_setup.js new file mode 100644 index 0000000000000..32071b8aa3f62 --- /dev/null +++ b/x-pack/plugins/observability/.storybook/jest_setup.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { setGlobalConfig } from '@storybook/testing-react'; +import * as globalStorybookConfig from './preview'; + +setGlobalConfig(globalStorybookConfig); diff --git a/x-pack/plugins/observability/.storybook/preview.js b/x-pack/plugins/observability/.storybook/preview.js new file mode 100644 index 0000000000000..18343c15a6465 --- /dev/null +++ b/x-pack/plugins/observability/.storybook/preview.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiThemeProviderDecorator } from '../../../../src/plugins/kibana_react/common'; + +export const decorators = [EuiThemeProviderDecorator]; diff --git a/x-pack/plugins/observability/jest.config.js b/x-pack/plugins/observability/jest.config.js index 66d42122382f3..6fdeab06df053 100644 --- a/x-pack/plugins/observability/jest.config.js +++ b/x-pack/plugins/observability/jest.config.js @@ -9,4 +9,5 @@ module.exports = { preset: '@kbn/test', rootDir: '../../..', roots: ['/x-pack/plugins/observability'], + setupFiles: ['/x-pack/plugins/observability/.storybook/jest_setup.js'], }; diff --git a/x-pack/plugins/observability/public/components/shared/core_web_vitals/__stories__/core_vitals.stories.tsx b/x-pack/plugins/observability/public/components/shared/core_web_vitals/__stories__/core_vitals.stories.tsx index 5f5cf2cb4da21..5c07b4626cf19 100644 --- a/x-pack/plugins/observability/public/components/shared/core_web_vitals/__stories__/core_vitals.stories.tsx +++ b/x-pack/plugins/observability/public/components/shared/core_web_vitals/__stories__/core_vitals.stories.tsx @@ -9,7 +9,6 @@ import React, { ComponentType } from 'react'; import { __IntlProvider as IntlProvider } from '@kbn/i18n/react'; import { Observable } from 'rxjs'; import { CoreStart } from 'src/core/public'; -import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; import { createKibanaReactContext } from '../../../../../../../../src/plugins/kibana_react/public'; import { CoreVitalItem } from '../core_vital_item'; import { LCP_HELP_LABEL, LCP_LABEL } from '../translations'; @@ -25,9 +24,7 @@ export default { (Story: ComponentType) => ( - - - + ), diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts index dd48cf3f7eeb8..ba1f2214223e3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts @@ -44,6 +44,7 @@ import { TAGS_LABEL, TBT_LABEL, URL_LABEL, + BACKEND_TIME_LABEL, } from './labels'; export const DEFAULT_TIME = { from: 'now-1h', to: 'now' }; @@ -66,7 +67,7 @@ export const FieldLabels: Record = { [TBT_FIELD]: TBT_LABEL, [FID_FIELD]: FID_LABEL, [CLS_FIELD]: CLS_LABEL, - [TRANSACTION_TIME_TO_FIRST_BYTE]: 'Page load time', + [TRANSACTION_TIME_TO_FIRST_BYTE]: BACKEND_TIME_LABEL, 'monitor.id': MONITOR_ID_LABEL, 'monitor.status': MONITOR_STATUS_LABEL, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts index 0be64677586c1..ae70bbdcfa3b8 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts @@ -411,6 +411,7 @@ describe('Lens Attribute', () => { sourceField: USER_AGENT_NAME, layerId: 'layer0', indexPattern: mockIndexPattern, + labels: layerConfig.seriesConfig.labels, }); expect(lnsAttr.visualization.layers).toEqual([ diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts index 5734cd1592692..dfb17ee470d35 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts @@ -30,7 +30,6 @@ import { import { urlFiltersToKueryString } from '../utils/stringify_kueries'; import { ExistsFilter, IndexPattern } from '../../../../../../../../src/plugins/data/common'; import { - FieldLabels, FILTER_RECORDS, USE_BREAK_DOWN_COLUMN, TERMS_COLUMN, @@ -125,17 +124,19 @@ export class LensAttributes { getBreakdownColumn({ sourceField, layerId, + labels, indexPattern, }: { sourceField: string; layerId: string; + labels: Record; indexPattern: IndexPattern; }): TermsIndexPatternColumn { const fieldMeta = indexPattern.getFieldByName(sourceField); return { sourceField, - label: `Top values of ${FieldLabels[sourceField]}`, + label: `Top values of ${labels[sourceField]}`, dataType: fieldMeta?.type as DataType, operationType: 'terms', scale: 'ordinal', @@ -304,6 +305,7 @@ export class LensAttributes { layerId, indexPattern: layerConfig.indexPattern, sourceField: layerConfig.breakdown || layerConfig.seriesConfig.breakdownFields[0], + labels: layerConfig.seriesConfig.labels, }); } @@ -344,7 +346,7 @@ export class LensAttributes { if (fieldName === RECORDS_FIELD || columnType === FILTER_RECORDS) { return this.getRecordsColumn( - columnLabel || label, + label || columnLabel, colIndex !== undefined ? columnFilters?.[colIndex] : undefined, timeScale ); @@ -433,6 +435,8 @@ export class LensAttributes { if (yAxisColumns.length === 1) { return lensColumns; } + + // starting from 1 index since 0 column is used as main column for (let i = 1; i < yAxisColumns.length; i++) { const { sourceField, operationType, label } = yAxisColumns[i]; @@ -555,16 +559,21 @@ export class LensAttributes { const layerConfigs = this.layerConfigs; layerConfigs.forEach((layerConfig, index) => { - const { breakdown } = layerConfig; + const { breakdown, seriesConfig } = layerConfig; const layerId = `layer${index}`; const columnFilter = this.getLayerFilters(layerConfig, layerConfigs.length); const timeShift = this.getTimeShift(this.layerConfigs[0], layerConfig, index); const mainYAxis = this.getMainYAxis(layerConfig, layerId, columnFilter); + + const { sourceField } = seriesConfig.xAxisColumn; + layers[layerId] = { columnOrder: [ `x-axis-column-${layerId}`, - ...(breakdown ? [`breakdown-column-${layerId}`] : []), + ...(breakdown && sourceField !== USE_BREAK_DOWN_COLUMN + ? [`breakdown-column-${layerId}`] + : []), `y-axis-column-${layerId}`, ...Object.keys(this.getChildYAxises(layerConfig, layerId, columnFilter)), ], @@ -576,13 +585,14 @@ export class LensAttributes { filter: { query: columnFilter, language: 'kuery' }, ...(timeShift ? { timeShift } : {}), }, - ...(breakdown && breakdown !== USE_BREAK_DOWN_COLUMN + ...(breakdown && sourceField !== USE_BREAK_DOWN_COLUMN ? // do nothing since this will be used a x axis source { [`breakdown-column-${layerId}`]: this.getBreakdownColumn({ layerId, sourceField: breakdown, indexPattern: layerConfig.indexPattern, + labels: layerConfig.seriesConfig.labels, }), } : {}), @@ -617,7 +627,10 @@ export class LensAttributes { { forAccessor: `y-axis-column-layer${index}` }, ], xAccessor: `x-axis-column-layer${index}`, - ...(layerConfig.breakdown ? { splitAccessor: `breakdown-column-layer${index}` } : {}), + ...(layerConfig.breakdown && + layerConfig.seriesConfig.xAxisColumn.sourceField !== USE_BREAK_DOWN_COLUMN + ? { splitAccessor: `breakdown-column-layer${index}` } + : {}), })), ...(this.layerConfigs[0].seriesConfig.yTitle ? { yTitle: this.layerConfigs[0].seriesConfig.yTitle } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts index 98979b9922a86..d1612a08f5551 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts @@ -6,7 +6,7 @@ */ import { ConfigProps, SeriesConfig } from '../../types'; -import { FieldLabels, USE_BREAK_DOWN_COLUMN } from '../constants'; +import { FieldLabels, REPORT_METRIC_FIELD, USE_BREAK_DOWN_COLUMN } from '../constants'; import { buildPhraseFilter } from '../utils'; import { SERVICE_NAME } from '../constants/elasticsearch_fieldnames'; import { MOBILE_APP, NUMBER_OF_DEVICES } from '../constants/labels'; @@ -22,9 +22,8 @@ export function getMobileDeviceDistributionConfig({ indexPattern }: ConfigProps) }, yAxisColumns: [ { - sourceField: 'labels.device_id', + sourceField: REPORT_METRIC_FIELD, operationType: 'unique_count', - label: NUMBER_OF_DEVICES, }, ], hasOperationType: false, @@ -39,6 +38,13 @@ export function getMobileDeviceDistributionConfig({ indexPattern }: ConfigProps) ...MobileFields, [SERVICE_NAME]: MOBILE_APP, }, + metricOptions: [ + { + id: 'labels.device_id', + field: 'labels.device_id', + label: NUMBER_OF_DEVICES, + }, + ], definitionFields: [SERVICE_NAME], }; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts index b9894347d96c0..9b1c4c8da3e9b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts @@ -6,7 +6,7 @@ */ import { ConfigProps, SeriesConfig } from '../../types'; -import { FieldLabels, OPERATION_COLUMN, RECORDS_FIELD, REPORT_METRIC_FIELD } from '../constants'; +import { FieldLabels, RECORDS_FIELD, REPORT_METRIC_FIELD } from '../constants'; import { buildPhrasesFilter } from '../utils'; import { METRIC_SYSTEM_CPU_USAGE, @@ -49,19 +49,16 @@ export function getMobileKPIDistributionConfig({ indexPattern }: ConfigProps): S label: RESPONSE_LATENCY, field: TRANSACTION_DURATION, id: TRANSACTION_DURATION, - columnType: OPERATION_COLUMN, }, { label: MEMORY_USAGE, field: METRIC_SYSTEM_MEMORY_USAGE, id: METRIC_SYSTEM_MEMORY_USAGE, - columnType: OPERATION_COLUMN, }, { label: CPU_USAGE, field: METRIC_SYSTEM_CPU_USAGE, id: METRIC_SYSTEM_CPU_USAGE, - columnType: OPERATION_COLUMN, }, ], }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.test.ts new file mode 100644 index 0000000000000..07bb13f957e45 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mockAppIndexPattern, mockIndexPattern } from '../../rtl_helpers'; +import { getDefaultConfigs } from '../default_configs'; +import { LayerConfig, LensAttributes } from '../lens_attributes'; +import { sampleAttributeCoreWebVital } from '../test_data/sample_attribute_cwv'; +import { SERVICE_NAME, USER_AGENT_OS } from '../constants/elasticsearch_fieldnames'; + +describe('Core web vital config test', function () { + mockAppIndexPattern(); + + const seriesConfig = getDefaultConfigs({ + reportType: 'core-web-vitals', + dataType: 'ux', + indexPattern: mockIndexPattern, + }); + + let lnsAttr: LensAttributes; + + const layerConfig: LayerConfig = { + seriesConfig, + indexPattern: mockIndexPattern, + reportDefinitions: { [SERVICE_NAME]: ['elastic-co'] }, + time: { from: 'now-15m', to: 'now' }, + breakdown: USER_AGENT_OS, + }; + + beforeEach(() => { + lnsAttr = new LensAttributes([layerConfig]); + }); + it('should return expected json', function () { + expect(lnsAttr.getJSON()).toEqual(sampleAttributeCoreWebVital); + }); +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts index 1d04a9b389503..62455df248085 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts @@ -31,6 +31,7 @@ import { URL_FULL, SERVICE_ENVIRONMENT, } from '../constants/elasticsearch_fieldnames'; +import { CLS_LABEL, FID_LABEL, LCP_LABEL } from '../constants/labels'; export function getCoreWebVitalsConfig({ indexPattern }: ConfigProps): SeriesConfig { const statusPallete = euiPaletteForStatus(3); @@ -91,7 +92,7 @@ export function getCoreWebVitalsConfig({ indexPattern }: ConfigProps): SeriesCon metricOptions: [ { id: LCP_FIELD, - label: 'Largest contentful paint', + label: LCP_LABEL, columnType: FILTER_RECORDS, columnFilters: [ { @@ -109,7 +110,7 @@ export function getCoreWebVitalsConfig({ indexPattern }: ConfigProps): SeriesCon ], }, { - label: 'First input delay', + label: FID_LABEL, id: FID_FIELD, columnType: FILTER_RECORDS, columnFilters: [ @@ -128,7 +129,7 @@ export function getCoreWebVitalsConfig({ indexPattern }: ConfigProps): SeriesCon ], }, { - label: 'Cumulative layout shift', + label: CLS_LABEL, id: CLS_FIELD, columnType: FILTER_RECORDS, columnFilters: [ diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts new file mode 100644 index 0000000000000..2087b85b81886 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export const sampleAttributeCoreWebVital = { + description: '', + references: [ + { + id: 'apm-*', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'apm-*', + name: 'indexpattern-datasource-layer-layer0', + type: 'index-pattern', + }, + ], + state: { + datasourceStates: { + indexpattern: { + layers: { + layer0: { + columnOrder: [ + 'x-axis-column-layer0', + 'y-axis-column-layer0', + 'y-axis-column-1', + 'y-axis-column-2', + ], + columns: { + 'x-axis-column-layer0': { + dataType: 'string', + isBucketed: true, + label: 'Top values of Operating system', + operationType: 'terms', + params: { + missingBucket: false, + orderBy: { + columnId: 'y-axis-column-layer0', + type: 'column', + }, + orderDirection: 'desc', + otherBucket: true, + size: 10, + }, + scale: 'ordinal', + sourceField: 'user_agent.os.name', + }, + 'y-axis-column-1': { + dataType: 'number', + filter: { + language: 'kuery', + query: + 'transaction.marks.agent.largestContentfulPaint > 2500 and transaction.marks.agent.largestContentfulPaint < 4000', + }, + isBucketed: false, + label: 'Average', + operationType: 'count', + scale: 'ratio', + sourceField: 'Records', + }, + 'y-axis-column-2': { + dataType: 'number', + filter: { + language: 'kuery', + query: 'transaction.marks.agent.largestContentfulPaint > 4000', + }, + isBucketed: false, + label: 'Poor', + operationType: 'count', + scale: 'ratio', + sourceField: 'Records', + }, + 'y-axis-column-layer0': { + dataType: 'number', + filter: { + language: 'kuery', + query: 'transaction.type: page-load and processor.event: transaction', + }, + isBucketed: false, + label: 'Good', + operationType: 'count', + scale: 'ratio', + sourceField: 'Records', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [], + query: { + language: 'kuery', + query: '', + }, + visualization: { + axisTitlesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + curveType: 'CURVE_MONOTONE_X', + fittingFunction: 'Linear', + gridlinesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + layers: [ + { + accessors: ['y-axis-column-layer0', 'y-axis-column-1', 'y-axis-column-2'], + layerId: 'layer0', + seriesType: 'bar_horizontal_percentage_stacked', + xAccessor: 'x-axis-column-layer0', + yConfig: [ + { + color: '#209280', + forAccessor: 'y-axis-column', + }, + { + color: '#d6bf57', + forAccessor: 'y-axis-column-1', + }, + { + color: '#cc5642', + forAccessor: 'y-axis-column-2', + }, + ], + }, + ], + legend: { + isVisible: true, + position: 'right', + }, + preferredSeriesType: 'line', + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + valueLabels: 'hide', + }, + }, + title: 'Prefilled from exploratory view app', + visualizationType: 'lnsXY', +}; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx index 07048d47b2bc3..12ae8560453c9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx @@ -36,7 +36,6 @@ describe('ReportTypesCol', function () { fireEvent.click(screen.getByText(/KPI over time/i)); expect(setSeries).toHaveBeenCalledWith(seriesId, { - breakdown: 'user_agent.name', dataType: 'ux', selectedMetricField: undefined, reportType: 'kpi-over-time', diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx index 396f8c4f1deb3..c4eebbfaca3eb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx @@ -78,6 +78,7 @@ export function ReportTypesCol({ seriesId, reportTypes }: Props) { ...restSeries, reportType, selectedMetricField: undefined, + breakdown: undefined, time: restSeries?.time ?? DEFAULT_TIME, }); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/observability_index_patterns.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/observability_index_patterns.ts index 634408dd614da..964de86ddf377 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/observability_index_patterns.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/utils/observability_index_patterns.ts @@ -100,11 +100,14 @@ export class ObservabilityIndexPatterns { if (defaultFieldFormats && defaultFieldFormats.length > 0) { let isParamsDifferent = false; defaultFieldFormats.forEach(({ field, format }) => { - const fieldFormat = indexPattern.getFormatterForField(indexPattern.getFieldByName(field)!); - const params = fieldFormat.params(); - if (!isParamsSame(params, format.params)) { - indexPattern.setFieldFormat(field, format); - isParamsDifferent = true; + const fieldByName = indexPattern.getFieldByName(field); + if (fieldByName) { + const fieldFormat = indexPattern.getFormatterForField(fieldByName); + const params = fieldFormat.params(); + if (!isParamsSame(params, format.params)) { + indexPattern.setFieldFormat(field, format); + isParamsDifferent = true; + } } }); if (isParamsDifferent) { diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/__stories__/field_value_selection.stories.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/__stories__/field_value_selection.stories.tsx index 80a25b82eb8cb..1152ba32960ed 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/__stories__/field_value_selection.stories.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/__stories__/field_value_selection.stories.tsx @@ -10,7 +10,6 @@ import { __IntlProvider as IntlProvider } from '@kbn/i18n/react'; import { Observable } from 'rxjs'; import { CoreStart } from 'src/core/public'; import { text } from '@storybook/addon-knobs'; -import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common'; import { createKibanaReactContext } from '../../../../../../../../src/plugins/kibana_react/public'; import { FieldValueSelectionProps } from '../types'; import { FieldValueSelection } from '../field_value_selection'; @@ -31,16 +30,14 @@ export default { (Story: ComponentType) => ( - - {}} - selectedValue={[]} - loading={false} - setQuery={() => {}} - /> - + {}} + selectedValue={[]} + loading={false} + setQuery={() => {}} + /> ), diff --git a/x-pack/plugins/observability/public/pages/landing/landing.stories.tsx b/x-pack/plugins/observability/public/pages/landing/landing.stories.tsx index 86922b045c742..ef3ded61492c7 100644 --- a/x-pack/plugins/observability/public/pages/landing/landing.stories.tsx +++ b/x-pack/plugins/observability/public/pages/landing/landing.stories.tsx @@ -6,7 +6,6 @@ */ import React, { ComponentType } from 'react'; -import { EuiThemeProvider } from '../../../../../../src/plugins/kibana_react/common'; import { PluginContext, PluginContextValue } from '../../context/plugin_context'; import { LandingPage } from './'; @@ -27,9 +26,7 @@ export default { } as unknown) as PluginContextValue; return ( - - - + ); }, diff --git a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx index dd424cf221d15..2982333235331 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -10,7 +10,6 @@ import { storiesOf } from '@storybook/react'; import { AppMountParameters, CoreStart } from 'kibana/public'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { EuiThemeProvider } from '../../../../../../src/plugins/kibana_react/common'; import { UI_SETTINGS } from '../../../../../../src/plugins/data/public'; import { HasDataContextProvider } from '../../context/has_data_context'; import { PluginContext } from '../../context/plugin_context'; @@ -65,9 +64,7 @@ const withCore = makeDecorator({ ObservabilityPageTemplate: KibanaPageTemplate, }} > - - {storyFn(context)} - + {storyFn(context)} ); diff --git a/x-pack/plugins/observability/server/plugin.ts b/x-pack/plugins/observability/server/plugin.ts index d820a6c0a6f76..3e8f511eb1153 100644 --- a/x-pack/plugins/observability/server/plugin.ts +++ b/x-pack/plugins/observability/server/plugin.ts @@ -99,6 +99,7 @@ export class ObservabilityPlugin implements Plugin { const start = () => core.getStartServices().then(([coreStart]) => coreStart); const ruleDataClient = plugins.ruleRegistry.ruleDataService.getRuleDataClient( + 'observability', plugins.ruleRegistry.ruleDataService.getFullAssetName(), () => Promise.resolve() ); diff --git a/x-pack/plugins/osquery/public/action_results/action_agents_status.tsx b/x-pack/plugins/osquery/public/action_results/action_agents_status.tsx new file mode 100644 index 0000000000000..2de5ab11664ae --- /dev/null +++ b/x-pack/plugins/osquery/public/action_results/action_agents_status.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { useEffect, useMemo, useState } from 'react'; + +import { Direction } from '../../common/search_strategy'; +import { AgentStatusBar } from './action_agents_status_bar'; +import { ActionAgentsStatusBadges } from './action_agents_status_badges'; +import { useActionResults } from './use_action_results'; + +interface ActionAgentsStatusProps { + actionId: string; + expirationDate?: string; + agentIds?: string[]; +} + +const ActionAgentsStatusComponent: React.FC = ({ + actionId, + expirationDate, + agentIds, +}) => { + const [isLive, setIsLive] = useState(true); + const expired = useMemo(() => (!expirationDate ? false : new Date(expirationDate) < new Date()), [ + expirationDate, + ]); + const { + // @ts-expect-error update types + data: { aggregations }, + } = useActionResults({ + actionId, + activePage: 0, + agentIds, + limit: 0, + direction: Direction.asc, + sortField: '@timestamp', + isLive, + }); + + const agentStatus = useMemo(() => { + const notRespondedCount = !agentIds?.length ? 0 : agentIds.length - aggregations.totalResponded; + + return { + success: aggregations.successful, + pending: notRespondedCount, + failed: aggregations.failed, + }; + }, [agentIds?.length, aggregations.failed, aggregations.successful, aggregations.totalResponded]); + + useEffect( + () => + setIsLive(() => { + if (!agentIds?.length || expired) return false; + + return !!(aggregations.totalResponded !== agentIds?.length); + }), + [agentIds?.length, aggregations.totalResponded, expired] + ); + + return ( + <> + + + + + + + + + + + + + + + + + ); +}; + +export const ActionAgentsStatus = React.memo(ActionAgentsStatusComponent); diff --git a/x-pack/plugins/osquery/public/action_results/action_agents_status_badges.tsx b/x-pack/plugins/osquery/public/action_results/action_agents_status_badges.tsx new file mode 100644 index 0000000000000..95b96ca454610 --- /dev/null +++ b/x-pack/plugins/osquery/public/action_results/action_agents_status_badges.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiHealth, EuiNotificationBadge, EuiFlexItem } from '@elastic/eui'; +import React, { memo } from 'react'; + +import { + AGENT_STATUSES, + getColorForAgentStatus, + getLabelForAgentStatus, +} from './services/agent_status'; +import type { ActionAgentStatus } from './types'; + +export const ActionAgentsStatusBadges = memo<{ + agentStatus: { [k in ActionAgentStatus]: number }; + expired: boolean; +}>(({ agentStatus, expired }) => ( + + {AGENT_STATUSES.map((status) => ( + + + + ))} + +)); + +ActionAgentsStatusBadges.displayName = 'ActionAgentsStatusBadges'; + +const AgentStatusBadge = memo<{ expired: boolean; status: ActionAgentStatus; count: number }>( + ({ expired, status, count }) => ( + <> + + + {getLabelForAgentStatus(status, expired)} + + + {count} + + + + + + ) +); + +AgentStatusBadge.displayName = 'AgentStatusBadge'; diff --git a/x-pack/plugins/osquery/public/action_results/action_agents_status_bar.tsx b/x-pack/plugins/osquery/public/action_results/action_agents_status_bar.tsx new file mode 100644 index 0000000000000..21866566cb7e3 --- /dev/null +++ b/x-pack/plugins/osquery/public/action_results/action_agents_status_bar.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import styled from 'styled-components'; +import { EuiColorPaletteDisplay } from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { AGENT_STATUSES, getColorForAgentStatus } from './services/agent_status'; +import type { ActionAgentStatus } from './types'; + +const StyledEuiColorPaletteDisplay = styled(EuiColorPaletteDisplay)` + &.osquery-action-agent-status-bar { + border: none; + border-radius: 0; + &:after { + border: none; + } + } +`; + +export const AgentStatusBar: React.FC<{ + agentStatus: { [k in ActionAgentStatus]: number }; +}> = ({ agentStatus }) => { + const palette = useMemo(() => { + let stop = 0; + return AGENT_STATUSES.reduce((acc, status) => { + stop += agentStatus[status] || 0; + acc.push({ + stop, + color: getColorForAgentStatus(status), + }); + return acc; + }, [] as Array<{ stop: number; color: string }>); + }, [agentStatus]); + return ( + + ); +}; diff --git a/x-pack/plugins/osquery/public/action_results/action_results_summary.tsx b/x-pack/plugins/osquery/public/action_results/action_results_summary.tsx index 257c89047aab0..d3b0e38a5e033 100644 --- a/x-pack/plugins/osquery/public/action_results/action_results_summary.tsx +++ b/x-pack/plugins/osquery/public/action_results/action_results_summary.tsx @@ -8,20 +8,8 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { i18n } from '@kbn/i18n'; -import { - EuiLink, - EuiFlexGroup, - EuiFlexItem, - EuiCard, - EuiTextColor, - EuiSpacer, - EuiDescriptionList, - EuiInMemoryTable, - EuiCodeBlock, - EuiProgress, -} from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; -import styled from 'styled-components'; +import { EuiLink, EuiInMemoryTable, EuiCodeBlock } from '@elastic/eui'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { PLUGIN_ID } from '../../../fleet/common'; import { pagePathGetters } from '../../../fleet/public'; @@ -30,15 +18,10 @@ import { useAllResults } from '../results/use_all_results'; import { Direction } from '../../common/search_strategy'; import { useKibana } from '../common/lib/kibana'; -const StyledEuiCard = styled(EuiCard)` - position: relative; -`; - interface ActionResultsSummaryProps { actionId: string; - expirationDate: Date; + expirationDate?: string; agentIds?: string[]; - isLive?: boolean; } const renderErrorMessage = (error: string) => ( @@ -51,14 +34,16 @@ const ActionResultsSummaryComponent: React.FC = ({ actionId, expirationDate, agentIds, - isLive, }) => { const getUrlForApp = useKibana().services.application.getUrlForApp; // @ts-expect-error update types const [pageIndex, setPageIndex] = useState(0); // @ts-expect-error update types const [pageSize, setPageSize] = useState(50); - const expired = useMemo(() => expirationDate < new Date(), [expirationDate]); + const expired = useMemo(() => (!expirationDate ? false : new Date(expirationDate) < new Date()), [ + expirationDate, + ]); + const [isLive, setIsLive] = useState(true); const { // @ts-expect-error update types data: { aggregations, edges }, @@ -69,7 +54,7 @@ const ActionResultsSummaryComponent: React.FC = ({ limit: pageSize, direction: Direction.asc, sortField: '@timestamp', - isLive: !expired && isLive, + isLive, }); const { data: logsResults } = useAllResults({ @@ -82,64 +67,15 @@ const ActionResultsSummaryComponent: React.FC = ({ direction: Direction.asc, }, ], - isLive: !expired && isLive, + isLive, }); - const notRespondedCount = useMemo(() => { - if (!agentIds || !aggregations.totalResponded) { - return '-'; - } - - return agentIds.length - aggregations.totalResponded; - }, [aggregations.totalResponded, agentIds]); - - const listItems = useMemo( - () => [ - { - title: i18n.translate( - 'xpack.osquery.liveQueryActionResults.summary.agentsQueriedLabelText', - { - defaultMessage: 'Agents queried', - } - ), - description: agentIds?.length, - }, - { - title: i18n.translate('xpack.osquery.liveQueryActionResults.summary.successfulLabelText', { - defaultMessage: 'Successful', - }), - description: aggregations.successful, - }, - { - title: expired - ? i18n.translate('xpack.osquery.liveQueryActionResults.summary.expiredLabelText', { - defaultMessage: 'Expired', - }) - : i18n.translate('xpack.osquery.liveQueryActionResults.summary.pendingLabelText', { - defaultMessage: 'Not yet responded', - }), - description: notRespondedCount, - }, - { - title: i18n.translate('xpack.osquery.liveQueryActionResults.summary.failedLabelText', { - defaultMessage: 'Failed', - }), - description: ( - - {aggregations.failed} - - ), - }, - ], - [agentIds, aggregations.failed, aggregations.successful, notRespondedCount, expired] - ); - const renderAgentIdColumn = useCallback( (agentId) => ( @@ -236,30 +172,26 @@ const ActionResultsSummaryComponent: React.FC = ({ [] ); - return ( - <> - - - - {!expired && notRespondedCount ? : null} - - - - + useEffect(() => { + setIsLive(() => { + if (!agentIds?.length || expired) return false; - {edges.length ? ( - <> - - - - ) : null} - - ); + const uniqueAgentsRepliedCount = + // @ts-expect-error update types + logsResults?.rawResponse.aggregations?.unique_agents.value ?? 0; + + return !!(uniqueAgentsRepliedCount !== agentIds?.length - aggregations.failed); + }); + }, [ + agentIds?.length, + aggregations.failed, + expired, + logsResults?.rawResponse.aggregations?.unique_agents, + ]); + + return edges.length ? ( + + ) : null; }; export const ActionResultsSummary = React.memo(ActionResultsSummaryComponent); diff --git a/x-pack/plugins/osquery/public/action_results/services/agent_status.tsx b/x-pack/plugins/osquery/public/action_results/services/agent_status.tsx new file mode 100644 index 0000000000000..39a033f49ec90 --- /dev/null +++ b/x-pack/plugins/osquery/public/action_results/services/agent_status.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { euiPaletteColorBlindBehindText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import type { ActionAgentStatus } from '../types'; + +const visColors = euiPaletteColorBlindBehindText(); +const colorToHexMap = { + default: '#d3dae6', + primary: visColors[1], + secondary: visColors[0], + accent: visColors[2], + warning: visColors[5], + danger: visColors[9], +}; + +export const AGENT_STATUSES: ActionAgentStatus[] = ['success', 'pending', 'failed']; + +export function getColorForAgentStatus(agentStatus: ActionAgentStatus): string { + switch (agentStatus) { + case 'success': + return colorToHexMap.secondary; + case 'pending': + return colorToHexMap.default; + case 'failed': + return colorToHexMap.danger; + default: + throw new Error(`Unsupported action agent status ${agentStatus}`); + } +} + +export function getLabelForAgentStatus(agentStatus: ActionAgentStatus, expired: boolean): string { + switch (agentStatus) { + case 'success': + return i18n.translate('xpack.osquery.liveQueryActionResults.summary.successfulLabelText', { + defaultMessage: 'Successful', + }); + case 'pending': + return expired + ? i18n.translate('xpack.osquery.liveQueryActionResults.summary.expiredLabelText', { + defaultMessage: 'Expired', + }) + : i18n.translate('xpack.osquery.liveQueryActionResults.summary.pendingLabelText', { + defaultMessage: 'Not yet responded', + }); + case 'failed': + return i18n.translate('xpack.osquery.liveQueryActionResults.summary.failedLabelText', { + defaultMessage: 'Failed', + }); + default: + throw new Error(`Unsupported action agent status ${agentStatus}`); + } +} diff --git a/x-pack/plugins/osquery/public/action_results/types.ts b/x-pack/plugins/osquery/public/action_results/types.ts new file mode 100644 index 0000000000000..ce9415986ba02 --- /dev/null +++ b/x-pack/plugins/osquery/public/action_results/types.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type ActionAgentStatus = 'success' | 'pending' | 'failed'; diff --git a/x-pack/plugins/osquery/public/action_results/use_action_results.ts b/x-pack/plugins/osquery/public/action_results/use_action_results.ts index ab69bf86dc326..29bff0819956a 100644 --- a/x-pack/plugins/osquery/public/action_results/use_action_results.ts +++ b/x-pack/plugins/osquery/public/action_results/use_action_results.ts @@ -83,7 +83,7 @@ export const useActionResults = ({ const totalResponded = // @ts-expect-error update types - responseData.rawResponse?.aggregations?.aggs.responses_by_action_id?.doc_count; + responseData.rawResponse?.aggregations?.aggs.responses_by_action_id?.doc_count ?? 0; const aggsBuckets = // @ts-expect-error update types responseData.rawResponse?.aggregations?.aggs.responses_by_action_id?.responses.buckets; @@ -120,7 +120,7 @@ export const useActionResults = ({ failed: 0, }, }, - refetchInterval: isLive ? 1000 : false, + refetchInterval: isLive ? 5000 : false, keepPreviousData: true, enabled: !skip && !!agentIds?.length, onSuccess: () => setErrorToast(), diff --git a/x-pack/plugins/osquery/public/agent_policies/agents_policy_link.tsx b/x-pack/plugins/osquery/public/agent_policies/agents_policy_link.tsx index 98402e349c77d..81953135b5321 100644 --- a/x-pack/plugins/osquery/public/agent_policies/agents_policy_link.tsx +++ b/x-pack/plugins/osquery/public/agent_policies/agents_policy_link.tsx @@ -27,7 +27,7 @@ const AgentsPolicyLinkComponent: React.FC = ({ policyId } const href = useMemo( () => getUrlForApp(PLUGIN_ID, { - path: `#` + pagePathGetters.policy_details({ policyId }), + path: `#` + pagePathGetters.policy_details({ policyId })[1], }), [getUrlForApp, policyId] ); @@ -38,7 +38,7 @@ const AgentsPolicyLinkComponent: React.FC = ({ policyId } event.preventDefault(); return navigateToApp(PLUGIN_ID, { - path: `#` + pagePathGetters.policy_details({ policyId }), + path: `#` + pagePathGetters.policy_details({ policyId })[1], }); } }, diff --git a/x-pack/plugins/osquery/public/agent_policies/use_agent_policies.ts b/x-pack/plugins/osquery/public/agent_policies/use_agent_policies.ts index 6f87610667198..c51f2d2f44a5c 100644 --- a/x-pack/plugins/osquery/public/agent_policies/use_agent_policies.ts +++ b/x-pack/plugins/osquery/public/agent_policies/use_agent_policies.ts @@ -30,7 +30,6 @@ export const useAgentPolicies = () => { }), { initialData: { items: [], total: 0, page: 1, perPage: 100 }, - placeholderData: [], keepPreviousData: true, select: (response) => response.items, onSuccess: () => setErrorToast(), diff --git a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx index 28d69a6a7b15a..63036f5f693f7 100644 --- a/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx +++ b/x-pack/plugins/osquery/public/fleet_integration/osquery_managed_policy_create_import_extension.tsx @@ -57,7 +57,7 @@ export const OsqueryManagedPolicyCreateImportExtension = React.memo< return getUrlForApp(PLUGIN_ID, { path: `#` + - pagePathGetters.policy_details({ policyId: policy?.policy_id }) + + pagePathGetters.policy_details({ policyId: policy?.policy_id })[1] + '?openEnrollmentFlyout=true', }); }, [getUrlForApp, policy?.policy_id]); diff --git a/x-pack/plugins/osquery/public/index.ts b/x-pack/plugins/osquery/public/index.ts index f0e956b64ee06..fadd61cce85ef 100644 --- a/x-pack/plugins/osquery/public/index.ts +++ b/x-pack/plugins/osquery/public/index.ts @@ -13,4 +13,4 @@ import { OsqueryPlugin } from './plugin'; export function plugin(initializerContext: PluginInitializerContext) { return new OsqueryPlugin(initializerContext); } -export { OsqueryPluginSetup, OsqueryPluginStart } from './types'; +export type { OsqueryPluginSetup, OsqueryPluginStart } from './types'; diff --git a/x-pack/plugins/osquery/public/live_queries/agent_results/index.tsx b/x-pack/plugins/osquery/public/live_queries/agent_results/index.tsx deleted file mode 100644 index d1ef18e2e12ea..0000000000000 --- a/x-pack/plugins/osquery/public/live_queries/agent_results/index.tsx +++ /dev/null @@ -1,30 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiCodeBlock, EuiSpacer } from '@elastic/eui'; -import React from 'react'; -import { useParams } from 'react-router-dom'; - -import { useActionDetails } from '../../actions/use_action_details'; -import { ResultsTable } from '../../results/results_table'; - -const QueryAgentResultsComponent = () => { - const { actionId, agentId } = useParams<{ actionId: string; agentId: string }>(); - const { data } = useActionDetails({ actionId }); - - return ( - <> - - {data?.actionDetails._source?.data?.query} - - - - - ); -}; - -export const QueryAgentResults = React.memo(QueryAgentResultsComponent); diff --git a/x-pack/plugins/osquery/public/live_queries/form/index.tsx b/x-pack/plugins/osquery/public/live_queries/form/index.tsx index 9e952810e3352..8654a74fecfb4 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx @@ -18,6 +18,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useCallback, useMemo, useState } from 'react'; import { useMutation } from 'react-query'; +import deepMerge from 'deepmerge'; import { UseField, Form, FormData, useForm, useFormData, FIELD_TYPES } from '../../shared_imports'; import { AgentsTableField } from './agents_table_field'; @@ -33,12 +34,19 @@ const FORM_ID = 'liveQueryForm'; export const MAX_QUERY_LENGTH = 2000; +const GhostFormField = () => <>; + interface LiveQueryFormProps { + agentId?: string | undefined; defaultValue?: Partial | undefined; onSuccess?: () => void; } -const LiveQueryFormComponent: React.FC = ({ defaultValue, onSuccess }) => { +const LiveQueryFormComponent: React.FC = ({ + agentId, + defaultValue, + onSuccess, +}) => { const { http } = useKibana().services; const [showSavedQueryFlyout, setShowSavedQueryFlyout] = useState(false); const setErrorToast = useErrorToast(); @@ -71,8 +79,6 @@ const LiveQueryFormComponent: React.FC = ({ defaultValue, on } ); - const expirationDate = useMemo(() => new Date(data?.actions[0].expiration), [data?.actions]); - const formSchema = { query: { type: FIELD_TYPES.TEXT, @@ -100,9 +106,18 @@ const LiveQueryFormComponent: React.FC = ({ defaultValue, on options: { stripEmptyFields: false, }, - defaultValue: defaultValue ?? { - query: '', - }, + defaultValue: deepMerge( + { + agentSelection: { + agents: [], + allAgentsSelected: false, + platformsSelected: [], + policiesSelected: [], + }, + query: '', + }, + defaultValue ?? {} + ), }); const { submit } = form; @@ -147,6 +162,59 @@ const LiveQueryFormComponent: React.FC = ({ defaultValue, on const flyoutFormDefaultValue = useMemo(() => ({ query }), [query]); + const queryFieldStepContent = useMemo( + () => ( + <> + + + + {!agentId && ( + + + + + + )} + + + + + + + + ), + [ + agentId, + agentSelected, + handleShowSaveQueryFlout, + queryComponentProps, + queryValueProvided, + resultsStatus, + submit, + ] + ); + + const resultsStepContent = useMemo( + () => + actionId ? ( + + ) : null, + [actionId, agentIds, data?.actions] + ); + const formSteps: EuiContainedStepProps[] = useMemo( () => [ { @@ -160,73 +228,34 @@ const LiveQueryFormComponent: React.FC = ({ defaultValue, on title: i18n.translate('xpack.osquery.liveQueryForm.steps.queryStepHeading', { defaultMessage: 'Enter query', }), - children: ( - <> - - - - - - - - - - - - - - - - ), + children: queryFieldStepContent, status: queryStatus, }, { title: i18n.translate('xpack.osquery.liveQueryForm.steps.resultsStepHeading', { defaultMessage: 'Check results', }), - children: actionId ? ( - - ) : null, + children: resultsStepContent, status: resultsStatus, }, ], - [ - actionId, - agentIds, - agentSelected, - handleShowSaveQueryFlout, - queryComponentProps, - queryStatus, - queryValueProvided, - expirationDate, - resultsStatus, - submit, - ] + [agentSelected, queryFieldStepContent, queryStatus, resultsStepContent, resultsStatus] + ); + + const singleAgentForm = useMemo( + () => ( + + + {queryFieldStepContent} + {resultsStepContent} + + ), + [queryFieldStepContent, resultsStepContent] ); return ( <> -
- - +
{agentId ? singleAgentForm : } {showSavedQueryFlyout ? ( , @@ -160,7 +161,14 @@ export class OsqueryPlugin implements Plugin = ({ - actionId, - agentIds, - expirationDate, - endDate, - isLive, - startDate, -}) => { - const tabs = useMemo( - () => [ - { - id: 'status', - name: 'Status', - content: ( - <> - - - - ), - }, - { - id: 'results', - name: 'Results', - content: ( - <> - - - - ), - }, - ], - [actionId, agentIds, endDate, isLive, startDate, expirationDate] - ); - - return ( - - ); -}; - -export const ResultTabs = React.memo(ResultTabsComponent); diff --git a/x-pack/plugins/osquery/public/results/results_table.tsx b/x-pack/plugins/osquery/public/results/results_table.tsx index 6ff60d30d23bf..d82737ab51e7c 100644 --- a/x-pack/plugins/osquery/public/results/results_table.tsx +++ b/x-pack/plugins/osquery/public/results/results_table.tsx @@ -14,6 +14,8 @@ import { EuiDataGridColumn, EuiLink, EuiLoadingContent, + EuiProgress, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react'; @@ -37,17 +39,16 @@ interface ResultsTableComponentProps { selectedAgent?: string; agentIds?: string[]; endDate?: string; - isLive?: boolean; startDate?: string; } const ResultsTableComponent: React.FC = ({ actionId, agentIds, - isLive, startDate, endDate, }) => { + const [isLive, setIsLive] = useState(true); const { // @ts-expect-error update types data: { aggregations }, @@ -60,13 +61,13 @@ const ResultsTableComponent: React.FC = ({ sortField: '@timestamp', isLive, }); - + const expired = useMemo(() => (!endDate ? false : new Date(endDate) < new Date()), [endDate]); const { getUrlForApp } = useKibana().services.application; const getFleetAppUrl = useCallback( (agentId) => getUrlForApp('fleet', { - path: `#` + pagePathGetters.agent_details({ agentId }), + path: `#` + pagePathGetters.agent_details({ agentId })[1], }), [getUrlForApp] ); @@ -216,29 +217,56 @@ const ResultsTableComponent: React.FC = ({ [actionId, endDate, startDate] ); - if (!aggregations.totalResponded) { - return ; - } + useEffect( + () => + setIsLive(() => { + if (!agentIds?.length || expired) return false; + + const uniqueAgentsRepliedCount = + // @ts-expect-error-type + allResultsData?.rawResponse.aggregations?.unique_agents.value ?? 0; + + return !!(uniqueAgentsRepliedCount !== agentIds?.length - aggregations.failed); + }), + [ + agentIds?.length, + aggregations.failed, + // @ts-expect-error-type + allResultsData?.rawResponse.aggregations?.unique_agents.value, + expired, + ] + ); - if (aggregations.totalResponded && isFetched && !allResultsData?.edges.length) { - return ; + if (!isFetched) { + return ; } return ( - // @ts-expect-error update types - - - + <> + {isLive && } + + {isFetched && !allResultsData?.edges.length ? ( + <> + + + + ) : ( + // @ts-expect-error update types + + + + )} + ); }; diff --git a/x-pack/plugins/osquery/public/results/translations.ts b/x-pack/plugins/osquery/public/results/translations.ts index 8e77e78ec76e2..e4f71d818f01d 100644 --- a/x-pack/plugins/osquery/public/results/translations.ts +++ b/x-pack/plugins/osquery/public/results/translations.ts @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; export const generateEmptyDataMessage = (agentsResponded: number): string => { return i18n.translate('xpack.osquery.results.multipleAgentsResponded', { defaultMessage: - '{agentsResponded, plural, one {# agent has} other {# agents have}} responded, but no osquery data has been reported.', + '{agentsResponded, plural, one {# agent has} other {# agents have}} responded, no osquery data has been reported.', values: { agentsResponded }, }); }; diff --git a/x-pack/plugins/osquery/public/results/use_all_results.ts b/x-pack/plugins/osquery/public/results/use_all_results.ts index 1121898410278..a13fceedfa07a 100644 --- a/x-pack/plugins/osquery/public/results/use_all_results.ts +++ b/x-pack/plugins/osquery/public/results/use_all_results.ts @@ -78,7 +78,7 @@ export const useAllResults = ({ }; }, { - refetchInterval: isLive ? 1000 : false, + refetchInterval: isLive ? 5000 : false, enabled: !skip, onSuccess: () => setErrorToast(), onError: (error: Error) => diff --git a/x-pack/plugins/osquery/public/routes/live_queries/details/index.tsx b/x-pack/plugins/osquery/public/routes/live_queries/details/index.tsx index e4f1bb447a15a..02f5c8b6fb2a5 100644 --- a/x-pack/plugins/osquery/public/routes/live_queries/details/index.tsx +++ b/x-pack/plugins/osquery/public/routes/live_queries/details/index.tsx @@ -6,54 +6,24 @@ */ import { get } from 'lodash'; -import { - EuiButtonEmpty, - EuiTextColor, - EuiFlexGroup, - EuiFlexItem, - EuiCodeBlock, - EuiSpacer, - EuiDescriptionList, - EuiDescriptionListTitle, - EuiDescriptionListDescription, -} from '@elastic/eui'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiCodeBlock, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useMemo } from 'react'; import { useParams } from 'react-router-dom'; -import styled from 'styled-components'; -import { Direction } from '../../../../common/search_strategy'; import { useRouterNavigate } from '../../../common/lib/kibana'; import { WithHeaderLayout } from '../../../components/layouts'; -import { useActionResults } from '../../../action_results/use_action_results'; import { useActionDetails } from '../../../actions/use_action_details'; import { ResultTabs } from '../../saved_queries/edit/tabs'; import { useBreadcrumbs } from '../../../common/hooks/use_breadcrumbs'; import { BetaBadge, BetaBadgeRowWrapper } from '../../../components/beta_badge'; -const Divider = styled.div` - width: 0; - height: 100%; - border-left: ${({ theme }) => theme.eui.euiBorderThin}; -`; - const LiveQueryDetailsPageComponent = () => { const { actionId } = useParams<{ actionId: string }>(); useBreadcrumbs('live_query_details', { liveQueryId: actionId }); const liveQueryListProps = useRouterNavigate('live_queries'); const { data } = useActionDetails({ actionId }); - const expirationDate = useMemo(() => new Date(data?.actionDetails._source.expiration), [ - data?.actionDetails, - ]); - const expired = useMemo(() => expirationDate < new Date(), [expirationDate]); - const { data: actionResultsData } = useActionResults({ - actionId, - activePage: 0, - limit: 0, - direction: Direction.asc, - sortField: '@timestamp', - }); const LeftColumn = useMemo( () => ( @@ -82,72 +52,14 @@ const LiveQueryDetailsPageComponent = () => { [liveQueryListProps] ); - const failed = useMemo(() => { - let result = actionResultsData?.aggregations.failed; - if (expired) { - result = '-'; - if (data?.actionDetails?.fields?.agents && actionResultsData?.aggregations) { - result = - data.actionDetails.fields.agents.length - actionResultsData.aggregations.successful; - } - } - return result; - }, [expired, actionResultsData?.aggregations, data?.actionDetails?.fields?.agents]); - - const RightColumn = useMemo( - () => ( - - - <> - - - - - - {/* eslint-disable-next-line react-perf/jsx-no-new-object-as-prop */} - - - - - - {data?.actionDetails?.fields?.agents?.length ?? '0'} - - - - - - - - {/* eslint-disable-next-line react-perf/jsx-no-new-object-as-prop */} - - - - - - {failed} - - - - - ), - [data?.actionDetails?.fields?.agents?.length, failed] - ); - return ( - + {data?.actionDetails._source?.data?.query} { const updateSavedQueryMutation = useUpdateSavedQuery({ savedQueryId }); const deleteSavedQueryMutation = useDeleteSavedQuery({ savedQueryId }); - useBreadcrumbs('saved_query_edit', { savedQueryId: savedQueryDetails?.attributes?.id ?? '' }); + useBreadcrumbs('saved_query_edit', { savedQueryName: savedQueryDetails?.attributes?.id ?? '' }); const handleCloseDeleteConfirmationModal = useCallback(() => { setIsDeleteModalVisible(false); diff --git a/x-pack/plugins/osquery/public/routes/saved_queries/edit/tabs.tsx b/x-pack/plugins/osquery/public/routes/saved_queries/edit/tabs.tsx index 1946cd6dd3450..1f56daaa3bdb5 100644 --- a/x-pack/plugins/osquery/public/routes/saved_queries/edit/tabs.tsx +++ b/x-pack/plugins/osquery/public/routes/saved_queries/edit/tabs.tsx @@ -10,12 +10,11 @@ import React, { useMemo } from 'react'; import { ResultsTable } from '../../../results/results_table'; import { ActionResultsSummary } from '../../../action_results/action_results_summary'; +import { ActionAgentsStatus } from '../../../action_results/action_agents_status'; interface ResultTabsProps { actionId: string; agentIds?: string[]; - expirationDate: Date; - isLive?: boolean; startDate?: string; endDate?: string; } @@ -24,8 +23,6 @@ const ResultTabsComponent: React.FC = ({ actionId, agentIds, endDate, - expirationDate, - isLive, startDate, }) => { const tabs = useMemo( @@ -39,7 +36,6 @@ const ResultTabsComponent: React.FC = ({ @@ -55,23 +51,26 @@ const ResultTabsComponent: React.FC = ({ ), }, ], - [actionId, agentIds, endDate, expirationDate, isLive, startDate] + [actionId, agentIds, endDate, startDate] ); return ( - + <> + + + + ); }; diff --git a/x-pack/plugins/osquery/public/saved_queries/form/index.tsx b/x-pack/plugins/osquery/public/saved_queries/form/index.tsx index 174227eb5e6e5..9bbf847c4d2a0 100644 --- a/x-pack/plugins/osquery/public/saved_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/saved_queries/form/index.tsx @@ -7,6 +7,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, EuiText } from '@elastic/eui'; import React from 'react'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ALL_OSQUERY_VERSIONS_OPTIONS } from '../../scheduled_query_groups/queries/constants'; @@ -57,7 +58,12 @@ const SavedQueryFormComponent = () => ( euiFieldProps={{ noSuggestions: false, singleSelection: { asPlainText: true }, - placeholder: ALL_OSQUERY_VERSIONS_OPTIONS[0].label, + placeholder: i18n.translate( + 'xpack.osquery.scheduledQueryGroup.queriesTable.osqueryVersionAllLabel', + { + defaultMessage: 'ALL', + } + ), options: ALL_OSQUERY_VERSIONS_OPTIONS, onCreateOption: undefined, }} diff --git a/x-pack/plugins/osquery/public/saved_queries/use_saved_queries.ts b/x-pack/plugins/osquery/public/saved_queries/use_saved_queries.ts index 324d4aace1647..bb5a73d9d50fa 100644 --- a/x-pack/plugins/osquery/public/saved_queries/use_saved_queries.ts +++ b/x-pack/plugins/osquery/public/saved_queries/use_saved_queries.ts @@ -40,7 +40,7 @@ export const useSavedQueries = ({ { keepPreviousData: true, // Refetch the data every 10 seconds - refetchInterval: isLive ? 10000 : false, + refetchInterval: isLive ? 5000 : false, } ); }; diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/query_flyout.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/query_flyout.tsx index b37c315849f60..32547bc5dd2d0 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/query_flyout.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/query_flyout.tsx @@ -18,9 +18,10 @@ import { EuiFlexItem, EuiButtonEmpty, EuiButton, - EuiDescribedFormGroup, + EuiText, } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { satisfies } from 'semver'; @@ -128,10 +129,7 @@ const QueryFlyoutComponent: React.FC = ({ - Set heading level based on context} - description={'Will be wrapped in a small, subdued EuiText block.'} - > + = ({ + + + + + } // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop euiFieldProps={{ isDisabled: !isFieldSupported, noSuggestions: false, singleSelection: { asPlainText: true }, - placeholder: ALL_OSQUERY_VERSIONS_OPTIONS[0].label, + placeholder: i18n.translate( + 'xpack.osquery.scheduledQueryGroup.queriesTable.osqueryVersionAllLabel', + { + defaultMessage: 'ALL', + } + ), options: ALL_OSQUERY_VERSIONS_OPTIONS, onCreateOption: undefined, }} @@ -160,7 +173,7 @@ const QueryFlyoutComponent: React.FC = ({ euiFieldProps={{ disabled: !isFieldSupported }} /> - + {!isFieldSupported ? ( diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/schema.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/schema.tsx index d8dbaad2f17e8..0b23ce924f930 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/schema.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/schema.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -65,14 +65,6 @@ export const formSchema = { defaultMessage="Minimum Osquery version" /> - - - - - ) as unknown) as string, validations: [], diff --git a/x-pack/plugins/osquery/public/shared_components/index.tsx b/x-pack/plugins/osquery/public/shared_components/index.tsx new file mode 100644 index 0000000000000..0e0ab4e5c045b --- /dev/null +++ b/x-pack/plugins/osquery/public/shared_components/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getLazyOsqueryAction } from './lazy_osquery_action'; diff --git a/x-pack/plugins/osquery/public/shared_components/lazy_osquery_action.tsx b/x-pack/plugins/osquery/public/shared_components/lazy_osquery_action.tsx new file mode 100644 index 0000000000000..9cc55a65ce2bc --- /dev/null +++ b/x-pack/plugins/osquery/public/shared_components/lazy_osquery_action.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy, Suspense } from 'react'; + +// @ts-expect-error update types +export const getLazyOsqueryAction = (services) => (props) => { + const OsqueryAction = lazy(() => import('./osquery_action')); + return ( + + + + ); +}; diff --git a/x-pack/plugins/osquery/public/shared_components/osquery_action/index.tsx b/x-pack/plugins/osquery/public/shared_components/osquery_action/index.tsx new file mode 100644 index 0000000000000..cf8a85cea244c --- /dev/null +++ b/x-pack/plugins/osquery/public/shared_components/osquery_action/index.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiErrorBoundary, EuiLoadingContent } from '@elastic/eui'; +import React, { useEffect, useState } from 'react'; +import { QueryClientProvider } from 'react-query'; +import { KibanaContextProvider, useKibana } from '../../common/lib/kibana'; + +import { LiveQueryForm } from '../../live_queries/form'; +import { queryClient } from '../../query_client'; + +interface OsqueryActionProps { + hostId?: string | undefined; +} + +const OsqueryActionComponent: React.FC = ({ hostId }) => { + const [agentId, setAgentId] = useState(); + const { indexPatterns, search } = useKibana().services.data; + + useEffect(() => { + if (hostId) { + const findAgent = async () => { + const searchSource = await search.searchSource.create(); + const indexPattern = await indexPatterns.find('.fleet-agents'); + + searchSource.setField('index', indexPattern[0]); + searchSource.setField('filter', [ + { + meta: { + alias: null, + disabled: false, + negate: false, + key: 'local_metadata.host.id', + value: hostId, + }, + query: { + match_phrase: { + 'local_metadata.host.id': hostId, + }, + }, + }, + { + meta: { + alias: null, + disabled: false, + negate: false, + key: 'active', + value: 'true', + }, + query: { + match_phrase: { + active: 'true', + }, + }, + }, + ]); + + const response = await searchSource.fetch$().toPromise(); + + if (response.rawResponse.hits.hits.length && response.rawResponse.hits.hits[0]._id) { + setAgentId(response.rawResponse.hits.hits[0]._id); + } + }; + + findAgent(); + } + }); + + if (!agentId) { + return ; + } + + return ( + // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop + + ); +}; + +export const OsqueryAction = React.memo(OsqueryActionComponent); + +// @ts-expect-error update types +const OsqueryActionWrapperComponent = ({ services, ...props }) => ( + + + + + + + +); + +const OsqueryActionWrapper = React.memo(OsqueryActionWrapperComponent); + +// eslint-disable-next-line import/no-default-export +export { OsqueryActionWrapper as default }; diff --git a/x-pack/plugins/osquery/public/types.ts b/x-pack/plugins/osquery/public/types.ts index 9a466dfc619b6..fd21b39d25504 100644 --- a/x-pack/plugins/osquery/public/types.ts +++ b/x-pack/plugins/osquery/public/types.ts @@ -16,11 +16,13 @@ import { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, } from '../../triggers_actions_ui/public'; +import { getLazyOsqueryAction } from './shared_components'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface OsqueryPluginSetup {} -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface OsqueryPluginStart {} +export interface OsqueryPluginStart { + OsqueryAction?: ReturnType; +} export interface AppPluginStartDependencies { navigation: NavigationPublicPluginStart; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/query.all_results.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/query.all_results.dsl.ts index b560fd3c364e9..406ff26991f0e 100644 --- a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/query.all_results.dsl.ts +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/query.all_results.dsl.ts @@ -47,12 +47,17 @@ export const buildResultsQuery = ({ size: 10000, }, }, + unique_agents: { + cardinality: { + field: 'elastic_agent.id', + }, + }, }, query: { bool: { filter } }, from: activePage * querySize, size: querySize, track_total_hits: true, - fields: agentId ? ['osquery.*'] : ['agent.*', 'osquery.*'], + fields: ['elastic_agent.*', 'agent.*', 'osquery.*'], sort: sort?.map((sortConfig) => ({ [sortConfig.field]: { diff --git a/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap b/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap index 40618aaeef359..8007acad93e4b 100644 --- a/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap +++ b/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap @@ -424,6 +424,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` className="euiTableCellContent euiTableCellContent--overflowingContent" >
@@ -1430,6 +1431,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` className="euiTableCellContent euiTableCellContent--overflowingContent" >
@@ -2450,6 +2452,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` className="euiTableCellContent euiTableCellContent--overflowingContent" >
@@ -3517,6 +3520,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` className="euiTableCellContent euiTableCellContent--overflowingContent" >
@@ -4617,6 +4621,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` className="euiTableCellContent euiTableCellContent--overflowingContent" >
@@ -5684,6 +5689,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` className="euiTableCellContent euiTableCellContent--overflowingContent" >
@@ -6751,6 +6757,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` className="euiTableCellContent euiTableCellContent--overflowingContent" >
@@ -7818,6 +7825,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` className="euiTableCellContent euiTableCellContent--overflowingContent" >
@@ -8885,6 +8893,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` className="euiTableCellContent euiTableCellContent--overflowingContent" >
diff --git a/x-pack/plugins/reporting/public/management/report_listing.tsx b/x-pack/plugins/reporting/public/management/report_listing.tsx index 749e42de526d3..dd41314b4883f 100644 --- a/x-pack/plugins/reporting/public/management/report_listing.tsx +++ b/x-pack/plugins/reporting/public/management/report_listing.tsx @@ -151,6 +151,7 @@ class ReportListingUi extends Component { return ( <> @@ -375,7 +376,7 @@ class ReportListingUi extends Component { }), render: (objectTitle: string, record: Job) => { return ( -
+
{objectTitle}
{record.object_type} diff --git a/x-pack/plugins/rule_registry/README.md b/x-pack/plugins/rule_registry/README.md index 3fe6305a0d9f6..945b8f161eb84 100644 --- a/x-pack/plugins/rule_registry/README.md +++ b/x-pack/plugins/rule_registry/README.md @@ -27,9 +27,7 @@ On plugin setup, rule type producers can create the index template as follows: ```ts // get the FQN of the component template. All assets are prefixed with the configured `index` value, which is `.alerts` by default. -const componentTemplateName = plugins.ruleRegistry.getFullAssetName( - 'apm-mappings' -); +const componentTemplateName = plugins.ruleRegistry.getFullAssetName('apm-mappings'); // if write is disabled, don't install these templates if (!plugins.ruleRegistry.isWriteEnabled()) { @@ -73,14 +71,10 @@ await plugins.ruleRegistry.createOrUpdateComponentTemplate({ await plugins.ruleRegistry.createOrUpdateIndexTemplate({ name: plugins.ruleRegistry.getFullAssetName('apm-index-template'), body: { - index_patterns: [ - plugins.ruleRegistry.getFullAssetName('observability-apm*'), - ], + index_patterns: [plugins.ruleRegistry.getFullAssetName('observability-apm*')], composed_of: [ // Technical component template, required - plugins.ruleRegistry.getFullAssetName( - TECHNICAL_COMPONENT_TEMPLATE_NAME - ), + plugins.ruleRegistry.getFullAssetName(TECHNICAL_COMPONENT_TEMPLATE_NAME), componentTemplateName, ], }, @@ -107,8 +101,7 @@ await ruleDataClient.getWriter().bulk({ // to read data, simply call ruleDataClient.getReader().search: const response = await ruleDataClient.getReader().search({ body: { - query: { - }, + query: {}, size: 100, fields: ['*'], sort: { @@ -132,6 +125,7 @@ The following fields are defined in the technical field component template and s - `rule.name`: the name of the rule (as specified by the user). - `rule.category`: the name of the rule type (as defined by the rule type producer) - `kibana.rac.alert.producer`: the producer of the rule type. Usually a Kibana plugin. e.g., `APM`. +- `kibana.rac.alert.owner`: the feature which produced the alert. Usually a Kibana feature id like `apm`, `siem`... - `kibana.rac.alert.id`: the id of the alert, that is unique within the context of the rule execution it was created in. E.g., for a rule that monitors latency for all services in all environments, this might be `opbeans-java:production`. - `kibana.rac.alert.uuid`: the unique identifier for the alert during its lifespan. If an alert recovers (or closes), this identifier is re-generated when it is opened again. - `kibana.rac.alert.status`: the status of the alert. Can be `open` or `closed`. @@ -145,3 +139,16 @@ The following fields are defined in the technical field component template and s - `kibana.rac.alert.ancestors`: the array of ancestors (if any) for the alert. - `kibana.rac.alert.depth`: the depth of the alert in the ancestral tree (default 0). - `kibana.rac.alert.building_block_type`: the building block type of the alert (default undefined). + +# Alerts as data + +Alerts as data can be interacted with using the AlertsClient api found in `x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts` + +This api includes public methods such as + +[x] getFullAssetName +[x] getAlertsIndex +[x] get +[x] update +[ ] bulkUpdate (TODO) +[ ] find (TODO) diff --git a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts index a946e9523548c..6d70c581802c1 100644 --- a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts +++ b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts @@ -18,6 +18,7 @@ import { ALERT_UUID, EVENT_ACTION, EVENT_KIND, + OWNER, PRODUCER, RULE_CATEGORY, RULE_ID, @@ -40,6 +41,7 @@ export const technicalRuleFieldMap = { RULE_CATEGORY, TAGS ), + [OWNER]: { type: 'keyword' }, [PRODUCER]: { type: 'keyword' }, [ALERT_UUID]: { type: 'keyword' }, [ALERT_ID]: { type: 'keyword' }, diff --git a/x-pack/plugins/rule_registry/common/constants.ts b/x-pack/plugins/rule_registry/common/constants.ts new file mode 100644 index 0000000000000..72793b1087e7b --- /dev/null +++ b/x-pack/plugins/rule_registry/common/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const BASE_RAC_ALERTS_API_PATH = '/internal/rac/alerts'; diff --git a/x-pack/plugins/rule_registry/docs/README.md b/x-pack/plugins/rule_registry/docs/README.md new file mode 100644 index 0000000000000..a22dc1ab7e864 --- /dev/null +++ b/x-pack/plugins/rule_registry/docs/README.md @@ -0,0 +1,44 @@ +# Alerts as data Client API Docs + +This directory contains generated docs using `typedoc` for the alerts as data client (alerts client) API that can be called from other server +plugins. This README will describe how to generate a new version of these markdown docs in the event that new methods +or parameters are added. + +## TypeDoc Info + +See more info at: +and: for the markdown plugin + +## Install dependencies + +```bash +yarn global add typedoc typedoc-plugin-markdown +``` + +## Generate the docs + +```bash +cd x-pack/plugins/rule_registry/docs +npx typedoc --options alerts_client_typedoc.json +``` + +After running the above commands the files in the `server` directory will be updated to match the new tsdocs. +If additional markdown directory should be created we can create a new typedoc configuration file and adjust the `out` +directory accordingly. + +## Troubleshooting + +This will use the global `tsc` so ensure typescript is installed globally and one of typescript version `3.9, 4.0, 4.1, 4.2`. + +``` +$ tsc --version +Version 4.2.4 +``` + +If you run into tsc errors that seem unrelated to the cases plugin try executing these commands before running `typedoc` + +```bash +cd +npx yarn kbn bootstrap +node scripts/build_ts_refs.js --clean --no-cache +``` diff --git a/x-pack/plugins/rule_registry/docs/alerts_client/alerts_client_api.md b/x-pack/plugins/rule_registry/docs/alerts_client/alerts_client_api.md new file mode 100644 index 0000000000000..b94a19f8e3f38 --- /dev/null +++ b/x-pack/plugins/rule_registry/docs/alerts_client/alerts_client_api.md @@ -0,0 +1,14 @@ +Alerts as data client API Interface + +# Alerts as data client API Interface + +## Table of contents + +### Classes + +- [AlertsClient](classes/alertsclient.md) + +### Interfaces + +- [ConstructorOptions](interfaces/constructoroptions.md) +- [UpdateOptions](interfaces/updateoptions.md) diff --git a/x-pack/plugins/rule_registry/docs/alerts_client/classes/alertsclient.md b/x-pack/plugins/rule_registry/docs/alerts_client/classes/alertsclient.md new file mode 100644 index 0000000000000..9b639829a9f5f --- /dev/null +++ b/x-pack/plugins/rule_registry/docs/alerts_client/classes/alertsclient.md @@ -0,0 +1,191 @@ +[Alerts as data client API Interface](../alerts_client_api.md) / AlertsClient + +# Class: AlertsClient + +Provides apis to interact with alerts as data +ensures the request is authorized to perform read / write actions +on alerts as data. + +## Table of contents + +### Constructors + +- [constructor](alertsclient.md#constructor) + +### Properties + +- [auditLogger](alertsclient.md#auditlogger) +- [authorization](alertsclient.md#authorization) +- [esClient](alertsclient.md#esclient) +- [logger](alertsclient.md#logger) + +### Methods + +- [fetchAlert](alertsclient.md#fetchalert) +- [get](alertsclient.md#get) +- [getAlertsIndex](alertsclient.md#getalertsindex) +- [getAuthorizedAlertsIndices](alertsclient.md#getauthorizedalertsindices) +- [update](alertsclient.md#update) + +## Constructors + +### constructor + +• **new AlertsClient**(`__namedParameters`) + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `__namedParameters` | [ConstructorOptions](../interfaces/constructoroptions.md) | + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:59](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L59) + +## Properties + +### auditLogger + +• `Private` `Optional` `Readonly` **auditLogger**: `AuditLogger` + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:57](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L57) + +___ + +### authorization + +• `Private` `Readonly` **authorization**: `PublicMethodsOf` + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:58](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L58) + +___ + +### esClient + +• `Private` `Readonly` **esClient**: `ElasticsearchClient` + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:59](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L59) + +___ + +### logger + +• `Private` `Readonly` **logger**: `Logger` + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:56](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L56) + +## Methods + +### fetchAlert + +▸ `Private` **fetchAlert**(`__namedParameters`): `Promise` + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `__namedParameters` | `GetAlertParams` | + +#### Returns + +`Promise` + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:79](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L79) + +___ + +### get + +▸ **get**(`__namedParameters`): `Promise`\>\> + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `__namedParameters` | `GetAlertParams` | + +#### Returns + +`Promise`\>\> + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:108](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L108) + +___ + +### getAlertsIndex + +▸ **getAlertsIndex**(`featureIds`, `operations`): `Promise`<`Object`\> + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `featureIds` | `string`[] | +| `operations` | (`ReadOperations` \| `WriteOperations`)[] | + +#### Returns + +`Promise`<`Object`\> + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:68](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L68) + +___ + +### getAuthorizedAlertsIndices + +▸ **getAuthorizedAlertsIndices**(`featureIds`): `Promise` + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `featureIds` | `string`[] | + +#### Returns + +`Promise` + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:200](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L200) + +___ + +### update + +▸ **update**(`__namedParameters`): `Promise`<`Object`\> + +#### Type parameters + +| Name | Type | +| :------ | :------ | +| `Params` | `Params`: `AlertTypeParams` = `never` | + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `__namedParameters` | [UpdateOptions](../interfaces/updateoptions.md) | + +#### Returns + +`Promise`<`Object`\> + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:146](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L146) diff --git a/x-pack/plugins/rule_registry/docs/alerts_client/interfaces/constructoroptions.md b/x-pack/plugins/rule_registry/docs/alerts_client/interfaces/constructoroptions.md new file mode 100644 index 0000000000000..e3dbc6b2c2354 --- /dev/null +++ b/x-pack/plugins/rule_registry/docs/alerts_client/interfaces/constructoroptions.md @@ -0,0 +1,52 @@ +[Alerts as data client API Interface](../alerts_client_api.md) / ConstructorOptions + +# Interface: ConstructorOptions + +## Table of contents + +### Properties + +- [auditLogger](constructoroptions.md#auditlogger) +- [authorization](constructoroptions.md#authorization) +- [esClient](constructoroptions.md#esclient) +- [logger](constructoroptions.md#logger) + +## Properties + +### auditLogger + +• `Optional` **auditLogger**: `AuditLogger` + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:34](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L34) + +___ + +### authorization + +• **authorization**: `PublicMethodsOf` + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:33](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L33) + +___ + +### esClient + +• **esClient**: `ElasticsearchClient` + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:35](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L35) + +___ + +### logger + +• **logger**: `Logger` + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:32](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L32) diff --git a/x-pack/plugins/rule_registry/docs/alerts_client/interfaces/updateoptions.md b/x-pack/plugins/rule_registry/docs/alerts_client/interfaces/updateoptions.md new file mode 100644 index 0000000000000..fbc0991635000 --- /dev/null +++ b/x-pack/plugins/rule_registry/docs/alerts_client/interfaces/updateoptions.md @@ -0,0 +1,58 @@ +[Alerts as data client API Interface](../alerts_client_api.md) / UpdateOptions + +# Interface: UpdateOptions + +## Type parameters + +| Name | Type | +| :------ | :------ | +| `Params` | `Params`: `AlertTypeParams` | + +## Table of contents + +### Properties + +- [\_version](updateoptions.md#_version) +- [id](updateoptions.md#id) +- [index](updateoptions.md#index) +- [status](updateoptions.md#status) + +## Properties + +### \_version + +• **\_version**: `undefined` \| `string` + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:41](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L41) + +___ + +### id + +• **id**: `string` + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:39](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L39) + +___ + +### index + +• **index**: `string` + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:42](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L42) + +___ + +### status + +• **status**: `string` + +#### Defined in + +[rule_registry/server/alert_data_client/alerts_client.ts:40](https://github.com/dhurley14/kibana/blob/d2173f5090e/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts#L40) diff --git a/x-pack/plugins/rule_registry/docs/alerts_client_typedoc.json b/x-pack/plugins/rule_registry/docs/alerts_client_typedoc.json new file mode 100644 index 0000000000000..5f117323eeb1c --- /dev/null +++ b/x-pack/plugins/rule_registry/docs/alerts_client_typedoc.json @@ -0,0 +1,17 @@ +{ + "entryPoints": [ + "../server/alert_data_client/alerts_client.ts" + ], + "exclude": [ + "**/mock.ts", + "../server/alert_data_client/+(mock.ts|utils.ts|utils.test.ts|types.ts)" + ], + "excludeExternals": true, + "out": "alerts_client", + "theme": "markdown", + "plugin": "typedoc-plugin-markdown", + "entryDocument": "alerts_client_api.md", + "readme": "none", + "name": "Alerts as data client API Interface" +} + diff --git a/x-pack/plugins/rule_registry/kibana.json b/x-pack/plugins/rule_registry/kibana.json index 8c1e8d0f5e40e..f74bebf585edd 100644 --- a/x-pack/plugins/rule_registry/kibana.json +++ b/x-pack/plugins/rule_registry/kibana.json @@ -12,5 +12,6 @@ "spaces", "triggersActionsUi" ], + "optionalPlugins": ["security"], "server": true } diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.mock.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.mock.ts new file mode 100644 index 0000000000000..73c6b4dd40526 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.mock.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PublicMethodsOf } from '@kbn/utility-types'; +import { AlertsClient } from './alerts_client'; + +type Schema = PublicMethodsOf; +export type AlertsClientMock = jest.Mocked; + +const createAlertsClientMock = () => { + const mocked: AlertsClientMock = { + get: jest.fn(), + getAlertsIndex: jest.fn(), + update: jest.fn(), + getAuthorizedAlertsIndices: jest.fn(), + }; + return mocked; +}; + +export const alertsClientMock: { + create: () => AlertsClientMock; +} = { + create: createAlertsClientMock, +}; diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts new file mode 100644 index 0000000000000..553c5ce4472a6 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts @@ -0,0 +1,243 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { PublicMethodsOf } from '@kbn/utility-types'; +import { decodeVersion, encodeHitVersion } from '@kbn/securitysolution-es-utils'; +import { AlertTypeParams } from '../../../alerting/server'; +import { + ReadOperations, + AlertingAuthorization, + WriteOperations, + AlertingAuthorizationEntity, +} from '../../../alerting/server'; +import { Logger, ElasticsearchClient } from '../../../../../src/core/server'; +import { alertAuditEvent, AlertAuditAction } from './audit_events'; +import { AuditLogger } from '../../../security/server'; +import { ALERT_STATUS, OWNER, RULE_ID } from '../../common/technical_rule_data_field_names'; +import { ParsedTechnicalFields } from '../../common/parse_technical_fields'; +import { mapConsumerToIndexName, validFeatureIds, isValidFeatureId } from '../utils/rbac'; + +// TODO: Fix typings https://github.com/elastic/kibana/issues/101776 +type NonNullableProps = Omit & + { [K in Props]-?: NonNullable }; +type AlertType = NonNullableProps; + +const isValidAlert = (source?: ParsedTechnicalFields): source is AlertType => { + return source?.[RULE_ID] != null && source?.[OWNER] != null; +}; +export interface ConstructorOptions { + logger: Logger; + authorization: PublicMethodsOf; + auditLogger?: AuditLogger; + esClient: ElasticsearchClient; +} + +export interface UpdateOptions { + id: string; + status: string; + _version: string | undefined; + index: string; +} + +interface GetAlertParams { + id: string; + index?: string; +} + +/** + * Provides apis to interact with alerts as data + * ensures the request is authorized to perform read / write actions + * on alerts as data. + */ +export class AlertsClient { + private readonly logger: Logger; + private readonly auditLogger?: AuditLogger; + private readonly authorization: PublicMethodsOf; + private readonly esClient: ElasticsearchClient; + + constructor({ auditLogger, authorization, logger, esClient }: ConstructorOptions) { + this.logger = logger; + this.authorization = authorization; + this.esClient = esClient; + this.auditLogger = auditLogger; + } + + public async getAlertsIndex( + featureIds: string[], + operations: Array + ) { + return this.authorization.getAugmentedRuleTypesWithAuthorization( + featureIds.length !== 0 ? featureIds : validFeatureIds, + operations, + AlertingAuthorizationEntity.Alert + ); + } + + private async fetchAlert({ + id, + index, + }: GetAlertParams): Promise<(AlertType & { _version: string | undefined }) | null | undefined> { + try { + const result = await this.esClient.search({ + // Context: Originally thought of always just searching `.alerts-*` but that could + // result in a big performance hit. If the client already knows which index the alert + // belongs to, passing in the index will speed things up + index: index ?? '.alerts-*', + ignore_unavailable: true, + body: { query: { term: { _id: id } } }, + seq_no_primary_term: true, + }); + + if (result == null || result.body == null || result.body.hits.hits.length === 0) { + return; + } + + if (!isValidAlert(result.body.hits.hits[0]._source)) { + const errorMessage = `Unable to retrieve alert details for alert with id of "${id}".`; + this.logger.debug(errorMessage); + throw new Error(errorMessage); + } + + return { + ...result.body.hits.hits[0]._source, + _version: encodeHitVersion(result.body.hits.hits[0]), + }; + } catch (error) { + const errorMessage = `Unable to retrieve alert with id of "${id}".`; + this.logger.debug(errorMessage); + throw error; + } + } + + public async get({ + id, + index, + }: GetAlertParams): Promise { + try { + // first search for the alert by id, then use the alert info to check if user has access to it + const alert = await this.fetchAlert({ + id, + index, + }); + + if (alert == null) { + return; + } + + // this.authorization leverages the alerting plugin's authorization + // client exposed to us for reuse + await this.authorization.ensureAuthorized({ + ruleTypeId: alert[RULE_ID], + consumer: alert[OWNER], + operation: ReadOperations.Get, + entity: AlertingAuthorizationEntity.Alert, + }); + + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.GET, + id, + }) + ); + + return alert; + } catch (error) { + this.logger.debug(`Error fetching alert with id of "${id}"`); + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.GET, + id, + error, + }) + ); + throw error; + } + } + + public async update({ + id, + status, + _version, + index, + }: UpdateOptions) { + try { + const alert = await this.fetchAlert({ + id, + index, + }); + + if (alert == null) { + return; + } + + await this.authorization.ensureAuthorized({ + ruleTypeId: alert[RULE_ID], + consumer: alert[OWNER], + operation: WriteOperations.Update, + entity: AlertingAuthorizationEntity.Alert, + }); + + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UPDATE, + id, + outcome: 'unknown', + }) + ); + + const { body: response } = await this.esClient.update({ + ...decodeVersion(_version), + id, + index, + body: { + doc: { + [ALERT_STATUS]: status, + }, + }, + refresh: 'wait_for', + }); + + return { + ...response, + _version: encodeHitVersion(response), + }; + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UPDATE, + id, + error, + }) + ); + throw error; + } + } + + public async getAuthorizedAlertsIndices(featureIds: string[]): Promise { + const augmentedRuleTypes = await this.authorization.getAugmentedRuleTypesWithAuthorization( + featureIds, + [ReadOperations.Find, ReadOperations.Get, WriteOperations.Update], + AlertingAuthorizationEntity.Alert + ); + + // As long as the user can read a minimum of one type of rule type produced by the provided feature, + // the user should be provided that features' alerts index. + // Limiting which alerts that user can read on that index will be done via the findAuthorizationFilter + const authorizedFeatures = new Set(); + for (const ruleType of augmentedRuleTypes.authorizedRuleTypes) { + authorizedFeatures.add(ruleType.producer); + } + + const toReturn = Array.from(authorizedFeatures).flatMap((feature) => { + if (isValidFeatureId(feature)) { + return mapConsumerToIndexName[feature]; + } + return []; + }); + + return toReturn; + } +} diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts new file mode 100644 index 0000000000000..9e1941f779722 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Request } from '@hapi/hapi'; + +import { AlertsClientFactory, AlertsClientFactoryProps } from './alerts_client_factory'; +import { ElasticsearchClient, KibanaRequest } from 'src/core/server'; +import { loggingSystemMock } from 'src/core/server/mocks'; +import { securityMock } from '../../../security/server/mocks'; +import { AuditLogger } from '../../../security/server'; +import { alertingAuthorizationMock } from '../../../alerting/server/authorization/alerting_authorization.mock'; + +jest.mock('./alerts_client'); + +const securityPluginSetup = securityMock.createSetup(); +const alertingAuthMock = alertingAuthorizationMock.create(); + +const alertsClientFactoryParams: AlertsClientFactoryProps = { + logger: loggingSystemMock.create().get(), + getAlertingAuthorization: (_: KibanaRequest) => alertingAuthMock, + securityPluginSetup, + esClient: {} as ElasticsearchClient, +}; + +const fakeRequest = ({ + app: {}, + headers: {}, + getBasePath: () => '', + path: '/', + route: { settings: {} }, + url: { + href: '/', + }, + raw: { + req: { + url: '/', + }, + }, +} as unknown) as Request; + +const auditLogger = { + log: jest.fn(), +} as jest.Mocked; + +describe('AlertsClientFactory', () => { + beforeEach(() => { + jest.resetAllMocks(); + + securityPluginSetup.audit.asScoped.mockReturnValue(auditLogger); + }); + + test('creates an alerts client with proper constructor arguments', async () => { + const factory = new AlertsClientFactory(); + factory.initialize({ ...alertsClientFactoryParams }); + const request = KibanaRequest.from(fakeRequest); + await factory.create(request); + + expect(jest.requireMock('./alerts_client').AlertsClient).toHaveBeenCalledWith({ + authorization: alertingAuthMock, + logger: alertsClientFactoryParams.logger, + auditLogger, + esClient: {}, + }); + }); + + test('throws an error if already initialized', () => { + const factory = new AlertsClientFactory(); + factory.initialize({ ...alertsClientFactoryParams }); + + expect(() => + factory.initialize({ ...alertsClientFactoryParams }) + ).toThrowErrorMatchingInlineSnapshot(`"AlertsClientFactory (RAC) already initialized"`); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.ts b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.ts new file mode 100644 index 0000000000000..43a3827b28972 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/alert_data_client/alerts_client_factory.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient, KibanaRequest, Logger } from 'src/core/server'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { SecurityPluginSetup } from '../../../security/server'; +import { AlertingAuthorization } from '../../../alerting/server'; +import { AlertsClient } from './alerts_client'; + +export interface AlertsClientFactoryProps { + logger: Logger; + esClient: ElasticsearchClient; + getAlertingAuthorization: (request: KibanaRequest) => PublicMethodsOf; + securityPluginSetup: SecurityPluginSetup | undefined; +} + +export class AlertsClientFactory { + private isInitialized = false; + private logger!: Logger; + private esClient!: ElasticsearchClient; + private getAlertingAuthorization!: ( + request: KibanaRequest + ) => PublicMethodsOf; + private securityPluginSetup!: SecurityPluginSetup | undefined; + + public initialize(options: AlertsClientFactoryProps) { + /** + * This should be called by the plugin's start() method. + */ + if (this.isInitialized) { + throw new Error('AlertsClientFactory (RAC) already initialized'); + } + + this.getAlertingAuthorization = options.getAlertingAuthorization; + this.isInitialized = true; + this.logger = options.logger; + this.esClient = options.esClient; + this.securityPluginSetup = options.securityPluginSetup; + } + + public async create(request: KibanaRequest): Promise { + const { securityPluginSetup, getAlertingAuthorization, logger } = this; + + return new AlertsClient({ + logger, + authorization: getAlertingAuthorization(request), + auditLogger: securityPluginSetup?.audit.asScoped(request), + esClient: this.esClient, + }); + } +} diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/audit_events.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/audit_events.test.ts new file mode 100644 index 0000000000000..9536a9a640a00 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/alert_data_client/audit_events.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AlertAuditAction, alertAuditEvent } from './audit_events'; + +describe('#alertAuditEvent', () => { + test('creates event with `unknown` outcome', () => { + expect( + alertAuditEvent({ + action: AlertAuditAction.GET, + outcome: 'unknown', + id: '123', + }) + ).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "alert_get", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "access", + ], + }, + "message": "User is accessing alert [id=123]", + } + `); + }); + + test('creates event with `success` outcome', () => { + expect( + alertAuditEvent({ + action: AlertAuditAction.GET, + id: '123', + }) + ).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "alert_get", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "message": "User has accessed alert [id=123]", + } + `); + }); + + test('creates event with `failure` outcome', () => { + expect( + alertAuditEvent({ + action: AlertAuditAction.GET, + id: '123', + error: new Error('ERROR_MESSAGE'), + }) + ).toMatchInlineSnapshot(` + Object { + "error": Object { + "code": "Error", + "message": "ERROR_MESSAGE", + }, + "event": Object { + "action": "alert_get", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "message": "Failed attempt to access alert [id=123]", + } + `); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/audit_events.ts b/x-pack/plugins/rule_registry/server/alert_data_client/audit_events.ts new file mode 100644 index 0000000000000..d07c23c7fbe9f --- /dev/null +++ b/x-pack/plugins/rule_registry/server/alert_data_client/audit_events.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EcsEventOutcome, EcsEventType } from 'src/core/server'; +import { AuditEvent } from '../../../security/server'; + +export enum AlertAuditAction { + GET = 'alert_get', + UPDATE = 'alert_update', + FIND = 'alert_find', +} + +type VerbsTuple = [string, string, string]; + +const eventVerbs: Record = { + alert_get: ['access', 'accessing', 'accessed'], + alert_update: ['update', 'updating', 'updated'], + alert_find: ['access', 'accessing', 'accessed'], +}; + +const eventTypes: Record = { + alert_get: 'access', + alert_update: 'change', + alert_find: 'access', +}; + +export interface AlertAuditEventParams { + action: AlertAuditAction; + outcome?: EcsEventOutcome; + id?: string; + error?: Error; +} + +export function alertAuditEvent({ action, id, outcome, error }: AlertAuditEventParams): AuditEvent { + const doc = id ? `alert [id=${id}]` : 'an alert'; + const [present, progressive, past] = eventVerbs[action]; + const message = error + ? `Failed attempt to ${present} ${doc}` + : outcome === 'unknown' + ? `User is ${progressive} ${doc}` + : `User has ${past} ${doc}`; + const type = eventTypes[action]; + + return { + message, + event: { + action, + category: ['database'], + type: type ? [type] : undefined, + outcome: outcome ?? (error ? 'failure' : 'success'), + }, + error: error && { + code: error.name, + message: error.message, + }, + }; +} diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts new file mode 100644 index 0000000000000..897c17a82b982 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/get.test.ts @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AlertsClient, ConstructorOptions } from '../alerts_client'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { alertingAuthorizationMock } from '../../../../alerting/server/authorization/alerting_authorization.mock'; +import { AuditLogger } from '../../../../security/server'; + +const alertingAuthMock = alertingAuthorizationMock.create(); +const esClientMock = elasticsearchClientMock.createElasticsearchClient(); +const auditLogger = { + log: jest.fn(), +} as jest.Mocked; + +const alertsClientParams: jest.Mocked = { + logger: loggingSystemMock.create().get(), + authorization: alertingAuthMock, + esClient: esClientMock, + auditLogger, +}; + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('get()', () => { + test('calls ES client with given params', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + esClientMock.search.mockResolvedValueOnce( + elasticsearchClientMock.createApiResponse({ + body: { + took: 5, + timed_out: false, + _shards: { + total: 1, + successful: 1, + failed: 0, + skipped: 0, + }, + hits: { + total: 1, + max_score: 999, + hits: [ + { + found: true, + _type: 'alert', + _index: '.alerts-observability-apm', + _id: 'NoxgpHkBqbdrfX07MqXV', + _version: 1, + _seq_no: 362, + _primary_term: 2, + _source: { + 'rule.id': 'apm.error_rate', + message: 'hello world 1', + 'kibana.rac.alert.owner': 'apm', + 'kibana.rac.alert.status': 'open', + }, + }, + ], + }, + }, + }) + ); + const result = await alertsClient.get({ id: '1', index: '.alerts-observability-apm' }); + expect(result).toMatchInlineSnapshot(` + Object { + "_version": "WzM2MiwyXQ==", + "kibana.rac.alert.owner": "apm", + "kibana.rac.alert.status": "open", + "message": "hello world 1", + "rule.id": "apm.error_rate", + } + `); + expect(esClientMock.search).toHaveBeenCalledTimes(1); + expect(esClientMock.search.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "body": Object { + "query": Object { + "term": Object { + "_id": "1", + }, + }, + }, + "ignore_unavailable": true, + "index": ".alerts-observability-apm", + "seq_no_primary_term": true, + }, + ] + `); + }); + + test('logs successful event in audit logger', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + esClientMock.search.mockResolvedValueOnce( + elasticsearchClientMock.createApiResponse({ + body: { + took: 5, + timed_out: false, + _shards: { + total: 1, + successful: 1, + failed: 0, + skipped: 0, + }, + hits: { + total: 1, + max_score: 999, + hits: [ + { + found: true, + _type: 'alert', + _index: '.alerts-observability-apm', + _id: 'NoxgpHkBqbdrfX07MqXV', + _version: 1, + _seq_no: 362, + _primary_term: 2, + _source: { + 'rule.id': 'apm.error_rate', + message: 'hello world 1', + 'kibana.rac.alert.owner': 'apm', + 'kibana.rac.alert.status': 'open', + }, + }, + ], + }, + }, + }) + ); + await alertsClient.get({ id: '1', index: '.alerts-observability-apm' }); + + expect(auditLogger.log).toHaveBeenCalledWith({ + error: undefined, + event: { action: 'alert_get', category: ['database'], outcome: 'success', type: ['access'] }, + message: 'User has accessed alert [id=1]', + }); + }); + + test(`throws an error if ES client get fails`, async () => { + const error = new Error('something went wrong'); + const alertsClient = new AlertsClient(alertsClientParams); + esClientMock.search.mockRejectedValue(error); + + await expect( + alertsClient.get({ id: '1', index: '.alerts-observability-apm' }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"something went wrong"`); + expect(auditLogger.log).toHaveBeenCalledWith({ + error: { code: 'Error', message: 'something went wrong' }, + event: { action: 'alert_get', category: ['database'], outcome: 'failure', type: ['access'] }, + message: 'Failed attempt to access alert [id=1]', + }); + }); + + describe('authorization', () => { + beforeEach(() => { + esClientMock.search.mockResolvedValueOnce( + elasticsearchClientMock.createApiResponse({ + body: { + took: 5, + timed_out: false, + _shards: { + total: 1, + successful: 1, + failed: 0, + skipped: 0, + }, + hits: { + total: 1, + max_score: 999, + hits: [ + { + found: true, + _type: 'alert', + _index: '.alerts-observability-apm', + _id: 'NoxgpHkBqbdrfX07MqXV', + _version: 1, + _seq_no: 362, + _primary_term: 2, + _source: { + 'rule.id': 'apm.error_rate', + message: 'hello world 1', + 'kibana.rac.alert.owner': 'apm', + 'kibana.rac.alert.status': 'open', + }, + }, + ], + }, + }, + }) + ); + }); + + test('returns alert if user is authorized to read alert under the consumer', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + const result = await alertsClient.get({ id: '1', index: '.alerts-observability-apm' }); + + expect(alertingAuthMock.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'alert', + consumer: 'apm', + operation: 'get', + ruleTypeId: 'apm.error_rate', + }); + expect(result).toMatchInlineSnapshot(` + Object { + "_version": "WzM2MiwyXQ==", + "kibana.rac.alert.owner": "apm", + "kibana.rac.alert.status": "open", + "message": "hello world 1", + "rule.id": "apm.error_rate", + } + `); + }); + + test('throws when user is not authorized to get this type of alert', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + alertingAuthMock.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to get a "apm.error_rate" alert for "apm"`) + ); + + await expect( + alertsClient.get({ id: '1', index: '.alerts-observability-apm' }) + ).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to get a "apm.error_rate" alert for "apm"]` + ); + + expect(alertingAuthMock.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'alert', + consumer: 'apm', + operation: 'get', + ruleTypeId: 'apm.error_rate', + }); + }); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts b/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts new file mode 100644 index 0000000000000..6fc387fe54b3b --- /dev/null +++ b/x-pack/plugins/rule_registry/server/alert_data_client/tests/update.test.ts @@ -0,0 +1,376 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AlertsClient, ConstructorOptions } from '../alerts_client'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { alertingAuthorizationMock } from '../../../../alerting/server/authorization/alerting_authorization.mock'; +import { AuditLogger } from '../../../../security/server'; + +const alertingAuthMock = alertingAuthorizationMock.create(); +const esClientMock = elasticsearchClientMock.createElasticsearchClient(); +const auditLogger = { + log: jest.fn(), +} as jest.Mocked; + +const alertsClientParams: jest.Mocked = { + logger: loggingSystemMock.create().get(), + authorization: alertingAuthMock, + esClient: esClientMock, + auditLogger, +}; + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('update()', () => { + test('calls ES client with given params', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + esClientMock.search.mockResolvedValueOnce( + elasticsearchClientMock.createApiResponse({ + body: { + took: 5, + timed_out: false, + _shards: { + total: 1, + successful: 1, + failed: 0, + skipped: 0, + }, + hits: { + total: 1, + max_score: 999, + hits: [ + { + found: true, + _type: 'alert', + _index: '.alerts-observability-apm', + _id: 'NoxgpHkBqbdrfX07MqXV', + _source: { + 'rule.id': 'apm.error_rate', + message: 'hello world 1', + 'kibana.rac.alert.owner': 'apm', + 'kibana.rac.alert.status': 'open', + }, + }, + ], + }, + }, + }) + ); + esClientMock.update.mockResolvedValueOnce( + elasticsearchClientMock.createApiResponse({ + body: { + _index: '.alerts-observability-apm', + _id: 'NoxgpHkBqbdrfX07MqXV', + _version: 2, + result: 'updated', + _shards: { total: 2, successful: 1, failed: 0 }, + _seq_no: 1, + _primary_term: 1, + }, + }) + ); + const result = await alertsClient.update({ + id: '1', + status: 'closed', + _version: undefined, + index: '.alerts-observability-apm', + }); + expect(result).toMatchInlineSnapshot(` + Object { + "_id": "NoxgpHkBqbdrfX07MqXV", + "_index": ".alerts-observability-apm", + "_primary_term": 1, + "_seq_no": 1, + "_shards": Object { + "failed": 0, + "successful": 1, + "total": 2, + }, + "_version": "WzEsMV0=", + "result": "updated", + } + `); + expect(esClientMock.update).toHaveBeenCalledTimes(1); + expect(esClientMock.update.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "body": Object { + "doc": Object { + "kibana.rac.alert.status": "closed", + }, + }, + "id": "1", + "index": ".alerts-observability-apm", + "refresh": "wait_for", + }, + ] + `); + }); + + test('logs successful event in audit logger', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + esClientMock.search.mockResolvedValueOnce( + elasticsearchClientMock.createApiResponse({ + body: { + took: 5, + timed_out: false, + _shards: { + total: 1, + successful: 1, + failed: 0, + skipped: 0, + }, + hits: { + total: 1, + max_score: 999, + hits: [ + { + found: true, + _type: 'alert', + _index: '.alerts-observability-apm', + _id: 'NoxgpHkBqbdrfX07MqXV', + _source: { + 'rule.id': 'apm.error_rate', + message: 'hello world 1', + 'kibana.rac.alert.owner': 'apm', + 'kibana.rac.alert.status': 'open', + }, + }, + ], + }, + }, + }) + ); + esClientMock.update.mockResolvedValueOnce( + elasticsearchClientMock.createApiResponse({ + body: { + _index: '.alerts-observability-apm', + _id: 'NoxgpHkBqbdrfX07MqXV', + _version: 2, + result: 'updated', + _shards: { total: 2, successful: 1, failed: 0 }, + _seq_no: 1, + _primary_term: 1, + }, + }) + ); + await alertsClient.update({ + id: '1', + status: 'closed', + _version: undefined, + index: '.alerts-observability-apm', + }); + + expect(auditLogger.log).toHaveBeenCalledWith({ + error: undefined, + event: { + action: 'alert_update', + category: ['database'], + outcome: 'unknown', + type: ['change'], + }, + message: 'User is updating alert [id=1]', + }); + }); + + test(`throws an error if ES client get fails`, async () => { + const error = new Error('something went wrong on get'); + const alertsClient = new AlertsClient(alertsClientParams); + esClientMock.search.mockRejectedValue(error); + + await expect( + alertsClient.update({ + id: '1', + status: 'closed', + _version: undefined, + index: '.alerts-observability-apm', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"something went wrong on get"`); + expect(auditLogger.log).toHaveBeenCalledWith({ + error: { code: 'Error', message: 'something went wrong on get' }, + event: { + action: 'alert_update', + category: ['database'], + outcome: 'failure', + type: ['change'], + }, + message: 'Failed attempt to update alert [id=1]', + }); + }); + + test(`throws an error if ES client update fails`, async () => { + const error = new Error('something went wrong on update'); + const alertsClient = new AlertsClient(alertsClientParams); + esClientMock.search.mockResolvedValueOnce( + elasticsearchClientMock.createApiResponse({ + body: { + took: 5, + timed_out: false, + _shards: { + total: 1, + successful: 1, + failed: 0, + skipped: 0, + }, + hits: { + total: 1, + max_score: 999, + hits: [ + { + found: true, + _type: 'alert', + _index: '.alerts-observability-apm', + _id: 'NoxgpHkBqbdrfX07MqXV', + _source: { + 'rule.id': 'apm.error_rate', + message: 'hello world 1', + 'kibana.rac.alert.owner': 'apm', + 'kibana.rac.alert.status': 'open', + }, + }, + ], + }, + }, + }) + ); + esClientMock.update.mockRejectedValue(error); + + await expect( + alertsClient.update({ + id: '1', + status: 'closed', + _version: undefined, + index: '.alerts-observability-apm', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"something went wrong on update"`); + expect(auditLogger.log).toHaveBeenCalledWith({ + error: { code: 'Error', message: 'something went wrong on update' }, + event: { + action: 'alert_update', + category: ['database'], + outcome: 'failure', + type: ['change'], + }, + message: 'Failed attempt to update alert [id=1]', + }); + }); + + describe('authorization', () => { + beforeEach(() => { + esClientMock.search.mockResolvedValueOnce( + elasticsearchClientMock.createApiResponse({ + body: { + took: 5, + timed_out: false, + _shards: { + total: 1, + successful: 1, + failed: 0, + skipped: 0, + }, + hits: { + total: 1, + max_score: 999, + hits: [ + { + found: true, + _type: 'alert', + _index: '.alerts-observability-apm', + _id: 'NoxgpHkBqbdrfX07MqXV', + _version: 2, + _seq_no: 362, + _primary_term: 2, + _source: { + 'rule.id': 'apm.error_rate', + message: 'hello world 1', + 'kibana.rac.alert.owner': 'apm', + 'kibana.rac.alert.status': 'open', + }, + }, + ], + }, + }, + }) + ); + + esClientMock.update.mockResolvedValueOnce( + elasticsearchClientMock.createApiResponse({ + body: { + _index: '.alerts-observability-apm', + _id: 'NoxgpHkBqbdrfX07MqXV', + _version: 2, + result: 'updated', + _shards: { total: 2, successful: 1, failed: 0 }, + _seq_no: 1, + _primary_term: 1, + }, + }) + ); + }); + + test('returns alert if user is authorized to update alert under the consumer', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + const result = await alertsClient.update({ + id: '1', + status: 'closed', + _version: undefined, + index: '.alerts-observability-apm', + }); + + expect(alertingAuthMock.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'alert', + consumer: 'apm', + operation: 'update', + ruleTypeId: 'apm.error_rate', + }); + expect(result).toMatchInlineSnapshot(` + Object { + "_id": "NoxgpHkBqbdrfX07MqXV", + "_index": ".alerts-observability-apm", + "_primary_term": 1, + "_seq_no": 1, + "_shards": Object { + "failed": 0, + "successful": 1, + "total": 2, + }, + "_version": "WzEsMV0=", + "result": "updated", + } + `); + }); + + test('throws when user is not authorized to update this type of alert', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + alertingAuthMock.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to get a "apm.error_rate" alert for "apm"`) + ); + + await expect( + alertsClient.update({ + id: '1', + status: 'closed', + _version: undefined, + index: '.alerts-observability-apm', + }) + ).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to get a "apm.error_rate" alert for "apm"]` + ); + + expect(alertingAuthMock.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'alert', + consumer: 'apm', + operation: 'update', + ruleTypeId: 'apm.error_rate', + }); + }); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/event_log/elasticsearch/index_writer.ts b/x-pack/plugins/rule_registry/server/event_log/elasticsearch/index_writer.ts index 7f83421ec80d8..6fd1c954d8c14 100644 --- a/x-pack/plugins/rule_registry/server/event_log/elasticsearch/index_writer.ts +++ b/x-pack/plugins/rule_registry/server/event_log/elasticsearch/index_writer.ts @@ -72,7 +72,7 @@ export class IndexWriter { for (const item of items) { if (item.doc === undefined) continue; - bulkBody.push({ create: { _index: item.index } }); + bulkBody.push({ create: { _index: item.index, version: 1 } }); bulkBody.push(item.doc); } diff --git a/x-pack/plugins/rule_registry/server/index.ts b/x-pack/plugins/rule_registry/server/index.ts index 9eefc19f34670..b6fd6b9a605c0 100644 --- a/x-pack/plugins/rule_registry/server/index.ts +++ b/x-pack/plugins/rule_registry/server/index.ts @@ -10,6 +10,7 @@ import { RuleRegistryPlugin } from './plugin'; export * from './config'; export type { RuleRegistryPluginSetupContract, RuleRegistryPluginStartContract } from './plugin'; +export type { RacRequestHandlerContext, RacApiRequestHandlerContext } from './types'; export { RuleDataClient } from './rule_data_client'; export { IRuleDataClient } from './rule_data_client/types'; export { getRuleExecutorData, RuleExecutorData } from './utils/get_rule_executor_data'; diff --git a/x-pack/plugins/rule_registry/server/plugin.ts b/x-pack/plugins/rule_registry/server/plugin.ts index 043b07f9d67c1..ca98254037732 100644 --- a/x-pack/plugins/rule_registry/server/plugin.ts +++ b/x-pack/plugins/rule_registry/server/plugin.ts @@ -4,19 +4,34 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { PluginInitializerContext, Plugin, CoreSetup, Logger } from 'src/core/server'; +import { + PluginInitializerContext, + Plugin, + CoreSetup, + Logger, + KibanaRequest, + CoreStart, + IContextProvider, +} from 'src/core/server'; +import { SecurityPluginSetup } from '../../security/server'; +import { AlertsClientFactory } from './alert_data_client/alerts_client_factory'; +import { PluginStartContract as AlertingStart } from '../../alerting/server'; +import { RacApiRequestHandlerContext, RacRequestHandlerContext } from './types'; +import { defineRoutes } from './routes'; import { SpacesPluginStart } from '../../spaces/server'; import { RuleRegistryPluginConfig } from './config'; import { RuleDataPluginService } from './rule_data_plugin_service'; import { EventLogService, IEventLogService } from './event_log'; +import { AlertsClient } from './alert_data_client/alerts_client'; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -interface RuleRegistryPluginSetupDependencies {} +export interface RuleRegistryPluginSetupDependencies { + security?: SecurityPluginSetup; +} -interface RuleRegistryPluginStartDependencies { +export interface RuleRegistryPluginStartDependencies { spaces: SpacesPluginStart; + alerting: AlertingStart; } export interface RuleRegistryPluginSetupContract { @@ -24,7 +39,10 @@ export interface RuleRegistryPluginSetupContract { eventLogService: IEventLogService; } -export type RuleRegistryPluginStartContract = void; +export interface RuleRegistryPluginStartContract { + getRacClientWithRequest: (req: KibanaRequest) => Promise; + alerting: AlertingStart; +} export class RuleRegistryPlugin implements @@ -37,17 +55,23 @@ export class RuleRegistryPlugin private readonly config: RuleRegistryPluginConfig; private readonly logger: Logger; private eventLogService: EventLogService | null; + private readonly alertsClientFactory: AlertsClientFactory; + private ruleDataService: RuleDataPluginService | null; + private security: SecurityPluginSetup | undefined; constructor(initContext: PluginInitializerContext) { this.config = initContext.config.get(); this.logger = initContext.logger.get(); this.eventLogService = null; + this.ruleDataService = null; + this.alertsClientFactory = new AlertsClientFactory(); } public setup( - core: CoreSetup + core: CoreSetup, + plugins: RuleRegistryPluginSetupDependencies ): RuleRegistryPluginSetupContract { - const { config, logger } = this; + const { logger } = this; const startDependencies = core.getStartServices().then(([coreStart, pluginStart]) => { return { @@ -56,23 +80,36 @@ export class RuleRegistryPlugin }; }); - const ruleDataService = new RuleDataPluginService({ - logger, - isWriteEnabled: config.write.enabled, - index: config.index, + this.security = plugins.security; + + const service = new RuleDataPluginService({ + logger: this.logger, + isWriteEnabled: this.config.write.enabled, + index: this.config.index, getClusterClient: async () => { const deps = await startDependencies; return deps.core.elasticsearch.client.asInternalUser; }, }); - ruleDataService.init().catch((originalError) => { + service.init().catch((originalError) => { const error = new Error('Failed installing assets'); // @ts-ignore error.stack = originalError.stack; - logger.error(error); + this.logger.error(error); }); + this.ruleDataService = service; + + // ALERTS ROUTES + const router = core.http.createRouter(); + core.http.registerRouteHandlerContext( + 'rac', + this.createRouteHandlerContext() + ); + + defineRoutes(router); + const eventLogService = new EventLogService({ config: { indexPrefix: this.config.index, @@ -86,10 +123,47 @@ export class RuleRegistryPlugin }); this.eventLogService = eventLogService; - return { ruleDataService, eventLogService }; + + return { ruleDataService: this.ruleDataService, eventLogService }; } - public start(): RuleRegistryPluginStartContract {} + public start( + core: CoreStart, + plugins: RuleRegistryPluginStartDependencies + ): RuleRegistryPluginStartContract { + const { logger, alertsClientFactory, security } = this; + + alertsClientFactory.initialize({ + logger, + esClient: core.elasticsearch.client.asInternalUser, + // NOTE: Alerts share the authorization client with the alerting plugin + getAlertingAuthorization(request: KibanaRequest) { + return plugins.alerting.getAlertingAuthorizationWithRequest(request); + }, + securityPluginSetup: security, + }); + + const getRacClientWithRequest = (request: KibanaRequest) => { + return alertsClientFactory.create(request); + }; + + return { + getRacClientWithRequest, + alerting: plugins.alerting, + }; + } + + private createRouteHandlerContext = (): IContextProvider => { + const { alertsClientFactory } = this; + return function alertsRouteHandlerContext(context, request): RacApiRequestHandlerContext { + return { + getAlertsClient: async () => { + const createdClient = alertsClientFactory.create(request); + return createdClient; + }, + }; + }; + }; public stop() { const { eventLogService, logger } = this; diff --git a/x-pack/plugins/rule_registry/server/routes/__mocks__/request_context.ts b/x-pack/plugins/rule_registry/server/routes/__mocks__/request_context.ts new file mode 100644 index 0000000000000..6d47882ca86c4 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/__mocks__/request_context.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { coreMock, elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks'; +import { alertsClientMock } from '../../alert_data_client/alerts_client.mock'; +import { RacRequestHandlerContext } from '../../types'; + +const createMockClients = () => ({ + rac: alertsClientMock.create(), + clusterClient: elasticsearchServiceMock.createLegacyScopedClusterClient(), + newClusterClient: elasticsearchServiceMock.createScopedClusterClient(), + savedObjectsClient: savedObjectsClientMock.create(), +}); + +const createRequestContextMock = ( + clients: ReturnType = createMockClients() +) => { + const coreContext = coreMock.createRequestHandlerContext(); + return ({ + rac: { getAlertsClient: jest.fn(() => clients.rac) }, + core: { + ...coreContext, + elasticsearch: { + ...coreContext.elasticsearch, + client: clients.newClusterClient, + legacy: { ...coreContext.elasticsearch.legacy, client: clients.clusterClient }, + }, + savedObjects: { client: clients.savedObjectsClient }, + }, + } as unknown) as RacRequestHandlerContext; +}; + +const createTools = () => { + const clients = createMockClients(); + const context = createRequestContextMock(clients); + + return { clients, context }; +}; + +export const requestContextMock = { + create: createRequestContextMock, + createMockClients, + createTools, +}; diff --git a/x-pack/plugins/rule_registry/server/routes/__mocks__/request_responses.ts b/x-pack/plugins/rule_registry/server/routes/__mocks__/request_responses.ts new file mode 100644 index 0000000000000..228fcf491994f --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/__mocks__/request_responses.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { BASE_RAC_ALERTS_API_PATH } from '../../../common/constants'; +import { requestMock } from './server'; + +export const getReadRequest = () => + requestMock.create({ + method: 'get', + path: BASE_RAC_ALERTS_API_PATH, + query: { id: 'alert-1' }, + }); + +export const getUpdateRequest = () => + requestMock.create({ + method: 'patch', + path: BASE_RAC_ALERTS_API_PATH, + body: { + status: 'closed', + ids: ['alert-1'], + index: '.alerts-observability-apm*', + }, + }); diff --git a/x-pack/plugins/rule_registry/server/routes/__mocks__/response_adapters.ts b/x-pack/plugins/rule_registry/server/routes/__mocks__/response_adapters.ts new file mode 100644 index 0000000000000..7952b33dcf9b1 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/__mocks__/response_adapters.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServerMock } from 'src/core/server/mocks'; + +const responseMock = { + create: httpServerMock.createResponseFactory, +}; + +type ResponseMock = ReturnType; +type Method = keyof ResponseMock; + +type MockCall = any; + +interface ResponseCall { + body: any; + status: number; +} + +/** + * @internal + */ +export interface Response extends ResponseCall { + calls: ResponseCall[]; +} + +const buildResponses = (method: Method, calls: MockCall[]): ResponseCall[] => { + if (!calls.length) return []; + + switch (method) { + case 'ok': + return calls.map(([call]) => ({ status: 200, body: call.body })); + case 'customError': + return calls.map(([call]) => ({ + status: call.statusCode, + body: call.body, + })); + default: + throw new Error(`Encountered unexpected call to response.${method}`); + } +}; + +export const responseAdapter = (response: ResponseMock): Response => { + const methods = Object.keys(response) as Method[]; + const calls = methods + .reduce((responses, method) => { + const methodMock = response[method]; + return [...responses, ...buildResponses(method, methodMock.mock.calls)]; + }, []) + .sort((call, other) => other.status - call.status); + + const [{ body, status }] = calls; + + return { + body, + status, + calls, + }; +}; diff --git a/x-pack/plugins/rule_registry/server/routes/__mocks__/server.ts b/x-pack/plugins/rule_registry/server/routes/__mocks__/server.ts new file mode 100644 index 0000000000000..ade72435c57d9 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/__mocks__/server.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RequestHandler, RouteConfig, KibanaRequest } from 'src/core/server'; +import { httpServerMock, httpServiceMock } from 'src/core/server/mocks'; +import { RacRequestHandlerContext } from '../../types'; +import { requestContextMock } from './request_context'; +import { responseAdapter } from './response_adapters'; + +export const requestMock = { + create: httpServerMock.createKibanaRequest, +}; + +export const responseFactoryMock = { + create: httpServerMock.createResponseFactory, +}; + +interface Route { + config: RouteConfig; + handler: RequestHandler; +} +const getRoute = (routerMock: MockServer['router']): Route => { + const routeCalls = [ + ...routerMock.get.mock.calls, + ...routerMock.post.mock.calls, + ...routerMock.put.mock.calls, + ...routerMock.patch.mock.calls, + ...routerMock.delete.mock.calls, + ]; + + const [route] = routeCalls; + if (!route) { + throw new Error('No route registered!'); + } + + const [config, handler] = route; + return { config, handler }; +}; + +const buildResultMock = () => ({ ok: jest.fn((x) => x), badRequest: jest.fn((x) => x) }); + +class MockServer { + constructor( + public readonly router = httpServiceMock.createRouter(), + private responseMock = responseFactoryMock.create(), + private contextMock = requestContextMock.create(), + private resultMock = buildResultMock() + ) {} + + public validate(request: KibanaRequest) { + this.validateRequest(request); + return this.resultMock; + } + + public async inject( + request: KibanaRequest, + context: RacRequestHandlerContext = this.contextMock + ) { + const validatedRequest = this.validateRequest(request); + const [rejection] = this.resultMock.badRequest.mock.calls; + if (rejection) { + throw new Error(`Request was rejected with message: '${rejection}'`); + } + + await this.getRoute().handler(context, validatedRequest, this.responseMock); + return responseAdapter(this.responseMock); + } + + private getRoute(): Route { + return getRoute(this.router); + } + + private maybeValidate(part: any, validator?: any): any { + return typeof validator === 'function' ? validator(part, this.resultMock) : part; + } + + private validateRequest(request: KibanaRequest): KibanaRequest { + const validations = this.getRoute().config.validate; + if (!validations) { + return request; + } + + const validatedRequest = requestMock.create({ + path: request.route.path, + method: request.route.method, + body: this.maybeValidate(request.body, validations.body), + query: this.maybeValidate(request.query, validations.query), + params: this.maybeValidate(request.params, validations.params), + }); + + return validatedRequest; + } +} + +const createMockServer = () => new MockServer(); + +export const serverMock = { + create: createMockServer, +}; diff --git a/x-pack/plugins/rule_registry/server/routes/get_alert_by_id.test.ts b/x-pack/plugins/rule_registry/server/routes/get_alert_by_id.test.ts new file mode 100644 index 0000000000000..0de1e6c585a17 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/get_alert_by_id.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { BASE_RAC_ALERTS_API_PATH } from '../../common/constants'; +import { ParsedTechnicalFields } from '../../common/parse_technical_fields'; +import { getAlertByIdRoute } from './get_alert_by_id'; +import { requestContextMock } from './__mocks__/request_context'; +import { getReadRequest } from './__mocks__/request_responses'; +import { requestMock, serverMock } from './__mocks__/server'; + +const getMockAlert = (): ParsedTechnicalFields => ({ + '@timestamp': '2021-06-21T21:33:05.713Z', + 'rule.id': 'apm.error_rate', + 'kibana.rac.alert.owner': 'apm', + 'kibana.rac.alert.status': 'open', +}); + +describe('getAlertByIdRoute', () => { + let server: ReturnType; + let { clients, context } = requestContextMock.createTools(); + + beforeEach(async () => { + server = serverMock.create(); + ({ clients, context } = requestContextMock.createTools()); + + clients.rac.get.mockResolvedValue(getMockAlert()); + + getAlertByIdRoute(server.router); + }); + + test('returns 200 when finding a single alert with valid params', async () => { + const response = await server.inject(getReadRequest(), context); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(getMockAlert()); + }); + + test('returns 200 when finding a single alert with index param', async () => { + const response = await server.inject( + requestMock.create({ + method: 'get', + path: BASE_RAC_ALERTS_API_PATH, + query: { id: 'alert-1', index: '.alerts-me' }, + }), + context + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(getMockAlert()); + }); + + describe('request validation', () => { + test('rejects invalid query params', async () => { + await expect( + server.inject( + requestMock.create({ + method: 'get', + path: BASE_RAC_ALERTS_API_PATH, + query: { id: 4 }, + }), + context + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Request was rejected with message: 'Invalid value \\"4\\" supplied to \\"id\\"'"` + ); + }); + + test('rejects unknown query params', async () => { + await expect( + server.inject( + requestMock.create({ + method: 'get', + path: BASE_RAC_ALERTS_API_PATH, + query: { notId: 4 }, + }), + context + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Request was rejected with message: 'Invalid value \\"undefined\\" supplied to \\"id\\"'"` + ); + }); + }); + + test('returns error status if rac client "GET" fails', async () => { + clients.rac.get.mockRejectedValue(new Error('Unable to get alert')); + const response = await server.inject(getReadRequest(), context); + + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + attributes: { success: false }, + message: 'Unable to get alert', + }); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/routes/get_alert_by_id.ts b/x-pack/plugins/rule_registry/server/routes/get_alert_by_id.ts new file mode 100644 index 0000000000000..9ddec56055a5a --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/get_alert_by_id.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IRouter } from 'kibana/server'; +import * as t from 'io-ts'; +import { id as _id } from '@kbn/securitysolution-io-ts-list-types'; +import { transformError } from '@kbn/securitysolution-es-utils'; + +import { RacRequestHandlerContext } from '../types'; +import { BASE_RAC_ALERTS_API_PATH } from '../../common/constants'; +import { buildRouteValidation } from './utils/route_validation'; + +export const getAlertByIdRoute = (router: IRouter) => { + router.get( + { + path: BASE_RAC_ALERTS_API_PATH, + validate: { + query: buildRouteValidation( + t.intersection([ + t.exact( + t.type({ + id: _id, + }) + ), + t.exact( + t.partial({ + index: t.string, + }) + ), + ]) + ), + }, + options: { + tags: ['access:rac'], + }, + }, + async (context, request, response) => { + try { + const alertsClient = await context.rac.getAlertsClient(); + const { id, index } = request.query; + const alert = await alertsClient.get({ id, index }); + if (alert == null) { + return response.notFound({ + body: { message: `alert with id ${id} and index ${index} not found` }, + }); + } + return response.ok({ + body: alert, + }); + } catch (exc) { + const err = transformError(exc); + const contentType = { + 'content-type': 'application/json', + }; + const defaultedHeaders = { + ...contentType, + }; + + return response.customError({ + headers: defaultedHeaders, + statusCode: err.statusCode, + body: { + message: err.message, + attributes: { + success: false, + }, + }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/rule_registry/server/routes/get_alert_index.ts b/x-pack/plugins/rule_registry/server/routes/get_alert_index.ts new file mode 100644 index 0000000000000..b8b181a493cec --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/get_alert_index.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IRouter } from 'kibana/server'; +import { id as _id } from '@kbn/securitysolution-io-ts-list-types'; +import { transformError } from '@kbn/securitysolution-es-utils'; + +import { RacRequestHandlerContext } from '../types'; +import { BASE_RAC_ALERTS_API_PATH } from '../../common/constants'; +import { validFeatureIds } from '../utils/rbac'; + +export const getAlertsIndexRoute = (router: IRouter) => { + router.get( + { + path: `${BASE_RAC_ALERTS_API_PATH}/index`, + validate: false, + options: { + tags: ['access:rac'], + }, + }, + async (context, request, response) => { + try { + const alertsClient = await context.rac.getAlertsClient(); + const indexName = await alertsClient.getAuthorizedAlertsIndices(validFeatureIds); + return response.ok({ + body: { index_name: indexName }, + }); + } catch (exc) { + const err = transformError(exc); + const contentType = { + 'content-type': 'application/json', + }; + const defaultedHeaders = { + ...contentType, + }; + + return response.custom({ + headers: defaultedHeaders, + statusCode: err.statusCode, + body: Buffer.from( + JSON.stringify({ + message: err.message, + status_code: err.statusCode, + }) + ), + }); + } + } + ); +}; diff --git a/x-pack/plugins/rule_registry/server/routes/index.ts b/x-pack/plugins/rule_registry/server/routes/index.ts new file mode 100644 index 0000000000000..6698cd7717268 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IRouter } from 'kibana/server'; +import { RacRequestHandlerContext } from '../types'; +import { getAlertByIdRoute } from './get_alert_by_id'; +import { updateAlertByIdRoute } from './update_alert_by_id'; +import { getAlertsIndexRoute } from './get_alert_index'; + +export function defineRoutes(router: IRouter) { + getAlertByIdRoute(router); + updateAlertByIdRoute(router); + getAlertsIndexRoute(router); +} diff --git a/x-pack/plugins/rule_registry/server/routes/update_alert_by_id.test.ts b/x-pack/plugins/rule_registry/server/routes/update_alert_by_id.test.ts new file mode 100644 index 0000000000000..7ec699491ca83 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/update_alert_by_id.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { BASE_RAC_ALERTS_API_PATH } from '../../common/constants'; +import { updateAlertByIdRoute } from './update_alert_by_id'; +import { requestContextMock } from './__mocks__/request_context'; +import { getUpdateRequest } from './__mocks__/request_responses'; +import { requestMock, serverMock } from './__mocks__/server'; + +describe('updateAlertByIdRoute', () => { + let server: ReturnType; + let { clients, context } = requestContextMock.createTools(); + + beforeEach(async () => { + server = serverMock.create(); + ({ clients, context } = requestContextMock.createTools()); + + clients.rac.update.mockResolvedValue({ + _index: '.alerts-observability-apm', + _id: 'NoxgpHkBqbdrfX07MqXV', + _version: 'WzM2MiwyXQ==', + result: 'updated', + _shards: { total: 2, successful: 1, failed: 0 }, + _seq_no: 1, + _primary_term: 1, + }); + + updateAlertByIdRoute(server.router); + }); + + test('returns 200 when updating a single alert with valid params', async () => { + const response = await server.inject(getUpdateRequest(), context); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + _index: '.alerts-observability-apm', + _id: 'NoxgpHkBqbdrfX07MqXV', + _version: 'WzM2MiwyXQ==', + result: 'updated', + _shards: { total: 2, successful: 1, failed: 0 }, + _seq_no: 1, + _primary_term: 1, + success: true, + }); + }); + + describe('request validation', () => { + test('rejects invalid query params', async () => { + await expect( + server.inject( + requestMock.create({ + method: 'patch', + path: BASE_RAC_ALERTS_API_PATH, + body: { + status: 'closed', + ids: 'alert-1', + index: '.alerts-observability-apm*', + }, + }), + context + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Request was rejected with message: 'Invalid value \\"alert-1\\" supplied to \\"ids\\"'"` + ); + }); + + test('rejects unknown query params', async () => { + await expect( + server.inject( + requestMock.create({ + method: 'patch', + path: BASE_RAC_ALERTS_API_PATH, + body: { + notStatus: 'closed', + ids: ['alert-1'], + index: '.alerts-observability-apm*', + }, + }), + context + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Request was rejected with message: 'Invalid value \\"undefined\\" supplied to \\"status\\"'"` + ); + }); + }); + + test('returns error status if rac client "GET" fails', async () => { + clients.rac.update.mockRejectedValue(new Error('Unable to update alert')); + const response = await server.inject(getUpdateRequest(), context); + + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + attributes: { success: false }, + message: 'Unable to update alert', + }); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/routes/update_alert_by_id.ts b/x-pack/plugins/rule_registry/server/routes/update_alert_by_id.ts new file mode 100644 index 0000000000000..a77688a514e77 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/update_alert_by_id.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IRouter } from 'kibana/server'; +import * as t from 'io-ts'; +import { id as _id } from '@kbn/securitysolution-io-ts-list-types'; +import { transformError } from '@kbn/securitysolution-es-utils'; + +import { buildRouteValidation } from './utils/route_validation'; +import { RacRequestHandlerContext } from '../types'; +import { BASE_RAC_ALERTS_API_PATH } from '../../common/constants'; + +export const updateAlertByIdRoute = (router: IRouter) => { + router.post( + { + path: BASE_RAC_ALERTS_API_PATH, + validate: { + body: buildRouteValidation( + t.intersection([ + t.exact( + t.type({ + status: t.string, + ids: t.array(t.string), + index: t.string, + }) + ), + t.exact( + t.partial({ + _version: t.string, + }) + ), + ]) + ), + }, + options: { + tags: ['access:rac'], + }, + }, + async (context, req, response) => { + try { + const alertsClient = await context.rac.getAlertsClient(); + const { status, ids, index, _version } = req.body; + + const updatedAlert = await alertsClient.update({ + id: ids[0], + status, + _version, + index, + }); + + if (updatedAlert == null) { + return response.notFound({ + body: { message: `alerts with ids ${ids} and index ${index} not found` }, + }); + } + + return response.ok({ body: { success: true, ...updatedAlert } }); + } catch (exc) { + const err = transformError(exc); + + const contentType = { + 'content-type': 'application/json', + }; + const defaultedHeaders = { + ...contentType, + }; + + return response.customError({ + headers: defaultedHeaders, + statusCode: err.statusCode, + body: { + message: err.message, + attributes: { + success: false, + }, + }, + }); + } + } + ); +}; diff --git a/x-pack/plugins/rule_registry/server/routes/utils/route_validation.ts b/x-pack/plugins/rule_registry/server/routes/utils/route_validation.ts new file mode 100644 index 0000000000000..8e74760d6d15f --- /dev/null +++ b/x-pack/plugins/rule_registry/server/routes/utils/route_validation.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import * as rt from 'io-ts'; +import { exactCheck, formatErrors } from '@kbn/securitysolution-io-ts-utils'; + +import { + RouteValidationError, + RouteValidationFunction, + RouteValidationResultFactory, +} from '../../../../../../src/core/server'; + +type RequestValidationResult = + | { + value: T; + error?: undefined; + } + | { + value?: undefined; + error: RouteValidationError; + }; + +/** + * Copied from x-pack/plugins/security_solution/server/utils/build_validation/route_validation.ts + * This really should be in @kbn/securitysolution-io-ts-utils rather than copied yet again, however, this has types + * from a lot of places such as RouteValidationResultFactory from core/server which in turn can pull in @kbn/schema + * which cannot work on the front end and @kbn/securitysolution-io-ts-utils works on both front and backend. + * + * TODO: Figure out a way to move this function into a package rather than copying it/forking it within plugins + */ +export const buildRouteValidation = >( + schema: T +): RouteValidationFunction => ( + inputValue: unknown, + validationResult: RouteValidationResultFactory +): RequestValidationResult => + pipe( + schema.decode(inputValue), + (decoded) => exactCheck(inputValue, decoded), + fold>( + (errors: rt.Errors) => validationResult.badRequest(formatErrors(errors).join()), + (validatedInput: A) => validationResult.ok(validatedInput) + ) + ); diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/types.ts b/x-pack/plugins/rule_registry/server/rule_data_client/types.ts index 3b90079ec5238..54e9a1b3c9a6f 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/types.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/types.ts @@ -11,6 +11,7 @@ import { ElasticsearchClient } from 'kibana/server'; import { FieldDescriptor } from 'src/plugins/data/server'; import { ESSearchRequest, ESSearchResponse } from 'src/core/types/elasticsearch'; import { TechnicalRuleDataFieldName } from '../../common/technical_rule_data_field_names'; +import { ValidFeatureId } from '../utils/rbac'; export interface RuleDataReader { search( @@ -37,9 +38,17 @@ export interface IRuleDataClient { createWriteTargetIfNeeded(options: { namespace?: string }): Promise; } +/** + * The purpose of the `feature` param is to force the user to update + * the data structure which contains the mapping of consumers to alerts + * as data indices. The idea is it is typed such that it forces the + * user to go to the code and modify it. At least until a better system + * is put in place or we move the alerts as data client out of rule registry. + */ export interface RuleDataClientConstructorOptions { getClusterClient: () => Promise; isWriteEnabled: boolean; ready: () => Promise; alias: string; + feature: ValidFeatureId; } diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index.ts index 33ff5281147e1..d84f85dbc99b7 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/index.ts @@ -20,10 +20,11 @@ import { ClusterPutComponentTemplateBody, PutIndexTemplateRequest } from '../../ import { RuleDataClient } from '../rule_data_client'; import { RuleDataWriteDisabledError } from './errors'; import { incrementIndexName } from './utils'; +import { ValidFeatureId } from '../utils/rbac'; const BOOTSTRAP_TIMEOUT = 60000; -interface RuleDataPluginServiceConstructorOptions { +export interface RuleDataPluginServiceConstructorOptions { getClusterClient: () => Promise; logger: Logger; isWriteEnabled: boolean; @@ -223,9 +224,10 @@ export class RuleDataPluginService { return [this.options.index, assetName].filter(Boolean).join('-'); } - getRuleDataClient(alias: string, initialize: () => Promise) { + getRuleDataClient(feature: ValidFeatureId, alias: string, initialize: () => Promise) { return new RuleDataClient({ alias, + feature, getClusterClient: () => this.getClusterClient(), isWriteEnabled: this.isWriteEnabled(), ready: initialize, diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock.ts new file mode 100644 index 0000000000000..275d68621864f --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.mock.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PublicMethodsOf } from '@kbn/utility-types'; +import { RuleDataPluginService, RuleDataPluginServiceConstructorOptions } from './'; + +type Schema = PublicMethodsOf; + +const createRuleDataPluginServiceMock = (_: RuleDataPluginServiceConstructorOptions) => { + const mocked: jest.Mocked = { + init: jest.fn(), + isReady: jest.fn(), + wait: jest.fn(), + isWriteEnabled: jest.fn(), + getFullAssetName: jest.fn(), + createOrUpdateComponentTemplate: jest.fn(), + createOrUpdateIndexTemplate: jest.fn(), + createOrUpdateLifecyclePolicy: jest.fn(), + getRuleDataClient: jest.fn(), + updateIndexMappingsMatchingPattern: jest.fn(), + }; + return mocked; +}; + +export const ruleDataPluginServiceMock: { + create: ( + _: RuleDataPluginServiceConstructorOptions + ) => jest.Mocked>; +} = { + create: createRuleDataPluginServiceMock, +}; diff --git a/x-pack/plugins/rule_registry/server/scripts/README.md b/x-pack/plugins/rule_registry/server/scripts/README.md new file mode 100644 index 0000000000000..2b3f01f3c4d6b --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/README.md @@ -0,0 +1,24 @@ +Users with roles granting them access to monitoring (observability) and siem (security solution) should only be able to access alerts with those roles + +```bash +myterminal~$ ./get_security_solution_alert.sh observer +{ + "statusCode": 404, + "error": "Not Found", + "message": "Unauthorized to get \"rac:8.0.0:securitySolution/get\" alert\"" +} +myterminal~$ ./get_security_solution_alert.sh +{ + "success": true +} +myterminal~$ ./get_observability_alert.sh +{ + "success": true +} +myterminal~$ ./get_observability_alert.sh hunter +{ + "statusCode": 404, + "error": "Not Found", + "message": "Unauthorized to get \"rac:8.0.0:observability/get\" alert\"" +} +``` diff --git a/x-pack/plugins/rule_registry/server/scripts/get_alerts_index.sh b/x-pack/plugins/rule_registry/server/scripts/get_alerts_index.sh new file mode 100755 index 0000000000000..bfa74aa016f02 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/get_alerts_index.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +set -e + +USER=${1:-'observer'} + +cd ./hunter && sh ./post_detections_role.sh && sh ./post_detections_user.sh +cd ../observer && sh ./post_detections_role.sh && sh ./post_detections_user.sh +cd .. + +# Example: ./find_rules.sh +curl -v -k \ + -u $USER:changeme \ + -X GET "${KIBANA_URL}${SPACE_URL}/internal/rac/alerts/index" | jq . + +# -X GET "${KIBANA_URL}${SPACE_URL}/api/apm/settings/apm-alerts-as-data-indices" | jq . diff --git a/x-pack/plugins/rule_registry/server/scripts/get_observability_alert.sh b/x-pack/plugins/rule_registry/server/scripts/get_observability_alert.sh new file mode 100755 index 0000000000000..6fbd0eb3dc816 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/get_observability_alert.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +set -e + +USER=${1:-'observer'} +ID=${2:-'DHEnOXoB8br9Z2X1fq_l'} + +cd ./hunter && sh ./post_detections_role.sh && sh ./post_detections_user.sh +cd ../observer && sh ./post_detections_role.sh && sh ./post_detections_user.sh +cd .. + +# Example: ./get_observability_alert.sh hunter +curl -v -k \ + -u $USER:changeme \ + -X GET "${KIBANA_URL}${SPACE_URL}/internal/rac/alerts?id=$ID&index=.alerts-observability-apm" | jq . diff --git a/x-pack/plugins/rule_registry/server/scripts/get_security_alert.sh b/x-pack/plugins/rule_registry/server/scripts/get_security_alert.sh new file mode 100755 index 0000000000000..9bf051c1c6412 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/get_security_alert.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +set -e + +USER=${1:-'hunter'} +ID=${2:-'kdL4gHoBFALkyfScIsY5'} + +cd ./hunter && sh ./post_detections_role.sh && sh ./post_detections_user.sh +cd ../observer && sh ./post_detections_role.sh && sh ./post_detections_user.sh +cd .. + +# Example: ./get_observability_alert.sh hunter +curl -v -k \ + -u $USER:changeme \ + -X GET "${KIBANA_URL}${SPACE_URL}/internal/rac/alerts?id=$ID&index=.alerts-security-solution" | jq . diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/README.md b/x-pack/plugins/rule_registry/server/scripts/hunter/README.md new file mode 100644 index 0000000000000..a0269d5b060a3 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/README.md @@ -0,0 +1,5 @@ +This user can access the monitoring route at http://localhost:5601/security-myfakepath + +| Role | Data Sources | Security Solution ML Jobs/Results | Lists | Rules/Exceptions | Action Connectors | Signals/Alerts | +| :-----------------: | :----------: | :-------------------------------: | :---: | :--------------: | :---------------: | :------------: | +| Hunter / T3 Analyst | read, write | read | read | read, write | read | read, write | diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/delete_detections_user.sh b/x-pack/plugins/rule_registry/server/scripts/hunter/delete_detections_user.sh new file mode 100755 index 0000000000000..595f0a49282d8 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/delete_detections_user.sh @@ -0,0 +1,11 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XDELETE ${ELASTICSEARCH_URL}/_security/user/hunter diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/detections_role.json b/x-pack/plugins/rule_registry/server/scripts/hunter/detections_role.json new file mode 100644 index 0000000000000..80f63f80b849c --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/detections_role.json @@ -0,0 +1,19 @@ +{ + "elasticsearch": { + "cluster": [], + "indices": [] + }, + "kibana": [ + { + "feature": { + "ml": ["read"], + "siem": ["all"], + "actions": ["read"], + "ruleRegistry": ["all"], + "builtInAlerts": ["all"], + "alerting": ["all"] + }, + "spaces": ["*"] + } + ] +} diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/detections_user.json b/x-pack/plugins/rule_registry/server/scripts/hunter/detections_user.json new file mode 100644 index 0000000000000..f9454cc0ad2fe --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/detections_user.json @@ -0,0 +1,6 @@ +{ + "password": "changeme", + "roles": ["hunter"], + "full_name": "Hunter", + "email": "detections-reader@example.com" +} diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/get_detections_role.sh b/x-pack/plugins/rule_registry/server/scripts/hunter/get_detections_role.sh new file mode 100755 index 0000000000000..7ec850ce220bb --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/get_detections_role.sh @@ -0,0 +1,11 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XGET ${KIBANA_URL}/api/security/role/hunter | jq -S . diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/index.ts b/x-pack/plugins/rule_registry/server/scripts/hunter/index.ts new file mode 100644 index 0000000000000..3411589de7721 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as hunterUser from './detections_user.json'; +import * as hunterRole from './detections_role.json'; +export { hunterUser, hunterRole }; diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_role.sh b/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_role.sh new file mode 100755 index 0000000000000..debffe0fcac4c --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_role.sh @@ -0,0 +1,14 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +ROLE=(${@:-./detections_role.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XPUT ${KIBANA_URL}/api/security/role/hunter \ +-d @${ROLE} diff --git a/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_user.sh b/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_user.sh new file mode 100755 index 0000000000000..ab2a053081394 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/hunter/post_detections_user.sh @@ -0,0 +1,14 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +USER=(${@:-./detections_user.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + ${ELASTICSEARCH_URL}/_security/user/hunter \ +-d @${USER} diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/README.md b/x-pack/plugins/rule_registry/server/scripts/observer/README.md new file mode 100644 index 0000000000000..dc7e989ba4635 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/README.md @@ -0,0 +1,5 @@ +This user can access the monitoring route at http://localhost:5601/monitoring-myfakepath + +| Role | Data Sources | Security Solution ML Jobs/Results | Lists | Rules/Exceptions | Action Connectors | Signals/Alerts | +| :------: | :----------: | :-------------------------------: | :---: | :--------------: | :---------------: | :------------: | +| observer | read, write | read | read | read, write | read | read, write | \ No newline at end of file diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/delete_detections_user.sh b/x-pack/plugins/rule_registry/server/scripts/observer/delete_detections_user.sh new file mode 100755 index 0000000000000..017d8904a51e1 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/delete_detections_user.sh @@ -0,0 +1,11 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XDELETE ${ELASTICSEARCH_URL}/_security/user/observer diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/detections_role.json b/x-pack/plugins/rule_registry/server/scripts/observer/detections_role.json new file mode 100644 index 0000000000000..dd3d3f96e3a33 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/detections_role.json @@ -0,0 +1,20 @@ +{ + "elasticsearch": { + "cluster": [], + "indices": [] + }, + "kibana": [ + { + "feature": { + "ml": ["read"], + "monitoring": ["all"], + "apm": ["minimal_read", "alerts_all"], + "ruleRegistry": ["all"], + "actions": ["read"], + "builtInAlerts": ["all"], + "alerting": ["all"] + }, + "spaces": ["*"] + } + ] +} diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/detections_user.json b/x-pack/plugins/rule_registry/server/scripts/observer/detections_user.json new file mode 100644 index 0000000000000..9f06e7dcc29f1 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/detections_user.json @@ -0,0 +1,6 @@ +{ + "password": "changeme", + "roles": ["observer"], + "full_name": "Observer", + "email": "monitoring-observer@example.com" +} \ No newline at end of file diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/get_detections_role.sh b/x-pack/plugins/rule_registry/server/scripts/observer/get_detections_role.sh new file mode 100755 index 0000000000000..7ec850ce220bb --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/get_detections_role.sh @@ -0,0 +1,11 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XGET ${KIBANA_URL}/api/security/role/hunter | jq -S . diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/get_observability_alert.sh b/x-pack/plugins/rule_registry/server/scripts/observer/get_observability_alert.sh new file mode 100755 index 0000000000000..dd71e9dc6af43 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/get_observability_alert.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +set -e + +USER=${1:-'observer'} + +cd ./hunter && sh ./post_detections_role.sh && sh ./post_detections_user.sh +cd ../observer && sh ./post_detections_role.sh && sh ./post_detections_user.sh +cd .. + +# Example: ./find_rules.sh +curl -s -k \ + -u $USER:changeme \ + -X GET ${KIBANA_URL}${SPACE_URL}/monitoring-myfakepath | jq . diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/get_security_solution_alert.sh b/x-pack/plugins/rule_registry/server/scripts/observer/get_security_solution_alert.sh new file mode 100755 index 0000000000000..b4348266c9634 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/get_security_solution_alert.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +set -e + +cd ./hunter && sh ./post_detections_role.sh && sh ./post_detections_user.sh +cd ../observer && sh ./post_detections_role.sh && sh ./post_detections_user.sh +cd .. + + +USER=${1:-'hunter'} + +# Example: ./find_rules.sh +curl -s -k \ + -u $USER:changeme \ + -X GET ${KIBANA_URL}${SPACE_URL}/security-myfakepath | jq . diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/index.ts b/x-pack/plugins/rule_registry/server/scripts/observer/index.ts new file mode 100644 index 0000000000000..5feebc1caeed1 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as observerUser from './detections_user.json'; +import * as observerRole from './detections_role.json'; +export { observerUser, observerRole }; diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_role.sh b/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_role.sh new file mode 100755 index 0000000000000..4dddb64befc6b --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_role.sh @@ -0,0 +1,14 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +ROLE=(${@:-./detections_role.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ +-XPUT ${KIBANA_URL}/api/security/role/observer \ +-d @${ROLE} \ No newline at end of file diff --git a/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_user.sh b/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_user.sh new file mode 100755 index 0000000000000..8a897c0d28142 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/observer/post_detections_user.sh @@ -0,0 +1,14 @@ + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +USER=(${@:-./detections_user.json}) + +curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + ${ELASTICSEARCH_URL}/_security/user/observer \ +-d @${USER} \ No newline at end of file diff --git a/x-pack/plugins/rule_registry/server/scripts/update_observability_alert.sh b/x-pack/plugins/rule_registry/server/scripts/update_observability_alert.sh new file mode 100755 index 0000000000000..f61fcf2662aa3 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/scripts/update_observability_alert.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License +# 2.0; you may not use this file except in compliance with the Elastic License +# 2.0. +# + +set -e + +IDS=${1} +STATUS=${2} + +echo $IDS +echo "'"$STATUS"'" + +cd ./hunter && sh ./post_detections_role.sh && sh ./post_detections_user.sh +cd ../observer && sh ./post_detections_role.sh && sh ./post_detections_user.sh +cd .. + +# Example: ./update_observability_alert.sh [\"my-alert-id\",\"another-alert-id\"] +curl -s -k \ + -H 'Content-Type: application/json' \ + -H 'kbn-xsrf: 123' \ + -u observer:changeme \ + -X POST ${KIBANA_URL}${SPACE_URL}/internal/rac/alerts \ + -d "{\"ids\": $IDS, \"status\":\"$STATUS\", \"index\":\".alerts-observability-apm\"}" | jq . diff --git a/x-pack/plugins/rule_registry/server/types.ts b/x-pack/plugins/rule_registry/server/types.ts index 959c05fd1334e..f8bd1940b10a8 100644 --- a/x-pack/plugins/rule_registry/server/types.ts +++ b/x-pack/plugins/rule_registry/server/types.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { RequestHandlerContext } from 'kibana/server'; import { AlertInstanceContext, AlertInstanceState, @@ -12,6 +13,7 @@ import { AlertTypeState, } from '../../alerting/common'; import { AlertType } from '../../alerting/server'; +import { AlertsClient } from './alert_data_client/alerts_client'; type SimpleAlertType< TParams extends AlertTypeParams = {}, @@ -38,3 +40,17 @@ export type AlertTypeWithExecutor< > & { executor: AlertTypeExecutor; }; + +/** + * @public + */ +export interface RacApiRequestHandlerContext { + getAlertsClient: () => Promise; +} + +/** + * @internal + */ +export interface RacRequestHandlerContext extends RequestHandlerContext { + rac: RacApiRequestHandlerContext; +} diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts index 38ddbd3f1876b..a37ba9ef56636 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts @@ -194,6 +194,7 @@ describe('createLifecycleRuleTypeFactory', () => { "event.kind": "event", "kibana.rac.alert.duration.us": 0, "kibana.rac.alert.id": "opbeans-java", + "kibana.rac.alert.owner": "consumer", "kibana.rac.alert.producer": "test", "kibana.rac.alert.start": "2021-06-16T09:01:00.000Z", "kibana.rac.alert.status": "open", @@ -212,6 +213,7 @@ describe('createLifecycleRuleTypeFactory', () => { "event.kind": "event", "kibana.rac.alert.duration.us": 0, "kibana.rac.alert.id": "opbeans-node", + "kibana.rac.alert.owner": "consumer", "kibana.rac.alert.producer": "test", "kibana.rac.alert.start": "2021-06-16T09:01:00.000Z", "kibana.rac.alert.status": "open", @@ -230,6 +232,7 @@ describe('createLifecycleRuleTypeFactory', () => { "event.kind": "signal", "kibana.rac.alert.duration.us": 0, "kibana.rac.alert.id": "opbeans-java", + "kibana.rac.alert.owner": "consumer", "kibana.rac.alert.producer": "test", "kibana.rac.alert.start": "2021-06-16T09:01:00.000Z", "kibana.rac.alert.status": "open", @@ -248,6 +251,7 @@ describe('createLifecycleRuleTypeFactory', () => { "event.kind": "signal", "kibana.rac.alert.duration.us": 0, "kibana.rac.alert.id": "opbeans-node", + "kibana.rac.alert.owner": "consumer", "kibana.rac.alert.producer": "test", "kibana.rac.alert.start": "2021-06-16T09:01:00.000Z", "kibana.rac.alert.status": "open", diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts index 005af59892b8a..34045a2a905f8 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts @@ -25,6 +25,7 @@ import { ALERT_UUID, EVENT_ACTION, EVENT_KIND, + OWNER, RULE_UUID, TIMESTAMP, } from '../../common/technical_rule_data_field_names'; @@ -69,6 +70,7 @@ export const createLifecycleRuleTypeFactory: CreateLifecycleRuleTypeFactory = ({ const { services: { alertInstanceFactory }, state: previousState, + rule, } = options; const ruleExecutorData = getRuleExecutorData(type, options); @@ -180,6 +182,7 @@ export const createLifecycleRuleTypeFactory: CreateLifecycleRuleTypeFactory = ({ ...ruleExecutorData, [TIMESTAMP]: timestamp, [EVENT_KIND]: 'event', + [OWNER]: rule.consumer, [ALERT_ID]: alertId, }; @@ -234,6 +237,7 @@ export const createLifecycleRuleTypeFactory: CreateLifecycleRuleTypeFactory = ({ [EVENT_KIND]: 'signal', }); } + logger.debug(`Preparing to index ${eventsToIndex.length} alerts.`); if (ruleDataClient.isWriteEnabled()) { await ruleDataClient.getWriter().bulk({ diff --git a/x-pack/plugins/rule_registry/server/utils/rbac.ts b/x-pack/plugins/rule_registry/server/utils/rbac.ts new file mode 100644 index 0000000000000..812dbb8408812 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/utils/rbac.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * registering a new instance of the rule data client + * in a new plugin will require updating the below data structure + * to include the index name where the alerts as data will be written to. + */ +export const mapConsumerToIndexName = { + apm: '.alerts-observability-apm', + observability: '.alerts-observability', + siem: ['.alerts-security.alerts', '.siem-signals'], +}; +export type ValidFeatureId = keyof typeof mapConsumerToIndexName; + +export const validFeatureIds = Object.keys(mapConsumerToIndexName); +export const isValidFeatureId = (a: unknown): a is ValidFeatureId => + typeof a === 'string' && validFeatureIds.includes(a); diff --git a/x-pack/plugins/rule_registry/tsconfig.json b/x-pack/plugins/rule_registry/tsconfig.json index 5aefe9769da22..f6253e441da31 100644 --- a/x-pack/plugins/rule_registry/tsconfig.json +++ b/x-pack/plugins/rule_registry/tsconfig.json @@ -7,11 +7,19 @@ "declaration": true, "declarationMap": true }, - "include": ["common/**/*", "server/**/*", "public/**/*", "../../../typings/**/*"], + "include": [ + "common/**/*", + "server/**/*", + // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 + "server/**/*.json", + "public/**/*", + "../../../typings/**/*" + ], "references": [ { "path": "../../../src/core/tsconfig.json" }, { "path": "../../../src/plugins/data/tsconfig.json" }, { "path": "../alerting/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, { "path": "../spaces/tsconfig.json" }, { "path": "../triggers_actions_ui/tsconfig.json" } ] diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts index 5f9175476795c..218b1f7745d94 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts @@ -48,6 +48,12 @@ import { SEVERITY_DROPDOWN, TAGS_CLEAR_BUTTON, TAGS_FIELD, + EMAIL_ACTION_BTN, + CREATE_ACTION_CONNECTOR_BTN, + SAVE_ACTION_CONNECTOR_BTN, + FROM_VALIDATION_ERROR, + EMAIL_ACTION_TO_INPUT, + EMAIL_ACTION_SUBJECT_INPUT, } from '../../screens/create_new_rule'; import { ADDITIONAL_LOOK_BACK_DETAILS, @@ -99,6 +105,7 @@ import { fillAboutRule, fillAboutRuleAndContinue, fillDefineCustomRuleWithImportedQueryAndContinue, + fillEmailConnectorForm, fillScheduleRuleAndContinue, goToAboutStepTab, goToActionsStepTab, @@ -360,6 +367,17 @@ describe('Custom detection rules deletion and edition', () => { cy.get(ACTIONS_THROTTLE_INPUT).invoke('val').should('eql', 'no_actions'); + cy.get(ACTIONS_THROTTLE_INPUT).select('Weekly'); + cy.get(EMAIL_ACTION_BTN).click(); + cy.get(CREATE_ACTION_CONNECTOR_BTN).click(); + fillEmailConnectorForm(); + cy.get(SAVE_ACTION_CONNECTOR_BTN).click(); + + cy.get(EMAIL_ACTION_TO_INPUT).type('test@example.com'); + cy.get(EMAIL_ACTION_SUBJECT_INPUT).type('Subject'); + + cy.get(FROM_VALIDATION_ERROR).should('not.exist'); + goToAboutStepTab(); cy.get(TAGS_CLEAR_BUTTON).click({ force: true }); fillAboutRule(editedRule); diff --git a/x-pack/plugins/security_solution/cypress/objects/connector.ts b/x-pack/plugins/security_solution/cypress/objects/connector.ts new file mode 100644 index 0000000000000..2a0f1cc43eff0 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/objects/connector.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface EmailConnector { + name: string; + from: string; + host: string; + port: string; + user: string; + password: string; +} + +export const emailConnector: EmailConnector = { + name: 'Test connector', + from: 'test@example.com', + host: 'example.com', + port: '80', + user: 'username', + password: 'password', +}; diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts index a580068b636e4..551857ca3bfca 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts @@ -16,6 +16,30 @@ export const ACTIONS_EDIT_TAB = '[data-test-subj="edit-rule-actions-tab"]'; export const ACTIONS_THROTTLE_INPUT = '[data-test-subj="stepRuleActions"] [data-test-subj="select"]'; +export const EMAIL_ACTION_BTN = '[data-test-subj=".email-ActionTypeSelectOption"]'; + +export const CREATE_ACTION_CONNECTOR_BTN = '[data-test-subj="createActionConnectorButton-0"]'; + +export const SAVE_ACTION_CONNECTOR_BTN = '[data-test-subj="saveActionButtonModal"]'; + +export const EMAIL_ACTION_TO_INPUT = '[data-test-subj="toEmailAddressInput"]'; + +export const EMAIL_ACTION_SUBJECT_INPUT = '[data-test-subj="subjectInput"]'; + +export const FROM_VALIDATION_ERROR = '.euiFormErrorText'; + +export const CONNECTOR_NAME_INPUT = '[data-test-subj="nameInput"]'; + +export const EMAIL_CONNECTOR_FROM_INPUT = '[data-test-subj="emailFromInput"]'; + +export const EMAIL_CONNECTOR_HOST_INPUT = '[data-test-subj="emailHostInput"]'; + +export const EMAIL_CONNECTOR_PORT_INPUT = '[data-test-subj="emailPortInput"]'; + +export const EMAIL_CONNECTOR_USER_INPUT = '[data-test-subj="emailUserInput"]'; + +export const EMAIL_CONNECTOR_PASSWORD_INPUT = '[data-test-subj="emailPasswordInput"]'; + export const ADD_FALSE_POSITIVE_BTN = '[data-test-subj="detectionEngineStepAboutRuleFalsePositives"] .euiButtonEmpty__text'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index 9c15b1f03932d..9b74110f0ef77 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { emailConnector, EmailConnector } from '../objects/connector'; import { CustomRule, MachineLearningRule, @@ -85,6 +86,12 @@ import { THRESHOLD_FIELD_SELECTION, THRESHOLD_INPUT_AREA, THRESHOLD_TYPE, + CONNECTOR_NAME_INPUT, + EMAIL_CONNECTOR_FROM_INPUT, + EMAIL_CONNECTOR_HOST_INPUT, + EMAIL_CONNECTOR_PORT_INPUT, + EMAIL_CONNECTOR_USER_INPUT, + EMAIL_CONNECTOR_PASSWORD_INPUT, } from '../screens/create_new_rule'; import { TOAST_ERROR } from '../screens/shared'; import { SERVER_SIDE_EVENT_COUNT } from '../screens/timeline'; @@ -390,6 +397,15 @@ export const fillIndexAndIndicatorIndexPattern = ( getIndicatorIndicatorIndex().type(`${indicatorIndex}{enter}`); }; +export const fillEmailConnectorForm = (connector: EmailConnector = emailConnector) => { + cy.get(CONNECTOR_NAME_INPUT).type(connector.name); + cy.get(EMAIL_CONNECTOR_FROM_INPUT).type(connector.from); + cy.get(EMAIL_CONNECTOR_HOST_INPUT).type(connector.host); + cy.get(EMAIL_CONNECTOR_PORT_INPUT).type(connector.port); + cy.get(EMAIL_CONNECTOR_USER_INPUT).type(connector.user); + cy.get(EMAIL_CONNECTOR_PASSWORD_INPUT).type(connector.password); +}; + /** Returns the indicator index drop down field. Pass in row number, default is 1 */ export const getIndicatorIndexComboField = (row = 1) => cy.get(THREAT_COMBO_BOX_INPUT).eq(row * 2 - 2); diff --git a/x-pack/plugins/security_solution/public/app/app.tsx b/x-pack/plugins/security_solution/public/app/app.tsx index c223570c77201..0cba9341cbce1 100644 --- a/x-pack/plugins/security_solution/public/app/app.tsx +++ b/x-pack/plugins/security_solution/public/app/app.tsx @@ -24,7 +24,7 @@ import { State } from '../common/store'; import { StartServices } from '../types'; import { PageRouter } from './routes'; import { EuiThemeProvider } from '../../../../../src/plugins/kibana_react/common'; -import { UserPrivilegesProvider } from '../detections/components/user_privileges'; +import { UserPrivilegesProvider } from '../common/components/user_privileges'; interface StartAppComponent { children: React.ReactNode; diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index e1c14f2a86380..f5cec592c7abf 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -165,7 +165,7 @@ const nestedDeepLinks: SecurityDeepLinks = { navLinkStatus: AppNavLinkStatus.hidden, keywords: [ i18n.translate('xpack.securitySolution.search.exceptions', { - defaultMessage: 'Exception list', + defaultMessage: 'Exceptions', }), ], searchable: true, diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index 847a9114d94bd..027789713a2ae 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -24,7 +24,7 @@ export const RULES = i18n.translate('xpack.securitySolution.navigation.rules', { }); export const EXCEPTIONS = i18n.translate('xpack.securitySolution.navigation.exceptions', { - defaultMessage: 'Exception list', + defaultMessage: 'Exceptions', }); export const ALERTS = i18n.translate('xpack.securitySolution.navigation.alerts', { diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx index e3549aa6ec047..af88aacb7602a 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx @@ -141,7 +141,7 @@ describe('useSecuritySolutionNavigation', () => { "href": "securitySolution/exceptions?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", "id": "exceptions", "isSelected": false, - "name": "Exception list", + "name": "Exceptions", "onClick": [Function], }, ], diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/__mocks__/use_endpoint_privileges.ts b/x-pack/plugins/security_solution/public/common/components/user_privileges/__mocks__/use_endpoint_privileges.ts new file mode 100644 index 0000000000000..80cf11fecd847 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/__mocks__/use_endpoint_privileges.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EndpointPrivileges } from '../use_endpoint_privileges'; + +export const useEndpointPrivileges = jest.fn(() => { + const endpointPrivilegesMock: EndpointPrivileges = { + loading: false, + canAccessFleet: true, + canAccessEndpointManagement: true, + }; + return endpointPrivilegesMock; +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/user_privileges/index.tsx b/x-pack/plugins/security_solution/public/common/components/user_privileges/index.tsx similarity index 59% rename from x-pack/plugins/security_solution/public/detections/components/user_privileges/index.tsx rename to x-pack/plugins/security_solution/public/common/components/user_privileges/index.tsx index bd6ff11b27f96..5a33297f04f9a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/user_privileges/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/index.tsx @@ -6,19 +6,25 @@ */ import React, { createContext, useContext } from 'react'; -import { useFetchDetectionEnginePrivileges } from './use_fetch_detection_engine_privileges'; -import { useFetchListPrivileges } from './use_fetch_list_privileges'; +import { DeepReadonly } from 'utility-types'; +import { useFetchDetectionEnginePrivileges } from '../../../detections/components/user_privileges/use_fetch_detection_engine_privileges'; +import { useFetchListPrivileges } from '../../../detections/components/user_privileges/use_fetch_list_privileges'; +import { EndpointPrivileges, useEndpointPrivileges } from './use_endpoint_privileges'; export interface UserPrivilegesState { listPrivileges: ReturnType; detectionEnginePrivileges: ReturnType; + endpointPrivileges: EndpointPrivileges; } -const UserPrivilegesContext = createContext({ +export const initialUserPrivilegesState = (): UserPrivilegesState => ({ listPrivileges: { loading: false, error: undefined, result: undefined }, detectionEnginePrivileges: { loading: false, error: undefined, result: undefined }, + endpointPrivileges: { loading: true, canAccessEndpointManagement: false, canAccessFleet: false }, }); +const UserPrivilegesContext = createContext(initialUserPrivilegesState()); + interface UserPrivilegesProviderProps { children: React.ReactNode; } @@ -26,12 +32,14 @@ interface UserPrivilegesProviderProps { export const UserPrivilegesProvider = ({ children }: UserPrivilegesProviderProps) => { const listPrivileges = useFetchListPrivileges(); const detectionEnginePrivileges = useFetchDetectionEnginePrivileges(); + const endpointPrivileges = useEndpointPrivileges(); return ( {children} @@ -39,4 +47,5 @@ export const UserPrivilegesProvider = ({ children }: UserPrivilegesProviderProps ); }; -export const useUserPrivileges = () => useContext(UserPrivilegesContext); +export const useUserPrivileges = (): DeepReadonly => + useContext(UserPrivilegesContext); diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.test.ts b/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.test.ts new file mode 100644 index 0000000000000..8e9dae9f12ad5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, RenderHookResult, RenderResult } from '@testing-library/react-hooks'; +import { useHttp, useCurrentUser } from '../../lib/kibana'; +import { EndpointPrivileges, useEndpointPrivileges } from './use_endpoint_privileges'; +import { fleetGetCheckPermissionsHttpMock } from '../../../management/pages/endpoint_hosts/mocks'; +import { securityMock } from '../../../../../security/public/mocks'; +import { appRoutesService } from '../../../../../fleet/common'; +import { AuthenticatedUser } from '../../../../../security/common'; + +jest.mock('../../lib/kibana'); + +describe('When using useEndpointPrivileges hook', () => { + let authenticatedUser: AuthenticatedUser; + let fleetApiMock: ReturnType; + let result: RenderResult; + let unmount: ReturnType['unmount']; + let waitForNextUpdate: ReturnType['waitForNextUpdate']; + let render: () => RenderHookResult; + + beforeEach(() => { + authenticatedUser = securityMock.createMockAuthenticatedUser({ + roles: ['superuser'], + }); + + (useCurrentUser as jest.Mock).mockReturnValue(authenticatedUser); + + fleetApiMock = fleetGetCheckPermissionsHttpMock( + useHttp() as Parameters[0] + ); + + render = () => { + const hookRenderResponse = renderHook(() => useEndpointPrivileges()); + ({ result, unmount, waitForNextUpdate } = hookRenderResponse); + return hookRenderResponse; + }; + }); + + afterEach(() => { + unmount(); + }); + + it('should return `loading: true` while retrieving privileges', async () => { + // Add a daly to the API response that we can control from the test + let releaseApiResponse: () => void; + fleetApiMock.responseProvider.checkPermissions.mockDelay.mockReturnValue( + new Promise((resolve) => { + releaseApiResponse = () => resolve(); + }) + ); + (useCurrentUser as jest.Mock).mockReturnValue(null); + + const { rerender } = render(); + expect(result.current).toEqual({ + canAccessEndpointManagement: false, + canAccessFleet: false, + loading: true, + }); + + // Make user service available + (useCurrentUser as jest.Mock).mockReturnValue(authenticatedUser); + rerender(); + expect(result.current).toEqual({ + canAccessEndpointManagement: false, + canAccessFleet: false, + loading: true, + }); + + // Release the API response + releaseApiResponse!(); + await fleetApiMock.waitForApi(); + expect(result.current).toEqual({ + canAccessEndpointManagement: true, + canAccessFleet: true, + loading: false, + }); + }); + + it('should call Fleet permissions api to determine user privilege to fleet', async () => { + render(); + await waitForNextUpdate(); + await fleetApiMock.waitForApi(); + expect(useHttp().get as jest.Mock).toHaveBeenCalledWith( + appRoutesService.getCheckPermissionsPath() + ); + }); + + it('should set privileges to false if user does not have superuser role', async () => { + authenticatedUser.roles = []; + render(); + await waitForNextUpdate(); + await fleetApiMock.waitForApi(); + expect(result.current).toEqual({ + canAccessEndpointManagement: false, + canAccessFleet: true, // this is only true here because I did not adjust the API mock + loading: false, + }); + }); + + it('should set privileges to false if fleet api check returns failure', async () => { + fleetApiMock.responseProvider.checkPermissions.mockReturnValue({ + error: 'MISSING_SECURITY', + success: false, + }); + + render(); + await waitForNextUpdate(); + await fleetApiMock.waitForApi(); + expect(result.current).toEqual({ + canAccessEndpointManagement: false, + canAccessFleet: false, + loading: false, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.ts b/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.ts new file mode 100644 index 0000000000000..b8db0c5c0fbc9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCurrentUser, useHttp } from '../../lib/kibana'; +import { appRoutesService, CheckPermissionsResponse } from '../../../../../fleet/common'; + +export interface EndpointPrivileges { + loading: boolean; + /** If user has permissions to access Fleet */ + canAccessFleet: boolean; + /** If user has permissions to access Endpoint management (includes check to ensure they also have access to fleet) */ + canAccessEndpointManagement: boolean; +} + +/** + * Retrieve the endpoint privileges for the current user. + * + * **NOTE:** Consider using `usePrivileges().endpointPrivileges` instead of this hook in order + * to keep API calls to a minimum. + */ +export const useEndpointPrivileges = (): EndpointPrivileges => { + const http = useHttp(); + const user = useCurrentUser(); + const isMounted = useRef(true); + const [canAccessFleet, setCanAccessFleet] = useState(false); + const [fleetCheckDone, setFleetCheckDone] = useState(false); + + // Check if user can access fleet + useEffect(() => { + (async () => { + try { + const fleetPermissionsResponse = await http.get( + appRoutesService.getCheckPermissionsPath() + ); + + if (isMounted.current) { + setCanAccessFleet(fleetPermissionsResponse.success); + } + } finally { + if (isMounted.current) { + setFleetCheckDone(true); + } + } + })(); + }, [http]); + + // Check if user has `superuser` role + const isSuperUser = useMemo(() => { + if (user?.roles) { + return user.roles.includes('superuser'); + } + return false; + }, [user?.roles]); + + const privileges = useMemo(() => { + return { + loading: !fleetCheckDone || !user, + canAccessFleet, + canAccessEndpointManagement: canAccessFleet && isSuperUser, + }; + }, [canAccessFleet, fleetCheckDone, isSuperUser, user]); + + // Capture if component is unmounted + useEffect( + () => () => { + isMounted.current = false; + }, + [] + ); + + return privileges; +}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/endpoint/ingest_enabled.ts b/x-pack/plugins/security_solution/public/common/hooks/endpoint/ingest_enabled.ts deleted file mode 100644 index 18582e7064a7b..0000000000000 --- a/x-pack/plugins/security_solution/public/common/hooks/endpoint/ingest_enabled.ts +++ /dev/null @@ -1,35 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ApplicationStart } from 'src/core/public'; -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -/** - * Returns an object which fleet permissions are allowed - */ -export const useIngestEnabledCheck = (): { - allEnabled: boolean; - show: boolean; - write: boolean; - read: boolean; -} => { - const { services } = useKibana<{ application: ApplicationStart }>(); - - // Check if Fleet is present in the configuration - const show = Boolean(services.application.capabilities.fleet?.show); - const write = Boolean(services.application.capabilities.fleet?.write); - const read = Boolean(services.application.capabilities.fleet?.read); - - // Check if all Fleet permissions are enabled - const allEnabled = show && read && write ? true : false; - - return { - allEnabled, - show, - write, - read, - }; -}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_upgrade_security_packages.ts b/x-pack/plugins/security_solution/public/common/hooks/use_upgrade_security_packages.ts index 6a3afccd8794d..ef1e658d349bf 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_upgrade_security_packages.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_upgrade_security_packages.ts @@ -8,14 +8,9 @@ import { useEffect } from 'react'; import { HttpFetchOptions, HttpStart } from 'kibana/public'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { - epmRouteService, - appRoutesService, - CheckPermissionsResponse, - BulkInstallPackagesResponse, -} from '../../../../fleet/common'; +import { epmRouteService, BulkInstallPackagesResponse } from '../../../../fleet/common'; import { StartServices } from '../../types'; -import { useIngestEnabledCheck } from './endpoint/ingest_enabled'; +import { useUserPrivileges } from '../components/user_privileges'; /** * Requests that the endpoint and security_detection_engine package be upgraded to the latest version @@ -35,25 +30,9 @@ const sendUpgradeSecurityPackages = async ( }); }; -/** - * Checks with the ingest manager if the current user making these requests has the right permissions - * to install the endpoint package. - * - * @param http an http client for sending the request - * @param options an object containing options for the request - */ -const sendCheckPermissions = async ( - http: HttpStart, - options: HttpFetchOptions = {} -): Promise => { - return http.get(appRoutesService.getCheckPermissionsPath(), { - ...options, - }); -}; - export const useUpgradeSecurityPackages = () => { const context = useKibana(); - const { allEnabled: ingestEnabled } = useIngestEnabledCheck(); + const canAccessFleet = useUserPrivileges().endpointPrivileges.canAccessFleet; useEffect(() => { const abortController = new AbortController(); @@ -63,21 +42,11 @@ export const useUpgradeSecurityPackages = () => { abortController.abort(); }; - if (ingestEnabled) { + if (canAccessFleet) { const signal = abortController.signal; (async () => { try { - // make sure we're a privileged user before trying to install the package - const { success: hasPermissions } = await sendCheckPermissions(context.services.http, { - signal, - }); - - // if we're not a privileged user then return and don't try to check the status of the endpoint package - if (!hasPermissions) { - return abortRequests; - } - // ignore the response for now since we aren't notifying the user await sendUpgradeSecurityPackages(context.services.http, { signal }); } catch (error) { @@ -93,5 +62,5 @@ export const useUpgradeSecurityPackages = () => { return abortRequests; })(); } - }, [ingestEnabled, context.services.http]); + }, [canAccessFleet, context.services.http]); }; diff --git a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx index 9ac7ae0f24322..d0755d05bdb5f 100644 --- a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx @@ -24,7 +24,7 @@ import { import { FieldHook } from '../../shared_imports'; import { SUB_PLUGINS_REDUCER } from './utils'; import { createSecuritySolutionStorageMock, localStorageMock } from './mock_local_storage'; -import { UserPrivilegesProvider } from '../../detections/components/user_privileges'; +import { UserPrivilegesProvider } from '../components/user_privileges'; const state: State = mockGlobalState; diff --git a/x-pack/plugins/security_solution/public/detections/components/callouts/missing_privileges_callout/use_missing_privileges.ts b/x-pack/plugins/security_solution/public/detections/components/callouts/missing_privileges_callout/use_missing_privileges.ts index dd139421e9ddd..73aa922251ee6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/callouts/missing_privileges_callout/use_missing_privileges.ts +++ b/x-pack/plugins/security_solution/public/detections/components/callouts/missing_privileges_callout/use_missing_privileges.ts @@ -9,7 +9,7 @@ import { useMemo } from 'react'; import { SAVED_OBJECTS_MANAGEMENT_FEATURE_ID } from '../../../../../common/constants'; import { Privilege } from '../../../containers/detection_engine/alerts/types'; import { useUserData } from '../../user_info'; -import { useUserPrivileges } from '../../user_privileges'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; const REQUIRED_INDEX_PRIVILIGES = ['read', 'write', 'view_index_metadata', 'maintenance'] as const; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx index 9ecf3333279eb..2206960f6bcd3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx @@ -122,7 +122,13 @@ export const RuleActionsField: React.FC = ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (key: string, value: any, index: number) => { const updatedActions = [...actions]; - updatedActions[index].params[key] = value; + updatedActions[index] = { + ...updatedActions[index], + params: { + ...updatedActions[index].params, + [key]: value, + }, + }; field.setValue(updatedActions); }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/security_solution/public/detections/components/user_info/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/user_info/index.test.tsx index 83ca0026b8934..bb9ec01399f8d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/user_info/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/user_info/index.test.tsx @@ -12,10 +12,11 @@ import { useUserInfo, ManageUserInfo } from './index'; import { useKibana } from '../../../common/lib/kibana'; import * as api from '../../containers/detection_engine/alerts/api'; import { TestProviders } from '../../../common/mock/test_providers'; -import { UserPrivilegesProvider } from '../user_privileges'; +import { UserPrivilegesProvider } from '../../../common/components/user_privileges'; jest.mock('../../../common/lib/kibana'); jest.mock('../../containers/detection_engine/alerts/api'); +jest.mock('../../../common/components/user_privileges/use_endpoint_privileges'); describe('useUserInfo', () => { beforeAll(() => { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx index 651d80f3165ab..f3afe83365286 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx @@ -9,13 +9,13 @@ import { act, renderHook } from '@testing-library/react-hooks'; import produce from 'immer'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; -import { useUserPrivileges } from '../../../components/user_privileges'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; import { Privilege } from './types'; import { UseAlertsPrivelegesReturn, useAlertsPrivileges } from './use_alerts_privileges'; jest.mock('./api'); jest.mock('../../../../common/hooks/use_app_toasts'); -jest.mock('../../../components/user_privileges'); +jest.mock('../../../../common/components/user_privileges'); const useUserPrivilegesMock = useUserPrivileges as jest.Mock>; @@ -86,6 +86,7 @@ const userPrivilegesInitial: ReturnType = { result: undefined, error: undefined, }, + endpointPrivileges: { loading: true, canAccessEndpointManagement: false, canAccessFleet: false }, }; describe('usePrivilegeUser', () => { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.tsx index 08e28521e1473..005224a80c189 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.tsx @@ -6,7 +6,7 @@ */ import { useEffect, useState } from 'react'; -import { useUserPrivileges } from '../../../components/user_privileges'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; export interface UseAlertsPrivelegesReturn extends AlertsPrivelegesState { loading: boolean; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx index ce262ce4f9a2e..ade83fed4fd6b 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx @@ -13,6 +13,7 @@ import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; jest.mock('./api'); jest.mock('../../../../common/hooks/use_app_toasts'); +jest.mock('../../../../common/components/user_privileges/use_endpoint_privileges'); describe('useSignalIndex', () => { let appToastsMock: jest.Mocked>; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.tsx index 0b7cd673c49f4..5f21f0287d7ea 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.tsx @@ -6,7 +6,7 @@ */ import { useEffect, useState } from 'react'; -import { useUserPrivileges } from '../../../components/user_privileges'; +import { useUserPrivileges } from '../../../../common/components/user_privileges'; import { Privilege } from '../alerts/types'; export interface UseListsPrivilegesState { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx index b4f5efe2348bb..206976e6c0c1a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx @@ -336,7 +336,8 @@ export const ExceptionListsTable = React.memo(() => { <> <> diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/translations.ts index 0dd016425f4e6..912f5bec4de35 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/translations.ts @@ -73,7 +73,14 @@ export const EXCEPTIONS_LISTS_SEARCH_PLACEHOLDER = i18n.translate( export const ALL_EXCEPTIONS = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allExceptions.tableTitle', { - defaultMessage: 'Exception Lists', + defaultMessage: 'Exceptions', + } +); + +export const ALL_EXCEPTIONS_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allExceptions.tableTitleDescription', + { + defaultMessage: 'Exceptions are automatically grouped into exception lists.', } ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/translations.ts index 5a49ab349c094..f4292f6c663cc 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/translations.ts @@ -15,9 +15,9 @@ export const PAGE_TITLE = i18n.translate( ); export const BACK_TO_RULES = i18n.translate( - 'xpack.securitySolution.detectionEngine.createRule.backToRulesDescription', + 'xpack.securitySolution.detectionEngine.createRule.backToRulesButton', { - defaultMessage: 'Back to detection rules', + defaultMessage: 'Rules', } ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/translations.ts index e42fde569bd67..ca3e5a4587a09 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/translations.ts @@ -15,9 +15,9 @@ export const PAGE_TITLE = i18n.translate( ); export const BACK_TO_RULES = i18n.translate( - 'xpack.securitySolution.detectionEngine.ruleDetails.backToRulesDescription', + 'xpack.securitySolution.detectionEngine.ruleDetails.backToRulesButton', { - defaultMessage: 'Back to detection rules', + defaultMessage: 'Rules', } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts index 5498d139fd6e1..d6b24fa3cbdfc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts @@ -25,6 +25,8 @@ import { } from '../../../../common/endpoint/constants'; import { AGENT_POLICY_API_ROUTES, + appRoutesService, + CheckPermissionsResponse, EPM_API_ROUTES, GetAgentPoliciesResponse, GetPackagesResponse, @@ -122,37 +124,66 @@ export const fleetGetPackageListHttpMock = httpHandlerMockFactory GetAgentPoliciesResponse; }>; -export const fleetGetAgentPolicyListHttpMock = httpHandlerMockFactory([ - { - id: 'agentPolicy', - path: AGENT_POLICY_API_ROUTES.LIST_PATTERN, - method: 'get', - handler: () => { - const generator = new EndpointDocGenerator('seed'); - const endpointMetadata = generator.generateHostMetadata(); - const agentPolicy = generator.generateAgentPolicy(); - - // Make sure that the Agent policy returned from the API has the Integration Policy ID that - // the endpoint metadata is using. This is needed especially when testing the Endpoint Details - // flyout where certain actions might be disabled if we know the endpoint integration policy no - // longer exists. - (agentPolicy.package_policies as string[]).push(endpointMetadata.Endpoint.policy.applied.id); - - return { - items: [agentPolicy], - perPage: 10, - total: 1, - page: 1, - }; +export const fleetGetAgentPolicyListHttpMock = httpHandlerMockFactory( + [ + { + id: 'agentPolicy', + path: AGENT_POLICY_API_ROUTES.LIST_PATTERN, + method: 'get', + handler: () => { + const generator = new EndpointDocGenerator('seed'); + const endpointMetadata = generator.generateHostMetadata(); + const agentPolicy = generator.generateAgentPolicy(); + + // Make sure that the Agent policy returned from the API has the Integration Policy ID that + // the endpoint metadata is using. This is needed especially when testing the Endpoint Details + // flyout where certain actions might be disabled if we know the endpoint integration policy no + // longer exists. + (agentPolicy.package_policies as string[]).push( + endpointMetadata.Endpoint.policy.applied.id + ); + + return { + items: [agentPolicy], + perPage: 10, + total: 1, + page: 1, + }; + }, }, - }, -]); + ] +); + +export type FleetGetCheckPermissionsInterface = ResponseProvidersInterface<{ + checkPermissions: () => CheckPermissionsResponse; +}>; + +export const fleetGetCheckPermissionsHttpMock = httpHandlerMockFactory( + [ + { + id: 'checkPermissions', + path: appRoutesService.getCheckPermissionsPath(), + method: 'get', + handler: () => { + return { + error: undefined, + success: true, + }; + }, + }, + ] +); type FleetApisHttpMockInterface = FleetGetPackageListHttpMockInterface & - FleetGetAgentPolicyListHttpMockInterface; + FleetGetAgentPolicyListHttpMockInterface & + FleetGetCheckPermissionsInterface; +/** + * Mocks all Fleet apis needed to render the Endpoint List/Details pages + */ export const fleetApisHttpMock = composeHttpHandlerMocks([ fleetGetPackageListHttpMock, fleetGetAgentPolicyListHttpMock, + fleetGetCheckPermissionsHttpMock, ]); type EndpointPageHttpMockInterface = EndpointMetadataHttpMocksInterface & diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts index c45d0f88927be..fb2251072f03d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/test_utils/index.ts @@ -106,6 +106,107 @@ export type EventFiltersListQueryHttpMockProviders = ResponseProvidersInterface< eventFiltersCreateList: () => ExceptionListItemSchema; }>; +export const esResponseData = () => ({ + rawResponse: { + took: 0, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: 1, + max_score: 1, + hits: [ + { + _index: '.ds-logs-endpoint.events.process-default-2021.07.06-000001', + _id: 'ZihXfHoBP7UhLrksX9-B', + _score: 1, + _source: { + agent: { + id: '9b5fad11-6cd9-401b-afc1-1c2b0c8a2603', + type: 'endpoint', + version: '7.12.2', + }, + process: { + args: '"C:\\lsass.exe" \\d6e', + Ext: { + ancestry: ['wm6pfs8yo3', 'd0zpkp91jx'], + }, + parent: { + pid: 2356, + entity_id: 'wm6pfs8yo3', + }, + code_signature: { + subject_name: 'Microsoft', + status: 'trusted', + }, + name: 'lsass.exe', + pid: 2522, + entity_id: 'hmmlst1ewe', + executable: 'C:\\lsass.exe', + hash: { + md5: 'de8c03a1-099f-4d9b-9a5e-1961c18af19f', + }, + }, + network: { + forwarded_ip: '10.105.19.209', + direction: 'inbound', + }, + '@timestamp': 1625694621727, + ecs: { + version: '1.4.0', + }, + data_stream: { + namespace: 'default', + type: 'logs', + dataset: 'endpoint.events.process', + }, + host: { + hostname: 'Host-15ofk0qkwk', + os: { + Ext: { + variant: 'Windows Pro', + }, + name: 'Linux', + family: 'Debian OS', + version: '10.0', + platform: 'Windows', + full: 'Windows 10', + }, + ip: ['10.133.4.77', '10.135.101.75', '10.137.102.119'], + name: 'Host-15ofk0qkwk', + id: 'bae7a849-1ce9-421a-a879-5fee5dcd1fb9', + mac: ['ad-65-2d-17-aa-95', '63-4-33-c5-c6-90'], + architecture: 'uwp8xmxk1f', + }, + event: { + agent_id_status: 'auth_metadata_missing', + sequence: 36, + ingested: '2021-07-06T15:02:18.746828Z', + kind: 'event', + id: '02057ac0-0ae5-442c-9082-c5a7489dde09', + category: 'network', + type: 'start', + }, + user: { + domain: '22bk8yptgw', + name: 'dlkfiz43rh', + }, + }, + }, + ], + }, + }, + isPartial: false, + isRunning: false, + total: 1, + loaded: 1, + isRestored: false, +}); + /** * Mock `core.http` methods used by Event Filters List page */ diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx index 9f81d25520524..48523ce45c3f9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/flyout/index.tsx @@ -70,6 +70,15 @@ export const EventFiltersFlyout: React.FC = memo( payload: { entry: getInitialExceptionFromEvent() }, }); } + + return () => { + dispatch({ + type: 'eventFiltersFormStateChanged', + payload: { + type: 'UninitialisedResourceState', + }, + }); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/modal/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/modal/index.test.tsx index 178b774e91635..c77188694f507 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/modal/index.test.tsx @@ -6,18 +6,24 @@ */ import React from 'react'; import { EventFiltersModal } from '.'; -import { RenderResult, act, render } from '@testing-library/react'; +import { RenderResult, act } from '@testing-library/react'; import { fireEvent } from '@testing-library/dom'; -import { Provider } from 'react-redux'; -import { ThemeProvider } from 'styled-components'; -import { createGlobalNoMiddlewareStore, ecsEventMock } from '../../../test_utils'; -import { getMockTheme } from '../../../../../../common/lib/kibana/kibana_react.mock'; +import { ecsEventMock, esResponseData } from '../../../test_utils'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../../common/mock/endpoint'; +import { MiddlewareActionSpyHelper } from '../../../../../../common/store/test_utils'; + import { MODAL_TITLE, MODAL_SUBTITLE, ACTIONS_CONFIRM, ACTIONS_CANCEL } from './translations'; import type { CreateExceptionListItemSchema, ExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; +import { useKibana } from '../../../../../../common/lib/kibana'; +import { EventFiltersListPageState } from '../../../types'; +jest.mock('../../../../../../common/lib/kibana'); jest.mock('../form'); jest.mock('../../hooks', () => { const originalModule = jest.requireActual('../../hooks'); @@ -29,67 +35,88 @@ jest.mock('../../hooks', () => { }; }); -const mockTheme = getMockTheme({ - eui: { - paddingSizes: { m: '2' }, - euiBreakpoints: { l: '2' }, - }, -}); - describe('Event filter modal', () => { let component: RenderResult; - let store: ReturnType; let onCancelMock: jest.Mock; - - const renderForm = () => { - const Wrapper: React.FC = ({ children }) => ( - - {children} - - ); - - return render(, { - wrapper: Wrapper, - }); - }; + let mockedContext: AppContextTestRender; + let waitForAction: MiddlewareActionSpyHelper['waitForAction']; + let render: () => ReturnType; + let getState: () => EventFiltersListPageState; beforeEach(() => { - store = createGlobalNoMiddlewareStore(); + mockedContext = createAppRootMockRenderer(); + waitForAction = mockedContext.middlewareSpy.waitForAction; onCancelMock = jest.fn(); + getState = () => mockedContext.store.getState().management.eventFilters; + render = () => + mockedContext.render(); + (useKibana as jest.Mock).mockReturnValue({ + services: { + http: {}, + data: { + search: { + search: jest.fn().mockImplementation(() => ({ toPromise: () => esResponseData() })), + }, + }, + notifications: {}, + }, + }); }); - it('should renders correctly', () => { - component = renderForm(); + it('should renders correctly', async () => { + await act(async () => { + component = render(); + await waitForAction('eventFiltersInitForm'); + }); + expect(component.getAllByText(MODAL_TITLE)).not.toBeNull(); expect(component.getByText(MODAL_SUBTITLE)).not.toBeNull(); expect(component.getAllByText(ACTIONS_CONFIRM)).not.toBeNull(); expect(component.getByText(ACTIONS_CANCEL)).not.toBeNull(); }); - it('should dispatch action to init form store on mount', () => { - component = renderForm(); - expect(store.getState()!.management!.eventFilters!.form!.entry).not.toBeNull(); + it('should dispatch action to init form store on mount', async () => { + await act(async () => { + component = render(); + await waitForAction('eventFiltersInitForm'); + }); + + expect(getState().form!.entry).not.toBeUndefined(); + }); + + it('should set OS with the enriched data', async () => { + await act(async () => { + component = render(); + await waitForAction('eventFiltersInitForm'); + }); + + expect(getState().form!.entry?.os_types).toContain('linux'); }); - it('should confirm form when button is disabled', () => { - component = renderForm(); + it('should confirm form when button is disabled', async () => { + await act(async () => { + component = render(); + await waitForAction('eventFiltersInitForm'); + }); + const confirmButton = component.getByTestId('add-exception-confirm-button'); act(() => { fireEvent.click(confirmButton); }); - expect(store.getState()!.management!.eventFilters!.form!.submissionResourceState.type).toBe( - 'UninitialisedResourceState' - ); + expect(getState().form!.submissionResourceState.type).toBe('UninitialisedResourceState'); }); - it('should confirm form when button is enabled', () => { - component = renderForm(); - store.dispatch({ + it('should confirm form when button is enabled', async () => { + await act(async () => { + component = render(); + await waitForAction('eventFiltersInitForm'); + }); + + mockedContext.store.dispatch({ type: 'eventFiltersChangeForm', payload: { entry: { - ...(store.getState()!.management!.eventFilters!.form! - .entry as CreateExceptionListItemSchema), + ...(getState().form!.entry as CreateExceptionListItemSchema), name: 'test', }, hasNameError: false, @@ -99,22 +126,24 @@ describe('Event filter modal', () => { act(() => { fireEvent.click(confirmButton); }); - expect(store.getState()!.management!.eventFilters!.form!.submissionResourceState.type).toBe( - 'UninitialisedResourceState' - ); - expect(confirmButton.hasAttribute('disabled')).toBeFalsy(); + expect(getState().form!.submissionResourceState.type).toBe('LoadingResourceState'); + expect(confirmButton.hasAttribute('disabled')).toBeTruthy(); }); - it('should close when exception has been submitted correctly', () => { - component = renderForm(); + it('should close when exception has been submitted correctly', async () => { + await act(async () => { + component = render(); + await waitForAction('eventFiltersInitForm'); + }); + expect(onCancelMock).toHaveBeenCalledTimes(0); act(() => { - store.dispatch({ + mockedContext.store.dispatch({ type: 'eventFiltersFormStateChanged', payload: { type: 'LoadedResourceState', - data: store.getState()!.management!.eventFilters!.form!.entry as ExceptionListItemSchema, + data: getState().form!.entry as ExceptionListItemSchema, }, }); }); @@ -122,8 +151,12 @@ describe('Event filter modal', () => { expect(onCancelMock).toHaveBeenCalledTimes(1); }); - it('should close when click on cancel button', () => { - component = renderForm(); + it('should close when click on cancel button', async () => { + await act(async () => { + component = render(); + await waitForAction('eventFiltersInitForm'); + }); + const cancelButton = component.getByText(ACTIONS_CANCEL); expect(onCancelMock).toHaveBeenCalledTimes(0); @@ -134,8 +167,12 @@ describe('Event filter modal', () => { expect(onCancelMock).toHaveBeenCalledTimes(1); }); - it('should close when close modal', () => { - component = renderForm(); + it('should close when close modal', async () => { + await act(async () => { + component = render(); + await waitForAction('eventFiltersInitForm'); + }); + const modalCloseButton = component.getByLabelText('Closes this modal window'); expect(onCancelMock).toHaveBeenCalledTimes(0); @@ -146,10 +183,14 @@ describe('Event filter modal', () => { expect(onCancelMock).toHaveBeenCalledTimes(1); }); - it('should prevent close when is loading action', () => { - component = renderForm(); + it('should prevent close when is loading action', async () => { + await act(async () => { + component = render(); + await waitForAction('eventFiltersInitForm'); + }); + act(() => { - store.dispatch({ + mockedContext.store.dispatch({ type: 'eventFiltersFormStateChanged', payload: { type: 'LoadingResourceState', diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/modal/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/modal/index.tsx index 50102d09248b1..dabf68ffed394 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/modal/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/modal/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { memo, useMemo, useEffect, useCallback } from 'react'; +import React, { memo, useMemo, useEffect, useCallback, useState, useRef } from 'react'; import { useDispatch } from 'react-redux'; import { Dispatch } from 'redux'; import styled, { css } from 'styled-components'; @@ -20,6 +20,7 @@ import { import { AppAction } from '../../../../../../common/store/actions'; import { Ecs } from '../../../../../../../common/ecs'; import { EventFiltersForm } from '../form'; +import { useKibana } from '../../../../../../common/lib/kibana'; import { useEventFiltersSelector, useEventFiltersNotification } from '../../hooks'; import { getFormHasError, @@ -61,10 +62,76 @@ const ModalBodySection = styled.section` export const EventFiltersModal: React.FC = memo(({ data, onCancel }) => { useEventFiltersNotification(); + const [enrichedData, setEnrichedData] = useState(); + const { + data: { search }, + } = useKibana().services; const dispatch = useDispatch>(); const formHasError = useEventFiltersSelector(getFormHasError); const creationInProgress = useEventFiltersSelector(isCreationInProgress); const creationSuccessful = useEventFiltersSelector(isCreationSuccessful); + const isMounted = useRef(false); + + // Enrich the event with missing ECS data from ES source + useEffect(() => { + isMounted.current = true; + + const enrichEvent = async () => { + if (!data._index) return; + const searchResponse = await search + .search({ + params: { + index: data._index, + body: { + query: { + match: { + _id: data._id, + }, + }, + }, + }, + }) + .toPromise(); + + if (!isMounted.current) return; + + setEnrichedData({ + ...data, + host: { + ...data.host, + os: { + ...(data?.host?.os || {}), + name: [searchResponse.rawResponse.hits.hits[0]._source.host.os.name], + }, + }, + }); + }; + + enrichEvent(); + + return () => { + dispatch({ + type: 'eventFiltersFormStateChanged', + payload: { + type: 'UninitialisedResourceState', + }, + }); + isMounted.current = false; + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Initialize the store with the enriched event to allow render the form + useEffect(() => { + if (enrichedData) { + dispatch({ + type: 'eventFiltersInitForm', + payload: { entry: getInitialExceptionFromEvent(enrichedData) }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enrichedData]); useEffect(() => { if (creationSuccessful) { @@ -78,15 +145,6 @@ export const EventFiltersModal: React.FC = memo(({ data, } }, [creationSuccessful, onCancel, dispatch]); - // Initialize the store with the event passed as prop to allow render the form. It acts as componentDidMount - useEffect(() => { - dispatch({ - type: 'eventFiltersInitForm', - payload: { entry: getInitialExceptionFromEvent(data) }, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const handleOnCancel = useCallback(() => { if (creationInProgress) return; onCancel(); diff --git a/x-pack/plugins/security_solution/public/management/pages/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/index.test.tsx index 9f2ed3618b06d..821e14edfda45 100644 --- a/x-pack/plugins/security_solution/public/management/pages/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/index.test.tsx @@ -10,11 +10,11 @@ import React from 'react'; import { ManagementContainer } from './index'; import '../../common/mock/match_media.ts'; import { AppContextTestRender, createAppRootMockRenderer } from '../../common/mock/endpoint'; -import { useIngestEnabledCheck } from '../../common/hooks/endpoint/ingest_enabled'; +import { useUserPrivileges } from '../../common/components/user_privileges'; -jest.mock('../../common/hooks/endpoint/ingest_enabled'); +jest.mock('../../common/components/user_privileges'); -describe('when in the Admistration tab', () => { +describe('when in the Administration tab', () => { let render: () => ReturnType; beforeEach(() => { @@ -23,14 +23,18 @@ describe('when in the Admistration tab', () => { mockedContext.history.push('/administration/endpoints'); }); - it('should display the No Permissions view when Ingest is OFF', async () => { - (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: false }); + it('should display the No Permissions if no sufficient privileges', async () => { + (useUserPrivileges as jest.Mock).mockReturnValue({ + endpointPrivileges: { loading: false, canAccessEndpointManagement: false }, + }); expect(await render().findByTestId('noIngestPermissions')).not.toBeNull(); }); - it('should display the Management view when Ingest is ON', async () => { - (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: true }); + it('should display the Management view if user has privileges', async () => { + (useUserPrivileges as jest.Mock).mockReturnValue({ + endpointPrivileges: { loading: false, canAccessEndpointManagement: true }, + }); expect(await render().findByTestId('endpointPage')).not.toBeNull(); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/index.tsx b/x-pack/plugins/security_solution/public/management/pages/index.tsx index 327dcd4458eeb..f348be6089923 100644 --- a/x-pack/plugins/security_solution/public/management/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/index.tsx @@ -7,7 +7,7 @@ import React, { memo } from 'react'; import { Route, Switch, Redirect } from 'react-router-dom'; -import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiLoadingSpinner, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { MANAGEMENT_ROUTING_ENDPOINTS_PATH, @@ -22,9 +22,9 @@ import { PolicyContainer } from './policy'; import { TrustedAppsContainer } from './trusted_apps'; import { MANAGEMENT_PATH, SecurityPageName } from '../../../common/constants'; import { SpyRoute } from '../../common/utils/route/spy_routes'; -import { useIngestEnabledCheck } from '../../common/hooks/endpoint/ingest_enabled'; import { EventFiltersContainer } from './event_filters'; import { getEndpointListPath } from '../common/routing'; +import { useUserPrivileges } from '../../common/components/user_privileges'; const NoPermissions = memo(() => { return ( @@ -80,9 +80,14 @@ const EventFilterTelemetry = () => ( ); export const ManagementContainer = memo(() => { - const { allEnabled: isIngestEnabled } = useIngestEnabledCheck(); + const { loading, canAccessEndpointManagement } = useUserPrivileges().endpointPrivileges; - if (!isIngestEnabled) { + // Lets wait until we can verify permissions + if (loading) { + return ; + } + + if (!canAccessEndpointManagement) { return ; } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts index 62d51c3630db7..db998b871cd93 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts @@ -691,4 +691,15 @@ export const AdvancedPolicySchema: AdvancedPolicySchemaType[] = [ } ), }, + { + key: 'linux.advanced.malware.quarantine', + first_supported_version: '7.14', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.linux.advanced.malware.quarantine', + { + defaultMessage: + 'Whether quarantine should be enabled when malware prevention is enabled. Default: true.', + } + ), + }, ]; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx index f0198092ec1be..7af9f84ad0875 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; import { OverviewEmpty } from '.'; -import { useIngestEnabledCheck } from '../../../common/hooks/endpoint/ingest_enabled'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; const endpointPackageVersion = '0.19.1'; @@ -20,8 +20,10 @@ jest.mock('../../../management/pages/endpoint_hosts/view/hooks', () => ({ useEndpointSelector: jest.fn().mockReturnValue({ endpointPackageVersion }), })); -jest.mock('../../../common/hooks/endpoint/ingest_enabled', () => ({ - useIngestEnabledCheck: jest.fn().mockReturnValue({ allEnabled: true }), +jest.mock('../../../common/components/user_privileges', () => ({ + useUserPrivileges: jest + .fn() + .mockReturnValue({ endpointPrivileges: { loading: false, canAccessFleet: true } }), })); jest.mock('../../../common/hooks/endpoint/use_navigate_to_app_event_handler', () => ({ @@ -36,7 +38,7 @@ describe('OverviewEmpty', () => { }); afterAll(() => { - (useIngestEnabledCheck as jest.Mock).mockReset(); + (useUserPrivileges as jest.Mock).mockReset(); }); test('render with correct actions ', () => { @@ -70,7 +72,9 @@ describe('OverviewEmpty', () => { describe('When isIngestEnabled = false', () => { let wrapper: ShallowWrapper; beforeAll(() => { - (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: false }); + (useUserPrivileges as jest.Mock).mockReturnValue({ + endpointPrivileges: { loading: false, canAccessFleet: false }, + }); wrapper = shallow(); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx index 028871d7be19d..c75438e18f5d5 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx @@ -20,9 +20,9 @@ import { useIngestUrl, } from '../../../management/pages/endpoint_hosts/view/hooks'; import { useNavigateToAppEventHandler } from '../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; -import { useIngestEnabledCheck } from '../../../common/hooks/endpoint/ingest_enabled'; import { CreateStructuredSelector } from '../../../common/store'; import { endpointPackageVersion as useEndpointPackageVersion } from '../../../management/pages/endpoint_hosts/store/selectors'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; const OverviewEmptyComponent: React.FC = () => { const { http, docLinks } = useKibana().services; @@ -40,7 +40,7 @@ const OverviewEmptyComponent: React.FC = () => { const handleEndpointClick = useNavigateToAppEventHandler('fleet', { path: endpointIntegrationUrl, }); - const { allEnabled: isIngestEnabled } = useIngestEnabledCheck(); + const canAccessFleet = useUserPrivileges().endpointPrivileges.canAccessFleet; const emptyPageActions: EmptyPageActionsProps = useMemo( () => ({ @@ -72,7 +72,7 @@ const OverviewEmptyComponent: React.FC = () => { [emptyPageActions] ); - return isIngestEnabled === true ? ( + return canAccessFleet === true ? ( ({ jest.mock('../../common/components/query_bar', () => ({ QueryBar: () => null, })); -jest.mock('../../common/hooks/endpoint/ingest_enabled'); +jest.mock('../../common/components/user_privileges', () => { + return { + ...jest.requireActual('../../common/components/user_privileges'), + useUserPrivileges: jest.fn(() => { + return { + listPrivileges: { loading: false, error: undefined, result: undefined }, + detectionEnginePrivileges: { loading: false, error: undefined, result: undefined }, + endpointPrivileges: { + loading: false, + canAccessEndpointManagement: true, + canAccessFleet: true, + }, + }; + }), + }; +}); jest.mock('../../common/containers/local_storage/use_messages_storage'); jest.mock('../containers/overview_cti_links'); jest.mock('../containers/overview_cti_links/use_cti_event_counts'); jest.mock('../containers/overview_cti_links'); + const useCtiDashboardLinksMock = useCtiDashboardLinks as jest.Mock; useCtiDashboardLinksMock.mockReturnValue(mockCtiLinksResponse); @@ -74,12 +95,25 @@ const endpointNoticeMessage = (hasMessageValue: boolean) => { }; }; const mockUseSourcererScope = useSourcererScope as jest.Mock; -const mockUseIngestEnabledCheck = useIngestEnabledCheck as jest.Mock; +const mockUseUserPrivileges = useUserPrivileges as jest.Mock; const mockUseFetchIndex = useFetchIndex as jest.Mock; const mockUseMessagesStorage: jest.Mock = useMessagesStorage as jest.Mock; describe('Overview', () => { + const loadedUserPrivilegesState = ( + endpointOverrides: Partial = {} + ): ReturnType => + merge(initialUserPrivilegesState(), { + endpointPrivileges: { + loading: false, + canAccessFleet: true, + canAccessEndpointManagement: true, + ...endpointOverrides, + }, + }); + beforeEach(() => { + mockUseUserPrivileges.mockReturnValue(loadedUserPrivilegesState()); mockUseFetchIndex.mockReturnValue([ false, { @@ -88,6 +122,10 @@ describe('Overview', () => { ]); }); + afterAll(() => { + mockUseUserPrivileges.mockReset(); + }); + describe('rendering', () => { test('it DOES NOT render the Getting started text when an index is available', () => { mockUseSourcererScope.mockReturnValue({ @@ -97,7 +135,6 @@ describe('Overview', () => { }); mockUseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false)); - mockUseIngestEnabledCheck.mockReturnValue({ allEnabled: true }); const wrapper = mount( @@ -125,7 +162,6 @@ describe('Overview', () => { }); mockUseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false)); - mockUseIngestEnabledCheck.mockReturnValue({ allEnabled: true }); const wrapper = mount( @@ -153,7 +189,6 @@ describe('Overview', () => { }); mockUseMessagesStorage.mockImplementation(() => endpointNoticeMessage(true)); - mockUseIngestEnabledCheck.mockReturnValue({ allEnabled: true }); const wrapper = mount( @@ -175,7 +210,6 @@ describe('Overview', () => { }); mockUseMessagesStorage.mockImplementation(() => endpointNoticeMessage(true)); - mockUseIngestEnabledCheck.mockReturnValue({ allEnabled: true }); const wrapper = mount( @@ -197,7 +231,6 @@ describe('Overview', () => { }); mockUseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false)); - mockUseIngestEnabledCheck.mockReturnValue({ allEnabled: true }); const wrapper = mount( @@ -219,7 +252,7 @@ describe('Overview', () => { }); mockUseMessagesStorage.mockImplementation(() => endpointNoticeMessage(true)); - mockUseIngestEnabledCheck.mockReturnValue({ allEnabled: false }); + mockUseUserPrivileges.mockReturnValue(loadedUserPrivilegesState({ canAccessFleet: false })); const wrapper = mount( @@ -239,7 +272,7 @@ describe('Overview', () => { selectedPatterns: [], indicesExist: false, }); - mockUseIngestEnabledCheck.mockReturnValue({ allEnabled: false }); + mockUseUserPrivileges.mockReturnValue(loadedUserPrivilegesState({ canAccessFleet: false })); mockUseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false)); }); @@ -266,7 +299,7 @@ describe('Overview', () => { }); it('shows Endpoint get ready button when ingest is enabled', () => { - mockUseIngestEnabledCheck.mockReturnValue({ allEnabled: true }); + mockUseUserPrivileges.mockReturnValue(loadedUserPrivilegesState({ canAccessFleet: true })); const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index 174141db9bfb1..ed12dce6db482 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -27,13 +27,13 @@ import { SecurityPageName } from '../../app/types'; import { EndpointNotice } from '../components/endpoint_notice'; import { useMessagesStorage } from '../../common/containers/local_storage/use_messages_storage'; import { ENDPOINT_METADATA_INDEX } from '../../../common/constants'; -import { useIngestEnabledCheck } from '../../common/hooks/endpoint/ingest_enabled'; import { useSourcererScope } from '../../common/containers/sourcerer'; import { Sourcerer } from '../../common/components/sourcerer'; import { SourcererScopeName } from '../../common/store/sourcerer/model'; import { useDeepEqualSelector } from '../../common/hooks/use_selector'; import { ThreatIntelLinkPanel } from '../components/overview_cti_links'; import { useIsThreatIntelModuleEnabled } from '../containers/overview_cti_links/use_is_threat_intel_module_enabled'; +import { useUserPrivileges } from '../../common/components/user_privileges'; const SidebarFlexItem = styled(EuiFlexItem)` margin-right: 24px; @@ -70,9 +70,8 @@ const OverviewComponent = () => { setDismissMessage(true); addMessage('management', 'dismissEndpointNotice'); }, [addMessage]); - const { allEnabled: isIngestEnabled } = useIngestEnabledCheck(); + const canAccessFleet = useUserPrivileges().endpointPrivileges.canAccessFleet; const isThreatIntelModuleEnabled = useIsThreatIntelModuleEnabled(); - return ( <> {indicesExist ? ( @@ -82,7 +81,7 @@ const OverviewComponent = () => { - {!dismissMessage && !metadataIndexExists && isIngestEnabled && ( + {!dismissMessage && !metadataIndexExists && canAccessFleet && ( <> diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/add_tags.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/add_tags.test.ts index a871c7157d5e8..93fddc06b8068 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/add_tags.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/add_tags.test.ts @@ -33,8 +33,17 @@ describe('add_tags', () => { const tags2 = addTags(tags1, 'rule-1', false); expect(tags2).toEqual([ 'tag-1', + `${INTERNAL_RULE_ID_KEY}:rule-1`, `${INTERNAL_IMMUTABLE_KEY}:false`, + ]); + }); + + test('it should overwrite existing immutable tag if it exists', () => { + const tags1 = addTags(['tag-1', `${INTERNAL_IMMUTABLE_KEY}:true`], 'rule-1', false); + expect(tags1).toEqual([ + 'tag-1', `${INTERNAL_RULE_ID_KEY}:rule-1`, + `${INTERNAL_IMMUTABLE_KEY}:false`, ]); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/add_tags.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/add_tags.ts index 6ff4a54ad8e54..d66f961b38598 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/add_tags.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/add_tags.ts @@ -10,7 +10,9 @@ import { INTERNAL_RULE_ID_KEY, INTERNAL_IMMUTABLE_KEY } from '../../../../common export const addTags = (tags: string[], ruleId: string, immutable: boolean): string[] => { return Array.from( new Set([ - ...tags.filter((tag) => !tag.startsWith(INTERNAL_RULE_ID_KEY)), + ...tags.filter( + (tag) => !(tag.startsWith(INTERNAL_RULE_ID_KEY) || tag.startsWith(INTERNAL_IMMUTABLE_KEY)) + ), `${INTERNAL_RULE_ID_KEY}:${ruleId}`, `${INTERNAL_IMMUTABLE_KEY}:${immutable}`, ]) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts index 3046999a632c6..92b4dcff61b35 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/duplicate_rule.test.ts @@ -123,8 +123,8 @@ describe('duplicateRule', () => { }, "tags": Array [ "test", - "__internal_immutable:false", "__internal_rule_id:newId", + "__internal_immutable:false", ], "throttle": null, } diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 4389b22611748..a8ad6c919a04d 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -238,6 +238,7 @@ export class Plugin implements IPlugin initializeRuleDataTemplatesPromise ); @@ -338,7 +339,7 @@ export class Plugin implements IPlugin = ({ match, location }) => { - + {typeof errorMessage !== 'undefined' && ( <> = React.memo( {created && ( - - + + } title={i18n.translate('xpack.transform.stepCreateForm.transformListCardTitle', { @@ -498,7 +497,7 @@ export const StepCreateForm: FC = React.memo( /> {started === true && createIndexPattern === true && indexPatternId === undefined && ( - + @@ -515,7 +514,7 @@ export const StepCreateForm: FC = React.memo( )} {isDiscoverAvailable && discoverLink !== undefined && ( - + } title={i18n.translate('xpack.transform.stepCreateForm.discoverCardTitle', { @@ -532,7 +531,7 @@ export const StepCreateForm: FC = React.memo( /> )} - + )} diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/_wizard.scss b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/_wizard.scss index 1b493e9e74490..2fb415f8ab2d9 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/_wizard.scss +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/_wizard.scss @@ -8,22 +8,11 @@ } } -.transform__stepDefineForm { - align-items: flex-start; -} - -.transform__stepDefineFormLeftColumn { - min-width: 420px; - border-right: 1px solid $euiColorLightShade; -} - /* -This is an override to replicate the previous full-page-width of the transforms creation wizard -when it was in use within the ML plugin. The Kibana management section limits a max-width to 1200px -which is a bit narrow for the two column layout of the transform wizard. We might revisit this for -future versions to blend in more with the overall design of the Kibana management section. -The management section's navigation width is 192px + 24px right margin +This ensures the wizard goes full page width, and that the data grid in the page does not +cause the body of the wizard page to overflow into the side navigation of the Kibana +Stack Management page on resize. */ -.mgtPage__body--transformWizard { - max-width: calc(100% - 216px); +.transform__wizardBody { + max-width: calc(100% - 16px); } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx index 5ae464affa016..63e21e5d8aa14 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment, FC, useEffect, useRef, useState, createContext, useMemo } from 'react'; +import React, { Fragment, FC, useRef, useState, createContext, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; @@ -34,11 +34,6 @@ import { WizardNav } from '../wizard_nav'; import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; import type { RuntimeMappings } from '../step_define/common/types'; -enum KBN_MANAGEMENT_PAGE_CLASSNAME { - DEFAULT_BODY = 'mgtPage__body', - TRANSFORM_BODY_MODIFIER = 'mgtPage__body--transformWizard', -} - enum WIZARD_STEPS { DEFINE, DETAILS, @@ -121,34 +116,6 @@ export const Wizard: FC = React.memo(({ cloneConfig, searchItems }) // The CREATE state const [stepCreateState, setStepCreateState] = useState(getDefaultStepCreateState); - useEffect(() => { - // The transform plugin doesn't control the wrapping management page via React - // so we use plain JS to add and remove a custom CSS class to set the full - // page width to 100% for the transform wizard. It's done to replicate the layout - // as it was when transforms were part of the ML plugin. This will be revisited - // to come up with an approach that's more in line with the overall layout - // of the Kibana management section. - let managementBody = document.getElementsByClassName( - KBN_MANAGEMENT_PAGE_CLASSNAME.DEFAULT_BODY - ); - - if (managementBody.length > 0) { - managementBody[0].classList.replace( - KBN_MANAGEMENT_PAGE_CLASSNAME.DEFAULT_BODY, - KBN_MANAGEMENT_PAGE_CLASSNAME.TRANSFORM_BODY_MODIFIER - ); - return () => { - managementBody = document.getElementsByClassName( - KBN_MANAGEMENT_PAGE_CLASSNAME.TRANSFORM_BODY_MODIFIER - ); - managementBody[0].classList.replace( - KBN_MANAGEMENT_PAGE_CLASSNAME.TRANSFORM_BODY_MODIFIER, - KBN_MANAGEMENT_PAGE_CLASSNAME.DEFAULT_BODY - ); - }; - } - }, []); - const transformConfig = getCreateTransformRequestBody( indexPattern.title, stepDefineState, diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx index d736bd60f2df6..4cb9ec926c049 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/create_transform_section.tsx @@ -68,7 +68,10 @@ export const CreateTransformSection: FC = ({ match }) => { - + {searchItemsError !== undefined && ( <> diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index fd0060f83a3e4..11770d2d2f386 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5937,7 +5937,6 @@ "xpack.apm.transactionsTable.notFoundLabel": "トランザクションが見つかりませんでした。", "xpack.apm.transactionsTable.throughputColumnLabel": "スループット", "xpack.apm.tutorial.apmServer.title": "APM Server", - "xpack.apm.tutorial.elasticCloud.textPre": "APM Server を有効にするには、[the Elastic Cloud console] (https://cloud.elastic.co/deployments?q={cloudId}) に移動し、展開設定で APM を有効にします。有効になったら、このページを更新してください。", "xpack.apm.tutorial.elasticCloudInstructions.title": "APM エージェント", "xpack.apm.tutorial.specProvider.artifacts.application.label": "APM を起動", "xpack.apm.unitLabel": "単位を選択", @@ -17302,7 +17301,6 @@ "xpack.osquery.fleetIntegration.scheduleQueryGroupsButtonText": "クエリグループをスケジュール", "xpack.osquery.liveQueriesHistory.newLiveQueryButtonLabel": "新しいライブクエリ", "xpack.osquery.liveQueriesHistory.pageTitle": "ライブクエリ履歴", - "xpack.osquery.liveQueryActionResults.summary.agentsQueriedLabelText": "エージェントがクエリされました", "xpack.osquery.liveQueryActionResults.summary.failedLabelText": "失敗", "xpack.osquery.liveQueryActionResults.summary.pendingLabelText": "未応答", "xpack.osquery.liveQueryActionResults.summary.successfulLabelText": "成功", @@ -17317,8 +17315,6 @@ "xpack.osquery.liveQueryActions.table.createdAtColumnTitle": "作成日時:", "xpack.osquery.liveQueryActions.table.queryColumnTitle": "クエリ", "xpack.osquery.liveQueryActions.table.viewDetailsColumnTitle": "詳細を表示", - "xpack.osquery.liveQueryDetails.kpis.agentsFailedCountLabelText": "エージェントが失敗しました", - "xpack.osquery.liveQueryDetails.kpis.agentsQueriedLabelText": "エージェントがクエリされました", "xpack.osquery.liveQueryDetails.pageTitle": "ライブクエリ詳細", "xpack.osquery.liveQueryDetails.viewLiveQueriesHistoryTitle": "ライブクエリ履歴を表示", "xpack.osquery.liveQueryForm.form.submitButtonLabel": "送信", @@ -19003,7 +18999,6 @@ "xpack.securitySolution.detectionEngine.components.importRuleModal.selectRuleDescription": "インポートするセキュリティルール (検出エンジンビューからエクスポートしたもの) を選択します", "xpack.securitySolution.detectionEngine.createRule. stepScheduleRule.completeWithActivatingTitle": "ルールの作成と有効化", "xpack.securitySolution.detectionEngine.createRule. stepScheduleRule.completeWithoutActivatingTitle": "有効化せずにルールを作成", - "xpack.securitySolution.detectionEngine.createRule.backToRulesDescription": "検出ルールに戻る", "xpack.securitySolution.detectionEngine.createRule.editRuleButton": "編集", "xpack.securitySolution.detectionEngine.createRule.eqlRuleTypeDescription": "イベント相関関係", "xpack.securitySolution.detectionEngine.createRule.filtersLabel": "フィルター", @@ -19745,7 +19740,6 @@ "xpack.securitySolution.detectionEngine.ruleDescription.thresholdResultsAggregatedByDescription": "結果集約条件", "xpack.securitySolution.detectionEngine.ruleDescription.thresholdResultsAllDescription": "すべての結果", "xpack.securitySolution.detectionEngine.ruleDetails.activatedRuleLabel": "有効化", - "xpack.securitySolution.detectionEngine.ruleDetails.backToRulesDescription": "検出ルールに戻る", "xpack.securitySolution.detectionEngine.ruleDetails.errorCalloutTitle": "ルール失敗", "xpack.securitySolution.detectionEngine.ruleDetails.exceptionsTab": "例外", "xpack.securitySolution.detectionEngine.ruleDetails.experimentalDescription": "実験的", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d2a2f0be215d3..9704070feb8ab 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5975,7 +5975,6 @@ "xpack.apm.transactionsTable.notFoundLabel": "未找到任何事务。", "xpack.apm.transactionsTable.throughputColumnLabel": "吞吐量", "xpack.apm.tutorial.apmServer.title": "APM Server", - "xpack.apm.tutorial.elasticCloud.textPre": "要启用 APM Server,请前往 [Elastic Cloud 控制台](https://cloud.elastic.co/deployments?q={cloudId}) 并在部署设置中启用 APM。启用后,请刷新此页面。", "xpack.apm.tutorial.elasticCloudInstructions.title": "APM 代理", "xpack.apm.tutorial.specProvider.artifacts.application.label": "启动 APM", "xpack.apm.unitLabel": "选择单位", @@ -17540,7 +17539,6 @@ "xpack.osquery.fleetIntegration.scheduleQueryGroupsButtonText": "计划查询组", "xpack.osquery.liveQueriesHistory.newLiveQueryButtonLabel": "新建实时查询", "xpack.osquery.liveQueriesHistory.pageTitle": "实时查询历史记录", - "xpack.osquery.liveQueryActionResults.summary.agentsQueriedLabelText": "查询的代理", "xpack.osquery.liveQueryActionResults.summary.failedLabelText": "失败", "xpack.osquery.liveQueryActionResults.summary.pendingLabelText": "尚未响应", "xpack.osquery.liveQueryActionResults.summary.successfulLabelText": "成功", @@ -17555,8 +17553,6 @@ "xpack.osquery.liveQueryActions.table.createdAtColumnTitle": "创建于", "xpack.osquery.liveQueryActions.table.queryColumnTitle": "查询", "xpack.osquery.liveQueryActions.table.viewDetailsColumnTitle": "查看详情", - "xpack.osquery.liveQueryDetails.kpis.agentsFailedCountLabelText": "失败的代理", - "xpack.osquery.liveQueryDetails.kpis.agentsQueriedLabelText": "查询的代理", "xpack.osquery.liveQueryDetails.pageTitle": "实时查询详情", "xpack.osquery.liveQueryDetails.viewLiveQueriesHistoryTitle": "查看实时查询历史记录", "xpack.osquery.liveQueryForm.form.submitButtonLabel": "提交", @@ -19282,7 +19278,6 @@ "xpack.securitySolution.detectionEngine.components.importRuleModal.successfullyImportedRulesTitle": "已成功导入 {totalRules} 个{totalRules, plural, other {规则}}", "xpack.securitySolution.detectionEngine.createRule. stepScheduleRule.completeWithActivatingTitle": "创建并激活规则", "xpack.securitySolution.detectionEngine.createRule. stepScheduleRule.completeWithoutActivatingTitle": "创建规则但不激活", - "xpack.securitySolution.detectionEngine.createRule.backToRulesDescription": "返回到检测规则", "xpack.securitySolution.detectionEngine.createRule.editRuleButton": "编辑", "xpack.securitySolution.detectionEngine.createRule.eqlRuleTypeDescription": "事件关联", "xpack.securitySolution.detectionEngine.createRule.filtersLabel": "筛选", @@ -20027,7 +20022,6 @@ "xpack.securitySolution.detectionEngine.ruleDescription.thresholdResultsAggregatedByDescription": "结果聚合依据", "xpack.securitySolution.detectionEngine.ruleDescription.thresholdResultsAllDescription": "所有结果", "xpack.securitySolution.detectionEngine.ruleDetails.activatedRuleLabel": "已激活", - "xpack.securitySolution.detectionEngine.ruleDetails.backToRulesDescription": "返回到检测规则", "xpack.securitySolution.detectionEngine.ruleDetails.errorCalloutTitle": "规则错误位置", "xpack.securitySolution.detectionEngine.ruleDetails.exceptionsTab": "例外", "xpack.securitySolution.detectionEngine.ruleDetails.experimentalDescription": "实验性", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx index 5d526e74564c5..56f333396908b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx @@ -43,6 +43,7 @@ export const IndexParamsFields = ({ ALERT_HISTORY_PREFIX, '' ); + const [isActionConnectorChanged, setIsActionConnectorChanged] = useState(false); const getDocumentToIndex = (doc: Array> | undefined) => doc && doc.length > 0 ? ((doc[0] as unknown) as string) : undefined; @@ -67,11 +68,12 @@ export const IndexParamsFields = ({ setUsePreconfiguredSchema(true); editAction('documents', [JSON.stringify(AlertHistoryDocumentTemplate)], index); setDocumentToIndex(JSON.stringify(AlertHistoryDocumentTemplate)); - } else { + } else if (isActionConnectorChanged) { setUsePreconfiguredSchema(false); editAction('documents', undefined, index); setDocumentToIndex(undefined); } + setIsActionConnectorChanged(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionConnector?.id]); diff --git a/x-pack/plugins/uptime/public/lib/alert_types/translations.ts b/x-pack/plugins/uptime/public/lib/alert_types/translations.ts index bb4af761d240d..5122120479cf7 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/translations.ts +++ b/x-pack/plugins/uptime/public/lib/alert_types/translations.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; export const TlsTranslations = { - defaultActionMessage: i18n.translate('xpack.uptime.alerts.tls.legacy.defaultActionMessage', { + defaultActionMessage: i18n.translate('xpack.uptime.alerts.tls.defaultActionMessage', { defaultMessage: `Detected TLS certificate {commonName} from issuer {issuer} is {status}. Certificate {summary} `, values: { @@ -18,17 +18,16 @@ export const TlsTranslations = { status: '{{state.status}}', }, }), - name: i18n.translate('xpack.uptime.alerts.tls.legacy.clientName', { - defaultMessage: 'Uptime TLS (Legacy)', + name: i18n.translate('xpack.uptime.alerts.tls.clientName', { + defaultMessage: 'Uptime TLS', }), - description: i18n.translate('xpack.uptime.alerts.tls.legacy.description', { - defaultMessage: - 'Alert when the TLS certificate of an Uptime monitor is about to expire. This alert will be deprecated in a future version.', + description: i18n.translate('xpack.uptime.alerts.tls.description', { + defaultMessage: 'Alert when the TLS certificate of an Uptime monitor is about to expire.', }), }; export const TlsTranslationsLegacy = { - defaultActionMessage: i18n.translate('xpack.uptime.alerts.tls.defaultActionMessage', { + defaultActionMessage: i18n.translate('xpack.uptime.alerts.tls.legacy.defaultActionMessage', { defaultMessage: `Detected {count} TLS certificates expiring or becoming too old. {expiringConditionalOpen} Expiring cert count: {expiringCount} @@ -51,11 +50,12 @@ Aging Certificates: {agingCommonNameAndDate} agingConditionalClose: '{{/state.hasAging}}', }, }), - name: i18n.translate('xpack.uptime.alerts.tls.clientName', { + name: i18n.translate('xpack.uptime.alerts.tls.legacy.clientName', { defaultMessage: 'Uptime TLS', }), - description: i18n.translate('xpack.uptime.alerts.tls.description', { - defaultMessage: 'Alert when the TLS certificate of an Uptime monitor is about to expire.', + description: i18n.translate('xpack.uptime.alerts.tls.legacy.description', { + defaultMessage: + 'Alert when the TLS certificate of an Uptime monitor is about to expire. This rule type will be deprecated in a future version.', }), }; diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 1a7f9acc9f1a3..9e527835231b4 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -45,6 +45,9 @@ const onlyNotInCoverageTests = [ require.resolve('../test/detection_engine_api_integration/basic/config.ts'), require.resolve('../test/lists_api_integration/security_and_spaces/config.ts'), require.resolve('../test/plugin_api_integration/config.ts'), + require.resolve('../test/rule_registry/security_and_spaces/config_basic.ts'), + require.resolve('../test/rule_registry/security_and_spaces/config_trial.ts'), + require.resolve('../test/rule_registry/spaces_only/config_trial.ts'), require.resolve('../test/security_api_integration/saml.config.ts'), require.resolve('../test/security_api_integration/session_idle.config.ts'), require.resolve('../test/security_api_integration/session_invalidate.config.ts'), diff --git a/x-pack/test/accessibility/apps/reporting.ts b/x-pack/test/accessibility/apps/reporting.ts new file mode 100644 index 0000000000000..bccb650fa08ca --- /dev/null +++ b/x-pack/test/accessibility/apps/reporting.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +import { JOB_PARAMS_RISON_CSV_DEPRECATED } from '../../reporting_api_integration/services/fixtures'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const { common } = getPageObjects(['common']); + const retry = getService('retry'); + const a11y = getService('a11y'); + const testSubjects = getService('testSubjects'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const reporting = getService('reporting'); + const esArchiver = getService('esArchiver'); + const security = getService('security'); + + describe('Reporting', () => { + const createReportingUser = async () => { + await security.user.create(reporting.REPORTING_USER_USERNAME, { + password: reporting.REPORTING_USER_PASSWORD, + roles: ['reporting_user', 'data_analyst', 'kibana_user'], // Deprecated: using built-in `reporting_user` role grants all Reporting privileges + full_name: 'a reporting user', + }); + }; + + const deleteReportingUser = async () => { + await security.user.delete(reporting.REPORTING_USER_USERNAME); + }; + + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/reporting/logs'); + await esArchiver.load('x-pack/test/functional/es_archives/logstash_functional'); + + await createReportingUser(); + await reporting.loginReportingUser(); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/reporting/logs'); + await esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional'); + + await deleteReportingUser(); + }); + + beforeEach(async () => { + // Add one report + await supertestWithoutAuth + .post(`/api/reporting/generate/csv`) + .auth(reporting.REPORTING_USER_USERNAME, reporting.REPORTING_USER_PASSWORD) + .set('kbn-xsrf', 'xxx') + .send({ jobParams: JOB_PARAMS_RISON_CSV_DEPRECATED }) + .expect(200); + + await retry.waitFor('Reporting app', async () => { + await common.navigateToApp('reporting'); + return testSubjects.exists('reportingPageHeader'); + }); + }); + + afterEach(async () => { + await reporting.deleteAllReports(); + }); + + it('List reports view', async () => { + await retry.waitForWithTimeout('A reporting list item', 5000, () => { + return testSubjects.exists('reportingListItemObjectTitle'); + }); + await a11y.testAppSnapshot(); + }); + }); +} diff --git a/x-pack/test/accessibility/config.ts b/x-pack/test/accessibility/config.ts index 81cfd70a23956..e79bbdb86a88a 100644 --- a/x-pack/test/accessibility/config.ts +++ b/x-pack/test/accessibility/config.ts @@ -37,6 +37,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { require.resolve('./apps/security_solution'), require.resolve('./apps/ml_embeddables_in_dashboard'), require.resolve('./apps/remote_clusters'), + require.resolve('./apps/reporting'), ], pageObjects, diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 2468bfec63321..2576a5eaf9bc9 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -21,7 +21,23 @@ export default function ({ getService }: FtrProviderContext) { // If you're removing a privilege, this breaks backwards compatibility // Roles are associated with these privileges, and we shouldn't be removing them in a minor version. const expected = { + global: ['all', 'read'], + space: ['all', 'read'], features: { + graph: ['all', 'read'], + savedObjectsTagging: ['all', 'read'], + canvas: ['all', 'read', 'minimal_all', 'minimal_read', 'generate_report'], + maps: ['all', 'read'], + fleet: ['all', 'read'], + actions: ['all', 'read'], + stackAlerts: ['all', 'read'], + ml: ['all', 'read'], + siem: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_all', 'cases_read'], + observabilityCases: ['all', 'read'], + uptime: ['all', 'read'], + infrastructure: ['all', 'read'], + logs: ['all', 'read'], + apm: ['all', 'read', 'minimal_all', 'minimal_read', 'alerts_all', 'alerts_read'], discover: [ 'all', 'read', @@ -53,24 +69,8 @@ export default function ({ getService }: FtrProviderContext) { advancedSettings: ['all', 'read'], indexPatterns: ['all', 'read'], savedObjectsManagement: ['all', 'read'], - savedObjectsTagging: ['all', 'read'], timelion: ['all', 'read'], - graph: ['all', 'read'], - maps: ['all', 'read'], - canvas: ['all', 'read', 'minimal_all', 'minimal_read', 'generate_report'], - infrastructure: ['all', 'read'], - logs: ['all', 'read'], - observabilityCases: ['all', 'read'], - uptime: ['all', 'read'], - apm: ['all', 'read'], - ml: ['all', 'read'], - siem: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_all', 'cases_read'], - fleet: ['all', 'read'], - stackAlerts: ['all', 'read'], - actions: ['all', 'read'], }, - global: ['all', 'read'], - space: ['all', 'read'], reserved: ['ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], }; diff --git a/x-pack/test/api_integration/apis/security_solution/events.ts b/x-pack/test/api_integration/apis/security_solution/events.ts index ff4256f1a1adf..2150b022ac425 100644 --- a/x-pack/test/api_integration/apis/security_solution/events.ts +++ b/x-pack/test/api_integration/apis/security_solution/events.ts @@ -7,6 +7,11 @@ import expect from '@kbn/expect'; +import { secOnly } from '../../../rule_registry/common/lib/authentication/users'; +import { + createSpacesAndUsers, + deleteSpacesAndUsers, +} from '../../../rule_registry/common/lib/authentication/'; import { Direction, TimelineEventsQueries, @@ -407,10 +412,19 @@ export default function ({ getService }: FtrProviderContext) { const retry = getService('retry'); const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); describe('Timeline', () => { - before(() => esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts')); - after(() => esArchiver.unload('x-pack/test/functional/es_archives/auditbeat/hosts')); + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts'); + await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts'); + await createSpacesAndUsers(getService); + }); + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/auditbeat/hosts'); + await esArchiver.unload('x-pack/test/functional/es_archives/rule_registry/alerts'); + await deleteSpacesAndUsers(getService); + }); it('Make sure that we get Timeline data', async () => { await retry.try(async () => { @@ -454,6 +468,60 @@ export default function ({ getService }: FtrProviderContext) { }); }); + // TODO: unskip this test once authz is added to search strategy + it.skip('Make sure that we get Timeline data using the hunter role and do not receive observability alerts', async () => { + await retry.try(async () => { + const requestBody = { + defaultIndex: ['.alerts*'], // query both .alerts-observability-apm and .alerts-security-solution + docValueFields: [], + factoryQueryType: TimelineEventsQueries.all, + fieldRequested: FIELD_REQUESTED, + // fields: [], + filterQuery: { + bool: { + filter: [ + { + match_all: {}, + }, + ], + }, + }, + pagination: { + activePage: 0, + querySize: 25, + }, + language: 'kuery', + sort: [ + { + field: '@timestamp', + direction: Direction.desc, + type: 'number', + }, + ], + timerange: { + from: FROM, + to: TO, + interval: '12h', + }, + }; + const resp = await supertestWithoutAuth + .post('/internal/search/securitySolutionTimelineSearchStrategy/') + .auth(secOnly.username, secOnly.password) // using security 'hunter' role + .set('kbn-xsrf', 'true') + .set('Content-Type', 'application/json') + .send(requestBody) + .expect(200); + + const timeline = resp.body; + + // we inject one alert into the security solutions alerts index and another alert into the observability alerts index + // therefore when accessing the .alerts* index with the security solution user, + // only security solution alerts should be returned since the security solution user + // is not authorized to view observability alerts. + expect(timeline.totalCount).to.be(1); + }); + }); + it('Make sure that pagination is working in Timeline query', async () => { await retry.try(async () => { const resp = await supertest diff --git a/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts b/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts index e07681afe2203..4e3740a1ccb1c 100644 --- a/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts +++ b/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts @@ -367,6 +367,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { "kibana.rac.alert.id": Array [ "apm.transaction_error_rate_opbeans-go_request_ENVIRONMENT_NOT_DEFINED", ], + "kibana.rac.alert.owner": Array [ + "apm", + ], "kibana.rac.alert.producer": Array [ "apm", ], @@ -437,6 +440,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { "kibana.rac.alert.id": Array [ "apm.transaction_error_rate_opbeans-go_request_ENVIRONMENT_NOT_DEFINED", ], + "kibana.rac.alert.owner": Array [ + "apm", + ], "kibana.rac.alert.producer": Array [ "apm", ], @@ -541,6 +547,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { "kibana.rac.alert.id": Array [ "apm.transaction_error_rate_opbeans-go_request_ENVIRONMENT_NOT_DEFINED", ], + "kibana.rac.alert.owner": Array [ + "apm", + ], "kibana.rac.alert.producer": Array [ "apm", ], diff --git a/x-pack/test/fleet_api_integration/apis/fleet_telemetry.ts b/x-pack/test/fleet_api_integration/apis/fleet_telemetry.ts index 36eef019f7bf7..ed79d7200c4ed 100644 --- a/x-pack/test/fleet_api_integration/apis/fleet_telemetry.ts +++ b/x-pack/test/fleet_api_integration/apis/fleet_telemetry.ts @@ -30,6 +30,13 @@ export default function (providerContext: FtrProviderContext) { case 'offline': data = { last_checkin: '2017-06-07T18:59:04.498Z' }; break; + // Agent with last checkin status as error and currently unenrolling => should displayd updating status + case 'error-unenrolling': + data = { + last_checkin_status: 'error', + unenrollment_started_at: '2017-06-07T18:59:04.498Z', + }; + break; default: data = { last_checkin: new Date().toISOString() }; } @@ -95,6 +102,7 @@ export default function (providerContext: FtrProviderContext) { await generateAgent('offline', defaultServerPolicy.id); await generateAgent('error', defaultServerPolicy.id); await generateAgent('degraded', defaultServerPolicy.id); + await generateAgent('error-unenrolling', defaultServerPolicy.id); }); it('should return the correct telemetry values for fleet', async () => { @@ -109,12 +117,12 @@ export default function (providerContext: FtrProviderContext) { .expect(200); expect(apiResponse.stack_stats.kibana.plugins.fleet.agents).eql({ - total_enrolled: 7, + total_enrolled: 8, healthy: 3, unhealthy: 3, offline: 1, - updating: 0, - total_all_statuses: 7, + updating: 1, + total_all_statuses: 8, }); expect(apiResponse.stack_stats.kibana.plugins.fleet.fleet_server).eql({ diff --git a/x-pack/test/functional/apps/canvas/smoke_test.js b/x-pack/test/functional/apps/canvas/smoke_test.js index fcc04aafdbcd8..cb29840c4b2aa 100644 --- a/x-pack/test/functional/apps/canvas/smoke_test.js +++ b/x-pack/test/functional/apps/canvas/smoke_test.js @@ -6,7 +6,6 @@ */ import expect from '@kbn/expect'; -import { parse } from 'url'; export default function canvasSmokeTest({ getService, getPageObjects }) { const testSubjects = getService('testSubjects'); @@ -45,7 +44,7 @@ export default function canvasSmokeTest({ getService, getPageObjects }) { const url = await browser.getCurrentUrl(); // remove all the search params, just compare the route - const hashRoute = parse(url).hash.split('?')[0]; + const hashRoute = new URL(url).hash.split('?')[0]; expect(hashRoute).to.equal(`#/workpad/${testWorkpadId}/page/1`); }); }); diff --git a/x-pack/test/functional/apps/monitoring/cluster/list.js b/x-pack/test/functional/apps/monitoring/cluster/list.js index f88e30f717141..09361f88f5652 100644 --- a/x-pack/test/functional/apps/monitoring/cluster/list.js +++ b/x-pack/test/functional/apps/monitoring/cluster/list.js @@ -26,6 +26,8 @@ export default function ({ getService, getPageObjects }) { to: 'Aug 16, 2017 @ 00:00:00.000', }); + await clusterList.closeAlertsModal(); + await clusterList.assertDefaults(); }); @@ -83,6 +85,8 @@ export default function ({ getService, getPageObjects }) { to: 'Sep 7, 2017 @ 20:18:55.733', }); + await clusterList.closeAlertsModal(); + await clusterList.assertDefaults(); }); diff --git a/x-pack/test/functional/apps/monitoring/elasticsearch/nodes_mb.js b/x-pack/test/functional/apps/monitoring/elasticsearch/nodes_mb.js index 7e9a0fec70824..a031b828e2632 100644 --- a/x-pack/test/functional/apps/monitoring/elasticsearch/nodes_mb.js +++ b/x-pack/test/functional/apps/monitoring/elasticsearch/nodes_mb.js @@ -263,7 +263,7 @@ export default function ({ getService, getPageObjects }) { } ); - overview.closeAlertsModal(); + await overview.closeAlertsModal(); // go to nodes listing await overview.clickEsNodes(); diff --git a/x-pack/test/functional/apps/monitoring/index.js b/x-pack/test/functional/apps/monitoring/index.js index 24ace88f334f0..213007c7b71df 100644 --- a/x-pack/test/functional/apps/monitoring/index.js +++ b/x-pack/test/functional/apps/monitoring/index.js @@ -46,7 +46,7 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./beats/listing')); loadTestFile(require.resolve('./beats/beat_detail')); - loadTestFile(require.resolve('./time_filter')); + // loadTestFile(require.resolve('./time_filter')); loadTestFile(require.resolve('./enable_monitoring')); loadTestFile(require.resolve('./setup/metricbeat_migration')); diff --git a/x-pack/test/functional/es_archives/rule_registry/alerts/data.json b/x-pack/test/functional/es_archives/rule_registry/alerts/data.json new file mode 100644 index 0000000000000..a9837210c2e5a --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_registry/alerts/data.json @@ -0,0 +1,29 @@ +{ + "type": "doc", + "value": { + "index": ".alerts-observability-apm", + "id": "NoxgpHkBqbdrfX07MqXV", + "source": { + "@timestamp": "2020-12-16T15:16:18.570Z", + "rule.id": "apm.error_rate", + "message": "hello world 1", + "kibana.rac.alert.owner": "apm", + "kibana.rac.alert.status": "open" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".alerts-security.alerts", + "id": "020202", + "source": { + "@timestamp": "2020-12-16T15:16:18.570Z", + "rule.id": "siem.signals", + "message": "hello world security", + "kibana.rac.alert.owner": "siem", + "kibana.rac.alert.status": "open" + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_registry/alerts/mappings.json b/x-pack/test/functional/es_archives/rule_registry/alerts/mappings.json new file mode 100644 index 0000000000000..4cb178d979982 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_registry/alerts/mappings.json @@ -0,0 +1,47 @@ +{ + "type": "index", + "value": { + "index": ".alerts-observability-apm", + "mappings": { + "properties": { + "message": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "kibana.rac.alert.owner": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } +} + +{ + "type": "index", + "value": { + "index": ".alerts-security.alerts", + "mappings": { + "properties": { + "message": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "kibana.rac.alert.owner": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } +} diff --git a/x-pack/test/functional/services/index.ts b/x-pack/test/functional/services/index.ts index 99293c71676b4..273db212400ab 100644 --- a/x-pack/test/functional/services/index.ts +++ b/x-pack/test/functional/services/index.ts @@ -9,6 +9,7 @@ import { services as kibanaFunctionalServices } from '../../../../test/functiona import { services as kibanaApiIntegrationServices } from '../../../../test/api_integration/services'; import { services as kibanaXPackApiIntegrationServices } from '../../api_integration/services'; import { services as commonServices } from '../../common/services'; +import { ReportingFunctionalProvider } from '../../reporting_functional/services'; import { MonitoringNoDataProvider, @@ -107,5 +108,6 @@ export const services = { dashboardDrilldownPanelActions: DashboardDrilldownPanelActionsProvider, dashboardDrilldownsManage: DashboardDrilldownsManageProvider, dashboardPanelTimeRange: DashboardPanelTimeRangeProvider, + reporting: ReportingFunctionalProvider, searchSessions: SearchSessionsService, }; diff --git a/x-pack/test/functional/services/monitoring/cluster_list.js b/x-pack/test/functional/services/monitoring/cluster_list.js index aea82fbb6b793..f63e7b6cd125e 100644 --- a/x-pack/test/functional/services/monitoring/cluster_list.js +++ b/x-pack/test/functional/services/monitoring/cluster_list.js @@ -15,6 +15,7 @@ export function MonitoringClusterListProvider({ getService, getPageObjects }) { const SUBJ_SEARCH_BAR = `${SUBJ_TABLE_CONTAINER} > monitoringTableToolBar`; const SUBJ_CLUSTER_ROW_PREFIX = `${SUBJ_TABLE_CONTAINER} > clusterRow_`; + const ALERTS_MODAL_BUTTON = 'alerts-modal-button'; return new (class ClusterList { async assertDefaults() { @@ -41,6 +42,10 @@ export function MonitoringClusterListProvider({ getService, getPageObjects }) { return PageObjects.monitoring.tableClearFilter(SUBJ_SEARCH_BAR); } + closeAlertsModal() { + return testSubjects.click(ALERTS_MODAL_BUTTON); + } + getClusterLink(clusterUuid) { return testSubjects.find(`${SUBJ_CLUSTER_ROW_PREFIX}${clusterUuid} > clusterLink`); } diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/ilm_migration_apis.ts b/x-pack/test/reporting_api_integration/reporting_without_security/ilm_migration_apis.ts index a0f4a3f91fe32..a9b6798a0224f 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/ilm_migration_apis.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security/ilm_migration_apis.ts @@ -47,7 +47,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('detects when reporting indices should be migrated due to missing ILM policy', async () => { - await reportingAPI.makeAllReportingPoliciesUnmanaged(); + await reportingAPI.makeAllReportingIndicesUnmanaged(); // TODO: Remove "any" when no longer through type issue "policy_id" missing await es.ilm.deleteLifecycle({ policy: ILM_POLICY_NAME } as any); @@ -63,7 +63,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('detects when reporting indices should be migrated due to unmanaged indices', async () => { - await reportingAPI.makeAllReportingPoliciesUnmanaged(); + await reportingAPI.makeAllReportingIndicesUnmanaged(); await supertestNoAuth .post(`/api/reporting/generate/csv`) .set('kbn-xsrf', 'xxx') diff --git a/x-pack/test/reporting_api_integration/services/scenarios.ts b/x-pack/test/reporting_api_integration/services/scenarios.ts index eb32de9d0dc9c..08c07e0e257ed 100644 --- a/x-pack/test/reporting_api_integration/services/scenarios.ts +++ b/x-pack/test/reporting_api_integration/services/scenarios.ts @@ -181,8 +181,8 @@ export function createScenarios({ getService }: Pick { - log.debug('ReportingAPI.makeAllReportingPoliciesUnmanaged'); + const makeAllReportingIndicesUnmanaged = async () => { + log.debug('ReportingAPI.makeAllReportingIndicesUnmanaged'); const settings: any = { 'index.lifecycle.name': null, }; @@ -214,6 +214,6 @@ export function createScenarios({ getService }: Pick { + const xPackApiIntegrationTestsConfig = await readConfigFile( + require.resolve('../../api_integration/config.ts') + ); + + const servers = { + ...xPackApiIntegrationTestsConfig.get('servers'), + elasticsearch: { + ...xPackApiIntegrationTestsConfig.get('servers.elasticsearch'), + protocol: ssl ? 'https' : 'http', + }, + }; + + return { + testFiles: testFiles ? testFiles : [require.resolve('../tests/common')], + servers, + services, + junit: { + reportName: 'X-Pack Rule Registry Alerts Client API Integration Tests', + }, + esTestCluster: { + ...xPackApiIntegrationTestsConfig.get('esTestCluster'), + license, + ssl, + serverArgs: [ + `xpack.license.self_generated.type=${license}`, + `xpack.security.enabled=${ + !disabledPlugins.includes('security') && ['trial', 'basic'].includes(license) + }`, + ], + }, + kbnTestServer: { + ...xPackApiIntegrationTestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackApiIntegrationTestsConfig.get('kbnTestServer.serverArgs'), + `--xpack.actions.allowedHosts=${JSON.stringify(['localhost', 'some.non.existent.com'])}`, + `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, + '--xpack.eventLog.logEntries=true', + ...disabledPlugins.map((key) => `--xpack.${key}.enabled=false`), + `--server.xsrf.whitelist=${JSON.stringify(getAllExternalServiceSimulatorPaths())}`, + ...(ssl + ? [ + `--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, + `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, + ] + : []), + ], + }, + }; + }; +} diff --git a/x-pack/test/rule_registry/common/ftr_provider_context.d.ts b/x-pack/test/rule_registry/common/ftr_provider_context.d.ts new file mode 100644 index 0000000000000..aa56557c09df8 --- /dev/null +++ b/x-pack/test/rule_registry/common/ftr_provider_context.d.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GenericFtrProviderContext } from '@kbn/test'; + +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/rule_registry/common/lib/authentication/index.ts b/x-pack/test/rule_registry/common/lib/authentication/index.ts new file mode 100644 index 0000000000000..f76159976a902 --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/authentication/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext as CommonFtrProviderContext } from '../../../common/ftr_provider_context'; +import { Role, User, UserInfo } from './types'; +import { allUsers } from './users'; +import { allRoles } from './roles'; +import { spaces } from './spaces'; + +export const getUserInfo = (user: User): UserInfo => ({ + username: user.username, + full_name: user.username.replace('_', ' '), + email: `${user.username}@elastic.co`, +}); + +export const createSpaces = async (getService: CommonFtrProviderContext['getService']) => { + const spacesService = getService('spaces'); + for (const space of spaces) { + await spacesService.create(space); + } +}; + +/** + * Creates the users and roles for use in the tests. Defaults to specific users and roles used by the security_and_spaces + * scenarios but can be passed specific ones as well. + */ +export const createUsersAndRoles = async ( + getService: CommonFtrProviderContext['getService'], + usersToCreate: User[] = allUsers, + rolesToCreate: Role[] = allRoles +) => { + const security = getService('security'); + + const createRole = async ({ name, privileges }: Role) => { + return security.role.create(name, privileges); + }; + + const createUser = async (user: User) => { + const userInfo = getUserInfo(user); + + return security.user.create(user.username, { + password: user.password, + roles: user.roles, + full_name: userInfo.full_name, + email: userInfo.email, + }); + }; + + for (const role of rolesToCreate) { + await createRole(role); + } + + for (const user of usersToCreate) { + await createUser(user); + } +}; + +export const deleteSpaces = async (getService: CommonFtrProviderContext['getService']) => { + const spacesService = getService('spaces'); + for (const space of spaces) { + try { + await spacesService.delete(space.id); + } catch (error) { + // ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users + } + } +}; + +export const deleteUsersAndRoles = async ( + getService: CommonFtrProviderContext['getService'], + usersToDelete: User[] = allUsers, + rolesToDelete: Role[] = allRoles +) => { + const security = getService('security'); + + for (const user of usersToDelete) { + try { + await security.user.delete(user.username); + } catch (error) { + // ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users + } + } + + for (const role of rolesToDelete) { + try { + await security.role.delete(role.name); + } catch (error) { + // ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users + } + } +}; + +export const createSpacesAndUsers = async (getService: CommonFtrProviderContext['getService']) => { + await createSpaces(getService); + await createUsersAndRoles(getService); +}; + +export const deleteSpacesAndUsers = async (getService: CommonFtrProviderContext['getService']) => { + await deleteSpaces(getService); + await deleteUsersAndRoles(getService); +}; diff --git a/x-pack/test/rule_registry/common/lib/authentication/roles.ts b/x-pack/test/rule_registry/common/lib/authentication/roles.ts new file mode 100644 index 0000000000000..e38378dcfc8f2 --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/authentication/roles.ts @@ -0,0 +1,435 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Role } from './types'; + +export const noKibanaPrivileges: Role = { + name: 'no_kibana_privileges', + privileges: { + elasticsearch: { + indices: [], + }, + }, +}; + +export const globalRead: Role = { + name: 'global_read', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['read'], + apm: ['read'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const securitySolutionOnlyAll: Role = { + name: 'sec_only_all_spaces_space1', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const securitySolutionOnlyAllSpace2: Role = { + name: 'sec_only_all_spaces_space2', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['all'], + }, + spaces: ['space2'], + }, + ], + }, +}; + +export const securitySolutionOnlyRead: Role = { + name: 'sec_only_read_spaces_space1', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['read'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const securitySolutionOnlyReadSpace2: Role = { + name: 'sec_only_read_spaces_space2', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['read'], + }, + spaces: ['space2'], + }, + ], + }, +}; + +export const observabilityOnlyAll: Role = { + name: 'obs_only_all_spaces_space1', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const observabilityOnlyAllSpace2: Role = { + name: 'obs_only_all_spaces_space2', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['all'], + }, + spaces: ['space2'], + }, + ], + }, +}; + +export const observabilityOnlyRead: Role = { + name: 'obs_only_read_spaces_space1', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['read'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const observabilityOnlyReadSpace2: Role = { + name: 'obs_only_read_spaces_space2', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['read'], + }, + spaces: ['space2'], + }, + ], + }, +}; + +/** + * These roles have access to all spaces. + */ +export const securitySolutionOnlyAllSpacesAll: Role = { + name: 'sec_only_all_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const securitySolutionOnlyReadSpacesAll: Role = { + name: 'sec_only_read_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + siem: ['read'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const observabilityOnlyAllSpacesAll: Role = { + name: 'obs_only_all_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const observabilityOnlyReadSpacesAll: Role = { + name: 'obs_only_read_all_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['read'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const roles = [ + noKibanaPrivileges, + globalRead, + securitySolutionOnlyAll, + securitySolutionOnlyRead, + observabilityOnlyAll, + observabilityOnlyRead, + observabilityOnlyReadSpacesAll, +]; + +/** + * These roles are only to be used in the 'trial' tests + * since they rely on subfeature privileges which are a gold licencse feature + * maybe put these roles into a separate roles file like "trial_roles"? + */ +export const observabilityMinReadAlertsRead: Role = { + name: 'obs_only_alerts_read', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['minimal_read', 'alerts_read'], + ruleRegistry: ['all'], + actions: ['read'], + builtInAlerts: ['all'], + alerting: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const observabilityMinReadAlertsReadSpacesAll: Role = { + name: 'obs_minimal_read_alerts_read_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['minimal_read', 'alerts_read'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const observabilityMinimalRead: Role = { + name: 'obs_minimal_read', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['minimal_read'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const observabilityMinimalReadSpacesAll: Role = { + name: 'obs_minimal_read_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['minimal_read'], + }, + spaces: ['*'], + }, + ], + }, +}; + +/** + * **************************************** + * These are used for testing update alerts privileges + * **************************************** + * **************************************** + * **************************************** + * **************************************** + * **************************************** + * **************************************** + * **************************************** + * **************************************** + */ + +export const observabilityMinReadAlertsAll: Role = { + name: 'obs_only_alerts_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['minimal_read', 'alerts_all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const observabilityMinReadAlertsAllSpacesAll: Role = { + name: 'obs_minimal_read_alerts_all_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['minimal_read', 'alerts_all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const observabilityMinimalAll: Role = { + name: 'obs_minimal_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['minimal_all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const observabilityMinimalAllSpacesAll: Role = { + name: 'obs_minimal_all_spaces_all', + privileges: { + elasticsearch: { + indices: [], + }, + kibana: [ + { + feature: { + apm: ['minimal_all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const allRoles = [ + noKibanaPrivileges, + globalRead, + securitySolutionOnlyAll, + securitySolutionOnlyRead, + observabilityOnlyAll, + observabilityOnlyRead, + securitySolutionOnlyAllSpacesAll, + securitySolutionOnlyReadSpacesAll, + observabilityOnlyAllSpacesAll, + observabilityOnlyReadSpacesAll, + observabilityMinReadAlertsRead, + observabilityMinReadAlertsReadSpacesAll, + observabilityMinimalRead, + observabilityMinimalReadSpacesAll, + observabilityMinReadAlertsAll, + observabilityMinReadAlertsAllSpacesAll, + observabilityMinimalAll, + observabilityMinimalAllSpacesAll, + securitySolutionOnlyAllSpace2, + securitySolutionOnlyReadSpace2, + observabilityOnlyAllSpace2, + observabilityOnlyReadSpace2, +]; diff --git a/x-pack/test/rule_registry/common/lib/authentication/spaces.ts b/x-pack/test/rule_registry/common/lib/authentication/spaces.ts new file mode 100644 index 0000000000000..556b1686601ff --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/authentication/spaces.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Space } from './types'; + +const space1: Space = { + id: 'space1', + name: 'Space 1', + disabledFeatures: [], +}; + +const space2: Space = { + id: 'space2', + name: 'Space 2', + disabledFeatures: [], +}; + +export const spaces: Space[] = [space1, space2]; + +export const getSpaceUrlPrefix = (spaceId?: string) => { + return spaceId && spaceId !== 'default' ? `/s/${spaceId}` : ``; +}; diff --git a/x-pack/test/rule_registry/common/lib/authentication/types.ts b/x-pack/test/rule_registry/common/lib/authentication/types.ts new file mode 100644 index 0000000000000..3bf3629441f93 --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/authentication/types.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface Space { + id: string; + namespace?: string; + name: string; + disabledFeatures: string[]; +} + +export interface User { + username: string; + password: string; + description?: string; + roles: string[]; +} + +export interface UserInfo { + username: string; + full_name: string; + email: string; +} + +interface FeaturesPrivileges { + [featureId: string]: string[]; +} + +interface ElasticsearchIndices { + names: string[]; + privileges: string[]; +} + +export interface ElasticSearchPrivilege { + cluster?: string[]; + indices?: ElasticsearchIndices[]; +} + +export interface KibanaPrivilege { + spaces: string[]; + base?: string[]; + feature?: FeaturesPrivileges; +} + +export interface Role { + name: string; + privileges: { + elasticsearch?: ElasticSearchPrivilege; + kibana?: KibanaPrivilege[]; + }; +} diff --git a/x-pack/test/rule_registry/common/lib/authentication/users.ts b/x-pack/test/rule_registry/common/lib/authentication/users.ts new file mode 100644 index 0000000000000..e142b3d1f56a3 --- /dev/null +++ b/x-pack/test/rule_registry/common/lib/authentication/users.ts @@ -0,0 +1,301 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + securitySolutionOnlyAll, + observabilityOnlyAll, + securitySolutionOnlyRead, + observabilityOnlyRead, + globalRead as globalReadRole, + noKibanaPrivileges as noKibanaPrivilegesRole, + securitySolutionOnlyAllSpacesAll, + securitySolutionOnlyReadSpacesAll, + observabilityOnlyAllSpacesAll, + observabilityOnlyReadSpacesAll, + // trial license roles + observabilityMinReadAlertsAll, + observabilityMinReadAlertsRead, + observabilityMinReadAlertsReadSpacesAll, + observabilityMinimalRead, + observabilityMinimalReadSpacesAll, + securitySolutionOnlyAllSpace2, + securitySolutionOnlyReadSpace2, + observabilityOnlyAllSpace2, + observabilityOnlyReadSpace2, + observabilityMinReadAlertsAllSpacesAll, +} from './roles'; +import { User } from './types'; + +export const superUser: User = { + username: 'superuser', + password: 'superuser', + roles: ['superuser'], +}; + +export const secOnly: User = { + username: 'sec_only_all_spaces_space1', + password: 'sec_only_all_spaces_space1', + roles: [securitySolutionOnlyAll.name], +}; + +export const secOnlySpace2: User = { + username: 'sec_only_all_spaces_space2', + password: 'sec_only_all_spaces_space2', + roles: [securitySolutionOnlyAllSpace2.name], +}; + +export const secOnlyRead: User = { + username: 'sec_only_read_spaces_space1', + password: 'sec_only_read_spaces_space1', + roles: [securitySolutionOnlyRead.name], +}; + +export const secOnlyReadSpace2: User = { + username: 'sec_only_read_spaces_space2', + password: 'sec_only_read_spaces_space2', + roles: [securitySolutionOnlyReadSpace2.name], +}; + +export const obsOnly: User = { + username: 'obs_only_all_spaces_space1', + password: 'obs_only_all_spaces_space1', + roles: [observabilityOnlyAll.name], +}; + +export const obsOnlySpace2: User = { + username: 'obs_only_all_spaces_space2', + password: 'obs_only_all_spaces_space2', + roles: [observabilityOnlyAllSpace2.name], +}; + +export const obsOnlyRead: User = { + username: 'obs_only_read_spaces_space1', + password: 'obs_only_read_spaces_space1', + roles: [observabilityOnlyRead.name], +}; + +export const obsOnlyReadSpace2: User = { + username: 'obs_only_read_spaces_space2', + password: 'obs_only_read_spaces_space2', + roles: [observabilityOnlyReadSpace2.name], +}; + +export const obsSec: User = { + username: 'sec_only_all_spaces_space1_and_obs_only_all_spaces_space1', + password: 'sec_only_all_spaces_space1_and_obs_only_all_spaces_space1', + roles: [securitySolutionOnlyAll.name, observabilityOnlyAll.name], +}; + +export const obsSecAllSpace2: User = { + username: 'sec_only_all_spaces_space2_and_obs_only_all_spaces_space2', + password: 'sec_only_all_spaces_space2_and_obs_only_all_spaces_space2', + roles: [securitySolutionOnlyAllSpace2.name, observabilityOnlyAllSpace2.name], +}; + +export const obsSecRead: User = { + username: 'sec_only_read_spaces_space1_and_obs_only_read_spaces_space1', + password: 'sec_only_read_spaces_space1_and_obs_only_read_spaces_space1', + roles: [securitySolutionOnlyRead.name, observabilityOnlyRead.name], +}; + +export const obsSecReadSpace2: User = { + username: 'sec_only_read_spaces_space2_and_obs_only_read_spaces_space2', + password: 'sec_only_read_spaces_space2_and_obs_only_read_spaces_space2', + roles: [securitySolutionOnlyReadSpace2.name, observabilityOnlyReadSpace2.name], +}; + +export const globalRead: User = { + username: 'global_read', + password: 'global_read', + roles: [globalReadRole.name], +}; + +export const noKibanaPrivileges: User = { + username: 'no_kibana_privileges', + password: 'no_kibana_privileges', + roles: [noKibanaPrivilegesRole.name], +}; + +export const obsOnlyReadSpacesAll: User = { + username: 'obs_only_read_all_spaces_all', + password: 'obs_only_read_all_spaces_all', + roles: [observabilityOnlyReadSpacesAll.name], +}; + +export const users = [ + superUser, + secOnly, + secOnlyRead, + obsOnly, + obsOnlyRead, + obsSec, + obsSecRead, + globalRead, + noKibanaPrivileges, + obsOnlyReadSpacesAll, +]; + +/** + * These users will have access to all spaces. + */ + +export const secOnlySpacesAll: User = { + username: 'sec_only_all_spaces_all', + password: 'sec_only_all_spaces_all', + roles: [securitySolutionOnlyAllSpacesAll.name], +}; + +export const secOnlyReadSpacesAll: User = { + username: 'sec_only_read_spaces_all', + password: 'sec_only_read_spaces_all', + roles: [securitySolutionOnlyReadSpacesAll.name], +}; + +export const obsOnlySpacesAll: User = { + username: 'obs_only_all_spaces_all', + password: 'obs_only_all_spaces_all', + roles: [observabilityOnlyAllSpacesAll.name], +}; + +export const obsSecSpacesAll: User = { + username: 'sec_only_all_spaces_all_and_obs_only_all_spaces_all', + password: 'sec_only_all_spaces_all_and_obs_only_all_spaces_all', + roles: [securitySolutionOnlyAllSpacesAll.name, observabilityOnlyAllSpacesAll.name], +}; + +export const obsSecReadSpacesAll: User = { + username: 'sec_only_read_all_spaces_all_and_obs_only_read_all_spaces_all', + password: 'sec_only_read_all_spaces_all_and_obs_only_read_all_spaces_all', + roles: [securitySolutionOnlyReadSpacesAll.name, observabilityOnlyReadSpacesAll.name], +}; + +/** + * These users are for the security_only tests because most of them have access to the default space instead of 'space1' + */ +export const usersDefaultSpace = [ + superUser, + secOnlySpacesAll, + secOnlyReadSpacesAll, + obsOnlySpacesAll, + obsOnlyReadSpacesAll, + obsSecSpacesAll, + obsSecReadSpacesAll, + globalRead, + noKibanaPrivileges, +]; + +/** + * Trial users with trial roles + */ + +// apm: ['minimal_read', 'alerts_read'] +// spaces: ['space1'] +export const obsMinReadAlertsRead: User = { + username: 'obs_minimal_read_alerts_read_single_space', + password: 'obs_minimal_read_alerts_read_single_space', + roles: [observabilityMinReadAlertsRead.name], +}; + +// apm: ['minimal_read', 'alerts_read'] +// spaces: ['*'] +export const obsMinReadAlertsReadSpacesAll: User = { + username: 'obs_minimal_read_alerts_read_all_spaces', + password: 'obs_minimal_read_alerts_read_all_spaces', + roles: [observabilityMinReadAlertsReadSpacesAll.name], +}; + +// apm: ['minimal_read'] +// spaces: ['space1'] +export const obsMinRead: User = { + username: 'obs_minimal_read_single_space', + password: 'obs_minimal_read_single_space', + roles: [observabilityMinimalRead.name], +}; + +// apm: ['minimal_read'] +// spaces: ['*'] +export const obsMinReadSpacesAll: User = { + username: 'obs_minimal_read_all_space', + password: 'obs_minimal_read_all_space', + roles: [observabilityMinimalReadSpacesAll.name], +}; + +// FOR UPDATES +// apm: ['minimal_read', 'alerts_all'] +// spaces: ['space1'] +export const obsMinReadAlertsAll: User = { + username: 'obs_minimal_read_alerts_all_single_space', + password: 'obs_minimal_read_alerts_all_single_space', + roles: [observabilityMinReadAlertsAll.name], +}; + +// apm: ['minimal_read', 'alerts_all'] +// spaces: ['*'] +export const obsMinReadAlertsAllSpacesAll: User = { + username: 'obs_minimal_read_alerts_all_all_spaces', + password: 'obs_minimal_read_alerts_all_all_spaces', + roles: [observabilityMinReadAlertsAllSpacesAll.name], +}; + +// apm: ['minimal_all'] +// spaces: ['space1'] +export const obsMinAll: User = { + username: 'obs_minimal_all_single_space', + password: 'obs_minimal_all_single_space', + roles: [observabilityMinimalRead.name], +}; + +// apm: ['minimal_all'] +// spaces: ['*'] +export const obsMinAllSpacesAll: User = { + username: 'obs_minimal_all_all_space', + password: 'obs_minimal_read_all_space', + roles: [observabilityMinimalReadSpacesAll.name], +}; + +export const trialUsers = [ + obsMinReadAlertsRead, + obsMinReadAlertsReadSpacesAll, + obsMinRead, + obsMinReadSpacesAll, + obsMinReadAlertsAll, + obsMinReadAlertsAllSpacesAll, + obsMinAll, + obsMinAllSpacesAll, +]; + +export const allUsers = [ + superUser, + secOnly, + secOnlyRead, + obsOnly, + obsOnlyRead, + obsSec, + obsSecRead, + globalRead, + noKibanaPrivileges, + obsOnlyReadSpacesAll, + secOnlySpacesAll, + secOnlyReadSpacesAll, + obsOnlySpacesAll, + obsSecSpacesAll, + obsSecReadSpacesAll, + obsMinReadAlertsRead, + obsMinReadAlertsReadSpacesAll, + obsMinRead, + obsMinReadSpacesAll, + obsMinReadAlertsAll, + obsMinReadAlertsAllSpacesAll, + obsMinAll, + obsMinAllSpacesAll, + secOnlySpace2, + secOnlyReadSpace2, + obsOnlySpace2, + obsOnlyReadSpace2, + obsSecAllSpace2, + obsSecReadSpace2, +]; diff --git a/x-pack/test/rule_registry/common/services.ts b/x-pack/test/rule_registry/common/services.ts new file mode 100644 index 0000000000000..7e415338c405f --- /dev/null +++ b/x-pack/test/rule_registry/common/services.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { services } from '../../api_integration/services'; diff --git a/x-pack/test/rule_registry/security_and_spaces/config_basic.ts b/x-pack/test/rule_registry/security_and_spaces/config_basic.ts new file mode 100644 index 0000000000000..98b7b1abe98e7 --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/config_basic.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createTestConfig } from '../common/config'; + +// eslint-disable-next-line import/no-default-export +export default createTestConfig('security_and_spaces', { + license: 'basic', + ssl: true, + testFiles: [require.resolve('./tests/basic')], +}); diff --git a/x-pack/test/rule_registry/security_and_spaces/config_trial.ts b/x-pack/test/rule_registry/security_and_spaces/config_trial.ts new file mode 100644 index 0000000000000..b5328fd83c2cb --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/config_trial.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createTestConfig } from '../common/config'; + +// eslint-disable-next-line import/no-default-export +export default createTestConfig('security_and_spaces', { + license: 'trial', + ssl: true, + testFiles: [require.resolve('./tests/trial')], +}); diff --git a/x-pack/test/rule_registry/security_and_spaces/roles_users_utils/index.ts b/x-pack/test/rule_registry/security_and_spaces/roles_users_utils/index.ts new file mode 100644 index 0000000000000..b320446cbe05f --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/roles_users_utils/index.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { assertUnreachable } from '../../../../plugins/security_solution/common/utility_types'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + t1AnalystUser, + t2AnalystUser, + hunterUser, + ruleAuthorUser, + socManagerUser, + platformEngineerUser, + detectionsAdminUser, + readerUser, + t1AnalystRole, + t2AnalystRole, + hunterRole, + ruleAuthorRole, + socManagerRole, + platformEngineerRole, + detectionsAdminRole, + readerRole, +} from '../../../../plugins/security_solution/server/lib/detection_engine/scripts/roles_users'; + +import { ROLES } from '../../../../plugins/security_solution/common/test'; + +export const createUserAndRole = async ( + getService: FtrProviderContext['getService'], + role: ROLES +): Promise => { + switch (role) { + case ROLES.detections_admin: + return postRoleAndUser( + ROLES.detections_admin, + detectionsAdminRole, + detectionsAdminUser, + getService + ); + case ROLES.t1_analyst: + return postRoleAndUser(ROLES.t1_analyst, t1AnalystRole, t1AnalystUser, getService); + case ROLES.t2_analyst: + return postRoleAndUser(ROLES.t2_analyst, t2AnalystRole, t2AnalystUser, getService); + case ROLES.hunter: + return postRoleAndUser(ROLES.hunter, hunterRole, hunterUser, getService); + case ROLES.rule_author: + return postRoleAndUser(ROLES.rule_author, ruleAuthorRole, ruleAuthorUser, getService); + case ROLES.soc_manager: + return postRoleAndUser(ROLES.soc_manager, socManagerRole, socManagerUser, getService); + case ROLES.platform_engineer: + return postRoleAndUser( + ROLES.platform_engineer, + platformEngineerRole, + platformEngineerUser, + getService + ); + case ROLES.reader: + return postRoleAndUser(ROLES.reader, readerRole, readerUser, getService); + default: + return assertUnreachable(role); + } +}; + +/** + * Given a roleName and security service this will delete the roleName + * and user + * @param roleName The user and role to delete with the same name + * @param securityService The security service + */ +export const deleteUserAndRole = async ( + getService: FtrProviderContext['getService'], + roleName: ROLES +): Promise => { + const securityService = getService('security'); + await securityService.user.delete(roleName); + await securityService.role.delete(roleName); +}; + +interface UserInterface { + password: string; + roles: string[]; + full_name: string; + email: string; +} + +interface RoleInterface { + elasticsearch: { + cluster: string[]; + indices: Array<{ + names: string[]; + privileges: string[]; + }>; + }; + kibana: Array<{ + feature: { + ml: string[]; + siem: string[]; + actions: string[]; + builtInAlerts: string[]; + }; + spaces: string[]; + }>; +} + +export const postRoleAndUser = async ( + roleName: string, + role: RoleInterface, + user: UserInterface, + getService: FtrProviderContext['getService'] +): Promise => { + const securityService = getService('security'); + await securityService.role.create(roleName, { + kibana: role.kibana, + elasticsearch: role.elasticsearch, + }); + await securityService.user.create(roleName, { + password: 'changeme', + full_name: user.full_name, + roles: user.roles, + }); +}; diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/basic/get_alert_by_id.ts b/x-pack/test/rule_registry/security_and_spaces/tests/basic/get_alert_by_id.ts new file mode 100644 index 0000000000000..cf3cc88f2cfc0 --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/tests/basic/get_alert_by_id.ts @@ -0,0 +1,210 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { + superUser, + globalRead, + obsOnly, + obsOnlyRead, + obsSec, + obsSecRead, + secOnly, + secOnlyRead, + secOnlySpace2, + secOnlyReadSpace2, + obsSecAllSpace2, + obsSecReadSpace2, + obsOnlySpace2, + obsOnlyReadSpace2, + obsOnlySpacesAll, + obsSecSpacesAll, + secOnlySpacesAll, + noKibanaPrivileges, +} from '../../../common/lib/authentication/users'; +import type { User } from '../../../common/lib/authentication/types'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { getSpaceUrlPrefix } from '../../../common/lib/authentication/spaces'; + +interface TestCase { + /** The space where the alert exists */ + space: string; + /** The ID of the alert */ + alertId: string; + /** The index of the alert */ + index: string; + /** Authorized users */ + authorizedUsers: User[]; + /** Unauthorized users */ + unauthorizedUsers: User[]; +} + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const TEST_URL = '/internal/rac/alerts'; + const ALERTS_INDEX_URL = `${TEST_URL}/index`; + const SPACE1 = 'space1'; + const SPACE2 = 'space2'; + const APM_ALERT_ID = 'NoxgpHkBqbdrfX07MqXV'; + const APM_ALERT_INDEX = '.alerts-observability-apm'; + const SECURITY_SOLUTION_ALERT_ID = '020202'; + const SECURITY_SOLUTION_ALERT_INDEX = '.alerts-security.alerts'; + + const getAPMIndexName = async (user: User) => { + const { + body: indexNames, + }: { body: { index_name: string[] | undefined } } = await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${ALERTS_INDEX_URL}`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'true') + .expect(200); + const observabilityIndex = indexNames?.index_name?.find( + (indexName) => indexName === APM_ALERT_INDEX + ); + expect(observabilityIndex).to.eql(APM_ALERT_INDEX); // assert this here so we can use constants in the dynamically-defined test cases below + }; + + const getSecuritySolutionIndexName = async (user: User) => { + const { + body: indexNames, + }: { body: { index_name: string[] | undefined } } = await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${ALERTS_INDEX_URL}`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'true') + .expect(200); + const securitySolution = indexNames?.index_name?.find( + (indexName) => indexName === SECURITY_SOLUTION_ALERT_INDEX + ); + expect(securitySolution).to.eql(SECURITY_SOLUTION_ALERT_INDEX); // assert this here so we can use constants in the dynamically-defined test cases below + }; + + describe('Alerts - GET - RBAC - spaces', () => { + before(async () => { + await getSecuritySolutionIndexName(superUser); + await getAPMIndexName(superUser); + + await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/rule_registry/alerts'); + }); + + function addTests({ space, authorizedUsers, unauthorizedUsers, alertId, index }: TestCase) { + authorizedUsers.forEach(({ username, password }) => { + it(`${username} should be able to access alert ${alertId} in ${space}/${index}`, async () => { + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(space)}${TEST_URL}?id=${alertId}&index=${index}`) + .auth(username, password) + .set('kbn-xsrf', 'true') + .expect(200); + }); + + it(`${username} should fail to access a non-existent alert in ${space}/${index}`, async () => { + const fakeAlertId = 'some-alert-id-that-doesnt-exist'; + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(space)}${TEST_URL}?id=${fakeAlertId}&index=${index}`) + .auth(username, password) + .set('kbn-xsrf', 'true') + .expect(404); + }); + + it(`${username} should return a 404 when trying to accesses not-existent alerts as data index`, async () => { + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(space)}${TEST_URL}?id=${APM_ALERT_ID}&index=myfakeindex`) + .auth(username, password) + .set('kbn-xsrf', 'true') + .expect(404); + }); + }); + + unauthorizedUsers.forEach(({ username, password }) => { + it(`${username} should NOT be able to access alert ${alertId} in ${space}/${index}`, async () => { + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(space)}${TEST_URL}?id=${alertId}&index=${index}`) + .auth(username, password) + .set('kbn-xsrf', 'true') + .expect(403); + }); + }); + } + + describe('Security Solution', () => { + const authorizedInAllSpaces = [superUser, globalRead, secOnlySpacesAll, obsSecSpacesAll]; + const authorizedOnlyInSpace1 = [secOnly, secOnlyRead, obsSec, obsSecRead]; + const authorizedOnlyInSpace2 = [ + secOnlySpace2, + secOnlyReadSpace2, + obsSecAllSpace2, + obsSecReadSpace2, + ]; + const unauthorized = [ + // these users are not authorized to access alerts for the Security Solution in any space + obsOnly, + obsOnlyRead, + obsOnlySpace2, + obsOnlyReadSpace2, + obsOnlySpacesAll, + noKibanaPrivileges, + ]; + + addTests({ + space: SPACE1, + alertId: SECURITY_SOLUTION_ALERT_ID, + index: SECURITY_SOLUTION_ALERT_INDEX, + authorizedUsers: [...authorizedInAllSpaces, ...authorizedOnlyInSpace1], + unauthorizedUsers: [...authorizedOnlyInSpace2, ...unauthorized], + }); + addTests({ + space: SPACE2, + alertId: SECURITY_SOLUTION_ALERT_ID, + index: SECURITY_SOLUTION_ALERT_INDEX, + authorizedUsers: [...authorizedInAllSpaces, ...authorizedOnlyInSpace2], + unauthorizedUsers: [...authorizedOnlyInSpace1, ...unauthorized], + }); + }); + + describe('APM', () => { + const authorizedInAllSpaces = [superUser, globalRead, obsOnlySpacesAll, obsSecSpacesAll]; + const authorizedOnlyInSpace1 = [obsOnly, obsOnlyRead, obsSec, obsSecRead]; + const authorizedOnlyInSpace2 = [ + obsOnlySpace2, + obsOnlyReadSpace2, + obsSecAllSpace2, + obsSecReadSpace2, + ]; + const unauthorized = [ + // these users are not authorized to access alerts for APM in any space + secOnly, + secOnlyRead, + secOnlySpace2, + secOnlyReadSpace2, + secOnlySpacesAll, + noKibanaPrivileges, + ]; + + addTests({ + space: SPACE1, + alertId: APM_ALERT_ID, + index: APM_ALERT_INDEX, + authorizedUsers: [...authorizedInAllSpaces, ...authorizedOnlyInSpace1], + unauthorizedUsers: [...authorizedOnlyInSpace2, ...unauthorized], + }); + addTests({ + space: SPACE2, + alertId: APM_ALERT_ID, + index: APM_ALERT_INDEX, + authorizedUsers: [...authorizedInAllSpaces, ...authorizedOnlyInSpace2], + unauthorizedUsers: [...authorizedOnlyInSpace1, ...unauthorized], + }); + }); + }); +}; diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/basic/index.ts b/x-pack/test/rule_registry/security_and_spaces/tests/basic/index.ts new file mode 100644 index 0000000000000..baea62c157218 --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/tests/basic/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { createSpacesAndUsers, deleteSpacesAndUsers } from '../../../common/lib/authentication'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile, getService }: FtrProviderContext): void => { + describe('rules security and spaces enabled: basic', function () { + // Fastest ciGroup for the moment. + this.tags('ciGroup5'); + + before(async () => { + await createSpacesAndUsers(getService); + }); + + after(async () => { + await deleteSpacesAndUsers(getService); + }); + + // Basic + loadTestFile(require.resolve('./get_alert_by_id')); + loadTestFile(require.resolve('./update_alert')); + }); +}; diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/basic/update_alert.ts b/x-pack/test/rule_registry/security_and_spaces/tests/basic/update_alert.ts new file mode 100644 index 0000000000000..4fb087e813768 --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/tests/basic/update_alert.ts @@ -0,0 +1,251 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; + +import { + superUser, + globalRead, + obsOnly, + obsOnlyRead, + obsSec, + obsSecRead, + secOnly, + secOnlyRead, + secOnlySpace2, + secOnlyReadSpace2, + obsSecAllSpace2, + obsSecReadSpace2, + obsOnlySpace2, + obsOnlyReadSpace2, + obsOnlySpacesAll, + obsSecSpacesAll, + secOnlySpacesAll, + noKibanaPrivileges, +} from '../../../common/lib/authentication/users'; +import type { User } from '../../../common/lib/authentication/types'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { getSpaceUrlPrefix } from '../../../common/lib/authentication/spaces'; + +interface TestCase { + /** The space where the alert exists */ + space: string; + /** The ID of the alert */ + alertId: string; + /** The index of the alert */ + index: string; + /** Authorized users */ + authorizedUsers: User[]; + /** Unauthorized users */ + unauthorizedUsers: User[]; +} + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const TEST_URL = '/internal/rac/alerts'; + const ALERTS_INDEX_URL = `${TEST_URL}/index`; + const SPACE1 = 'space1'; + const SPACE2 = 'space2'; + const APM_ALERT_ID = 'NoxgpHkBqbdrfX07MqXV'; + const APM_ALERT_INDEX = '.alerts-observability-apm'; + const SECURITY_SOLUTION_ALERT_ID = '020202'; + const SECURITY_SOLUTION_ALERT_INDEX = '.alerts-security.alerts'; + const ALERT_VERSION = Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'); // required for optimistic concurrency control + + const getAPMIndexName = async (user: User) => { + const { + body: indexNames, + }: { body: { index_name: string[] | undefined } } = await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${ALERTS_INDEX_URL}`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'true') + .expect(200); + const observabilityIndex = indexNames?.index_name?.find( + (indexName) => indexName === APM_ALERT_INDEX + ); + expect(observabilityIndex).to.eql(APM_ALERT_INDEX); // assert this here so we can use constants in the dynamically-defined test cases below + }; + + const getSecuritySolutionIndexName = async (user: User) => { + const { + body: indexNames, + }: { body: { index_name: string[] | undefined } } = await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${ALERTS_INDEX_URL}`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'true') + .expect(200); + const securitySolution = indexNames?.index_name?.find( + (indexName) => indexName === SECURITY_SOLUTION_ALERT_INDEX + ); + expect(securitySolution).to.eql(SECURITY_SOLUTION_ALERT_INDEX); // assert this here so we can use constants in the dynamically-defined test cases below + }; + + describe('Alert - Update - RBAC - spaces', () => { + before(async () => { + await getSecuritySolutionIndexName(superUser); + await getAPMIndexName(superUser); + }); + + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/rule_registry/alerts'); + }); + + function addTests({ space, authorizedUsers, unauthorizedUsers, alertId, index }: TestCase) { + authorizedUsers.forEach(({ username, password }) => { + it(`${username} should be able to update alert ${alertId} in ${space}/${index}`, async () => { + await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts'); // since this is a success case, reload the test data immediately beforehand + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(space)}${TEST_URL}`) + .auth(username, password) + .set('kbn-xsrf', 'true') + .send({ + ids: [alertId], + status: 'closed', + index, + _version: ALERT_VERSION, + }) + .expect(200); + }); + + it(`${username} should fail to update alert ${alertId} in ${space}/${index} with an incorrect version`, async () => { + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(space)}${TEST_URL}`) + .auth(username, password) + .set('kbn-xsrf', 'true') + .send({ + ids: [alertId], + status: 'closed', + index, + _version: Buffer.from(JSON.stringify([999, 999]), 'utf8').toString('base64'), + }) + .expect(409); + }); + + it(`${username} should fail to update a non-existent alert in ${space}/${index}`, async () => { + const fakeAlertId = 'some-alert-id-that-doesnt-exist'; + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(space)}${TEST_URL}`) + .auth(username, password) + .set('kbn-xsrf', 'true') + .send({ + ids: [fakeAlertId], + status: 'closed', + index, + _version: ALERT_VERSION, + }) + .expect(404); + }); + + it(`${username} should return a 404 when superuser accesses not-existent alerts as data index`, async () => { + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(space)}${TEST_URL}?id=${APM_ALERT_ID}&index=myfakeindex`) + .auth(username, password) + .set('kbn-xsrf', 'true') + .send({ + ids: [APM_ALERT_ID], + status: 'closed', + index: 'this index does not exist', + _version: ALERT_VERSION, + }) + .expect(404); + }); + }); + + unauthorizedUsers.forEach(({ username, password }) => { + it(`${username} should NOT be able to update alert ${alertId} in ${space}/${index}`, async () => { + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(space)}${TEST_URL}`) + .auth(username, password) + .set('kbn-xsrf', 'true') + .send({ + ids: [alertId], + status: 'closed', + index, + _version: ALERT_VERSION, + }) + .expect(403); + }); + }); + } + + describe('Security Solution', () => { + const authorizedInAllSpaces = [superUser, secOnlySpacesAll, obsSecSpacesAll]; + const authorizedOnlyInSpace1 = [secOnly, obsSec]; + const authorizedOnlyInSpace2 = [secOnlySpace2, obsSecAllSpace2]; + const unauthorized = [ + // these users are not authorized to update alerts for the Security Solution in any space + globalRead, + secOnlyRead, + obsSecRead, + secOnlyReadSpace2, + obsSecReadSpace2, + obsOnly, + obsOnlyRead, + obsOnlySpace2, + obsOnlyReadSpace2, + obsOnlySpacesAll, + noKibanaPrivileges, + ]; + + addTests({ + space: SPACE1, + alertId: SECURITY_SOLUTION_ALERT_ID, + index: SECURITY_SOLUTION_ALERT_INDEX, + authorizedUsers: [...authorizedInAllSpaces, ...authorizedOnlyInSpace1], + unauthorizedUsers: [...authorizedOnlyInSpace2, ...unauthorized], + }); + addTests({ + space: SPACE2, + alertId: SECURITY_SOLUTION_ALERT_ID, + index: SECURITY_SOLUTION_ALERT_INDEX, + authorizedUsers: [...authorizedInAllSpaces, ...authorizedOnlyInSpace2], + unauthorizedUsers: [...authorizedOnlyInSpace1, ...unauthorized], + }); + }); + + describe('APM', () => { + const authorizedInAllSpaces = [superUser, obsOnlySpacesAll, obsSecSpacesAll]; + const authorizedOnlyInSpace1 = [obsOnly, obsSec]; + const authorizedOnlyInSpace2 = [obsOnlySpace2, obsSecAllSpace2]; + const unauthorized = [ + // these users are not authorized to update alerts for APM in any space + globalRead, + obsOnlyRead, + obsSecRead, + obsOnlyReadSpace2, + obsSecReadSpace2, + secOnly, + secOnlyRead, + secOnlySpace2, + secOnlyReadSpace2, + secOnlySpacesAll, + noKibanaPrivileges, + ]; + + addTests({ + space: SPACE1, + alertId: APM_ALERT_ID, + index: APM_ALERT_INDEX, + authorizedUsers: [...authorizedInAllSpaces, ...authorizedOnlyInSpace1], + unauthorizedUsers: [...authorizedOnlyInSpace2, ...unauthorized], + }); + addTests({ + space: SPACE2, + alertId: APM_ALERT_ID, + index: APM_ALERT_INDEX, + authorizedUsers: [...authorizedInAllSpaces, ...authorizedOnlyInSpace2], + unauthorizedUsers: [...authorizedOnlyInSpace1, ...unauthorized], + }); + }); + }); +}; diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/trial/get_alerts.ts b/x-pack/test/rule_registry/security_and_spaces/tests/trial/get_alerts.ts new file mode 100644 index 0000000000000..a38f6cf3263b1 --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/tests/trial/get_alerts.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { + superUser, + obsMinReadSpacesAll, + obsMinRead, + obsMinReadAlertsRead, + obsMinReadAlertsReadSpacesAll, +} from '../../../common/lib/authentication/users'; +import type { User } from '../../../common/lib/authentication/types'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { getSpaceUrlPrefix } from '../../../common/lib/authentication/spaces'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const TEST_URL = '/internal/rac/alerts'; + const ALERTS_INDEX_URL = `${TEST_URL}/index`; + const SPACE1 = 'space1'; + const SPACE2 = 'space2'; + + const getAPMIndexName = async (user: User) => { + const { + body: indexNames, + }: { body: { index_name: string[] | undefined } } = await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${ALERTS_INDEX_URL}`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'true') + .expect(200); + const observabilityIndex = indexNames?.index_name?.find( + (indexName) => indexName === '.alerts-observability-apm' + ); + expect(observabilityIndex).to.eql('.alerts-observability-apm'); + return observabilityIndex; + }; + + describe('rbac with subfeatures', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts'); + }); + describe('Users:', () => { + // user with minimal_read and alerts_read privileges should be able to access apm alert + it(`${obsMinReadAlertsRead.username} should be able to access the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}?id=NoxgpHkBqbdrfX07MqXV&index=${apmIndex}`) + .auth(obsMinReadAlertsRead.username, obsMinReadAlertsRead.password) + .set('kbn-xsrf', 'true') + .expect(200); + }); + it(`${obsMinReadAlertsReadSpacesAll.username} should be able to access the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}?id=NoxgpHkBqbdrfX07MqXV&index=${apmIndex}`) + .auth(obsMinReadAlertsReadSpacesAll.username, obsMinReadAlertsReadSpacesAll.password) + .set('kbn-xsrf', 'true') + .expect(200); + }); + + it(`${obsMinRead.username} should NOT be able to access the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}?id=NoxgpHkBqbdrfX07MqXV&index=${apmIndex}`) + .auth(obsMinRead.username, obsMinRead.password) + .set('kbn-xsrf', 'true') + .expect(403); + }); + + it(`${obsMinReadSpacesAll.username} should NOT be able to access the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}?id=NoxgpHkBqbdrfX07MqXV&index=${apmIndex}`) + .auth(obsMinReadSpacesAll.username, obsMinReadSpacesAll.password) + .set('kbn-xsrf', 'true') + .expect(403); + }); + }); + + describe('Space:', () => { + it(`${obsMinReadAlertsRead.username} should NOT be able to access the APM alert in ${SPACE2}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE2)}${TEST_URL}?id=NoxgpHkBqbdrfX07MqXV&index=${apmIndex}`) + .auth(obsMinReadAlertsRead.username, obsMinReadAlertsRead.password) + .set('kbn-xsrf', 'true') + .expect(403); + }); + + it(`${obsMinReadAlertsReadSpacesAll.username} should be able to access the APM alert in ${SPACE2}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE2)}${TEST_URL}?id=NoxgpHkBqbdrfX07MqXV&index=${apmIndex}`) + .auth(obsMinReadAlertsReadSpacesAll.username, obsMinReadAlertsReadSpacesAll.password) + .set('kbn-xsrf', 'true') + .expect(200); + }); + + describe('extra params', () => { + it('should NOT allow to pass a filter query parameter', async () => { + await supertest + .get(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}?sortOrder=asc&namespaces[0]=*`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + }); + + it('should NOT allow to pass a non supported query parameter', async () => { + await supertest + .get(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}?notExists=something`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + }); + }); + }); + }); +}; diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/trial/index.ts b/x-pack/test/rule_registry/security_and_spaces/tests/trial/index.ts new file mode 100644 index 0000000000000..5e89f99200f2d --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/tests/trial/index.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { + createSpaces, + createUsersAndRoles, + deleteSpaces, + deleteUsersAndRoles, +} from '../../../common/lib/authentication'; + +import { + observabilityMinReadAlertsRead, + observabilityMinReadAlertsReadSpacesAll, + observabilityMinimalRead, + observabilityMinimalReadSpacesAll, + observabilityMinReadAlertsAll, + observabilityMinReadAlertsAllSpacesAll, + observabilityMinimalAll, + observabilityMinimalAllSpacesAll, +} from '../../../common/lib/authentication/roles'; +import { + obsMinReadAlertsRead, + obsMinReadAlertsReadSpacesAll, + obsMinRead, + obsMinReadSpacesAll, + superUser, + obsMinReadAlertsAll, + obsMinReadAlertsAllSpacesAll, + obsMinAll, + obsMinAllSpacesAll, +} from '../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile, getService }: FtrProviderContext): void => { + describe('rules security and spaces enabled: trial', function () { + // Fastest ciGroup for the moment. + this.tags('ciGroup5'); + + before(async () => { + await createSpaces(getService); + await createUsersAndRoles( + getService, + [ + obsMinReadAlertsRead, + obsMinReadAlertsReadSpacesAll, + obsMinRead, + obsMinReadSpacesAll, + superUser, + obsMinReadAlertsAll, + obsMinReadAlertsAllSpacesAll, + obsMinAll, + obsMinAllSpacesAll, + ], + [ + observabilityMinReadAlertsRead, + observabilityMinReadAlertsReadSpacesAll, + observabilityMinimalRead, + observabilityMinimalReadSpacesAll, + observabilityMinReadAlertsAll, + observabilityMinReadAlertsAllSpacesAll, + observabilityMinimalAll, + observabilityMinimalAllSpacesAll, + ] + ); + }); + + after(async () => { + await deleteSpaces(getService); + await deleteUsersAndRoles( + getService, + [ + obsMinReadAlertsRead, + obsMinReadAlertsReadSpacesAll, + obsMinRead, + obsMinReadSpacesAll, + superUser, + obsMinReadAlertsAll, + obsMinReadAlertsAllSpacesAll, + obsMinAll, + obsMinAllSpacesAll, + ], + [ + observabilityMinReadAlertsRead, + observabilityMinReadAlertsReadSpacesAll, + observabilityMinimalRead, + observabilityMinimalReadSpacesAll, + observabilityMinReadAlertsAll, + observabilityMinReadAlertsAllSpacesAll, + observabilityMinimalAll, + observabilityMinimalAllSpacesAll, + ] + ); + }); + + // Trial + loadTestFile(require.resolve('./get_alerts')); + loadTestFile(require.resolve('./update_alert')); + }); +}; diff --git a/x-pack/test/rule_registry/security_and_spaces/tests/trial/update_alert.ts b/x-pack/test/rule_registry/security_and_spaces/tests/trial/update_alert.ts new file mode 100644 index 0000000000000..c126c434bd4cf --- /dev/null +++ b/x-pack/test/rule_registry/security_and_spaces/tests/trial/update_alert.ts @@ -0,0 +1,189 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; + +import { + superUser, + obsMinReadAlertsAll, + obsMinReadAlertsAllSpacesAll, + obsMinAll, + obsMinAllSpacesAll, +} from '../../../common/lib/authentication/users'; +import type { User } from '../../../common/lib/authentication/types'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { getSpaceUrlPrefix } from '../../../common/lib/authentication/spaces'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const TEST_URL = '/internal/rac/alerts'; + const ALERTS_INDEX_URL = `${TEST_URL}/index`; + const SPACE1 = 'space1'; + + const getAPMIndexName = async (user: User) => { + const { + body: indexNames, + }: { body: { index_name: string[] | undefined } } = await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${ALERTS_INDEX_URL}`) + .auth(user.username, user.password) + .set('kbn-xsrf', 'true') + .expect(200); + const observabilityIndex = indexNames?.index_name?.find( + (indexName) => indexName === '.alerts-observability-apm' + ); + expect(observabilityIndex).to.eql('.alerts-observability-apm'); + return observabilityIndex; + }; + + describe('rbac', () => { + describe('Users update:', () => { + beforeEach(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts'); + }); + afterEach(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/rule_registry/alerts'); + }); + it(`${superUser.username} should be able to update the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(superUser.username, superUser.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(200); + }); + + it(`${superUser.username} should receive a 409 if trying to update an old alert document version`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(superUser.username, superUser.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(200); + + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(superUser.username, superUser.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([999, 999]), 'utf8').toString('base64'), + }) + .expect(409); + }); + + it(`${obsMinReadAlertsAllSpacesAll.username} should be able to update the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + const res = await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(obsMinReadAlertsAllSpacesAll.username, obsMinReadAlertsAllSpacesAll.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(200); + expect(res.body).to.eql({ + success: true, + _index: '.alerts-observability-apm', + _id: 'NoxgpHkBqbdrfX07MqXV', + result: 'updated', + _shards: { total: 2, successful: 1, failed: 0 }, + _version: 'WzEsMV0=', + _seq_no: 1, + _primary_term: 1, + }); + }); + it(`${obsMinReadAlertsAllSpacesAll.username} should receive a 409 if trying to update an old alert document version`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(obsMinReadAlertsAllSpacesAll.username, obsMinReadAlertsAllSpacesAll.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(200); + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(obsMinReadAlertsAllSpacesAll.username, obsMinReadAlertsAllSpacesAll.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([999, 999]), 'utf8').toString('base64'), + }) + .expect(409); + }); + + it(`${obsMinReadAlertsAll.username} should be able to update the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(obsMinReadAlertsAll.username, obsMinReadAlertsAll.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(200); + }); + it(`${obsMinAll.username} should NOT be able to update the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(obsMinAll.username, obsMinAll.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(403); + }); + + it(`${obsMinAllSpacesAll.username} should NOT be able to update the APM alert in ${SPACE1}`, async () => { + const apmIndex = await getAPMIndexName(superUser); + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE1)}${TEST_URL}`) + .auth(obsMinAllSpacesAll.username, obsMinAllSpacesAll.password) + .set('kbn-xsrf', 'true') + .send({ + ids: ['NoxgpHkBqbdrfX07MqXV'], + status: 'closed', + index: apmIndex, + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(403); + }); + }); + }); +}; diff --git a/x-pack/test/rule_registry/spaces_only/config_trial.ts b/x-pack/test/rule_registry/spaces_only/config_trial.ts new file mode 100644 index 0000000000000..e788a16d0272f --- /dev/null +++ b/x-pack/test/rule_registry/spaces_only/config_trial.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createTestConfig } from '../common/config'; + +// eslint-disable-next-line import/no-default-export +export default createTestConfig('spaces_only', { + license: 'trial', + disabledPlugins: ['security'], + ssl: false, + testFiles: [require.resolve('./tests/trial')], +}); diff --git a/x-pack/test/rule_registry/spaces_only/tests/trial/get_alert_by_id.ts b/x-pack/test/rule_registry/spaces_only/tests/trial/get_alert_by_id.ts new file mode 100644 index 0000000000000..df188718bff19 --- /dev/null +++ b/x-pack/test/rule_registry/spaces_only/tests/trial/get_alert_by_id.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { superUser } from '../../../common/lib/authentication/users'; +import type { User } from '../../../common/lib/authentication/types'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { getSpaceUrlPrefix } from '../../../common/lib/authentication/spaces'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const TEST_URL = '/internal/rac/alerts'; + const ALERTS_INDEX_URL = `${TEST_URL}/index`; + const SPACE1 = 'space1'; + const SPACE2 = 'space2'; + const APM_ALERT_ID = 'NoxgpHkBqbdrfX07MqXV'; + const APM_ALERT_INDEX = '.alerts-observability-apm'; + const SECURITY_SOLUTION_ALERT_INDEX = '.alerts-security.alerts'; + + const getAPMIndexName = async (user: User) => { + const { + body: indexNames, + }: { body: { index_name: string[] | undefined } } = await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${ALERTS_INDEX_URL}`) + .set('kbn-xsrf', 'true') + .expect(200); + const observabilityIndex = indexNames?.index_name?.find( + (indexName) => indexName === APM_ALERT_INDEX + ); + expect(observabilityIndex).to.eql(APM_ALERT_INDEX); // assert this here so we can use constants in the dynamically-defined test cases below + }; + + const getSecuritySolutionIndexName = async (user: User) => { + const { + body: indexNames, + }: { body: { index_name: string[] | undefined } } = await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${ALERTS_INDEX_URL}`) + .set('kbn-xsrf', 'true') + .expect(200); + const securitySolution = indexNames?.index_name?.find( + (indexName) => indexName === SECURITY_SOLUTION_ALERT_INDEX + ); + expect(securitySolution).to.eql(SECURITY_SOLUTION_ALERT_INDEX); // assert this here so we can use constants in the dynamically-defined test cases below + }; + + describe('Alerts - GET - RBAC - spaces', () => { + before(async () => { + await getSecuritySolutionIndexName(superUser); + await getAPMIndexName(superUser); + + await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/rule_registry/alerts'); + }); + + it('should return a 404 when superuser accesses not-existent alert', async () => { + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix()}${TEST_URL}?id=myfakeid&index=${APM_ALERT_INDEX}`) + .set('kbn-xsrf', 'true') + .expect(404); + }); + + it('should return a 404 when superuser accesses not-existent alerts as data index', async () => { + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix()}${TEST_URL}?id=${APM_ALERT_ID}&index=myfakeindex`) + .set('kbn-xsrf', 'true') + .expect(404); + }); + + it(`${superUser.username} should be able to access alert ${APM_ALERT_ID} in ${SPACE2}/${APM_ALERT_INDEX}`, async () => { + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE2)}${TEST_URL}?id=${APM_ALERT_ID}&index=${APM_ALERT_INDEX}`) + .set('kbn-xsrf', 'true') + .expect(200); + }); + }); +}; diff --git a/x-pack/test/rule_registry/spaces_only/tests/trial/index.ts b/x-pack/test/rule_registry/spaces_only/tests/trial/index.ts new file mode 100644 index 0000000000000..6deba4c68d0e2 --- /dev/null +++ b/x-pack/test/rule_registry/spaces_only/tests/trial/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { createSpaces, deleteSpaces } from '../../../common/lib/authentication'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile, getService }: FtrProviderContext): void => { + describe('rule registry spaces only: trial', function () { + // Fastest ciGroup for the moment. + this.tags('ciGroup5'); + + before(async () => { + await createSpaces(getService); + }); + + after(async () => { + await deleteSpaces(getService); + }); + + // Basic + loadTestFile(require.resolve('./get_alert_by_id')); + loadTestFile(require.resolve('./update_alert')); + }); +}; diff --git a/x-pack/test/rule_registry/spaces_only/tests/trial/update_alert.ts b/x-pack/test/rule_registry/spaces_only/tests/trial/update_alert.ts new file mode 100644 index 0000000000000..f5179b253b701 --- /dev/null +++ b/x-pack/test/rule_registry/spaces_only/tests/trial/update_alert.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; + +import { superUser } from '../../../common/lib/authentication/users'; +import type { User } from '../../../common/lib/authentication/types'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { getSpaceUrlPrefix } from '../../../common/lib/authentication/spaces'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const TEST_URL = '/internal/rac/alerts'; + const ALERTS_INDEX_URL = `${TEST_URL}/index`; + const SPACE1 = 'space1'; + const SPACE2 = 'space2'; + const APM_ALERT_ID = 'NoxgpHkBqbdrfX07MqXV'; + const APM_ALERT_INDEX = '.alerts-observability-apm'; + const SECURITY_SOLUTION_ALERT_INDEX = '.alerts-security.alerts'; + const ALERT_VERSION = Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'); // required for optimistic concurrency control + + const getAPMIndexName = async (user: User) => { + const { + body: indexNames, + }: { body: { index_name: string[] | undefined } } = await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${ALERTS_INDEX_URL}`) + .set('kbn-xsrf', 'true') + .expect(200); + const observabilityIndex = indexNames?.index_name?.find( + (indexName) => indexName === APM_ALERT_INDEX + ); + expect(observabilityIndex).to.eql(APM_ALERT_INDEX); // assert this here so we can use constants in the dynamically-defined test cases below + }; + + const getSecuritySolutionIndexName = async (user: User) => { + const { + body: indexNames, + }: { body: { index_name: string[] | undefined } } = await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(SPACE1)}${ALERTS_INDEX_URL}`) + .set('kbn-xsrf', 'true') + .expect(200); + const securitySolution = indexNames?.index_name?.find( + (indexName) => indexName === SECURITY_SOLUTION_ALERT_INDEX + ); + expect(securitySolution).to.eql(SECURITY_SOLUTION_ALERT_INDEX); // assert this here so we can use constants in the dynamically-defined test cases below + }; + + describe('Alert - Update - RBAC - spaces', () => { + before(async () => { + await getSecuritySolutionIndexName(superUser); + await getAPMIndexName(superUser); + }); + + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/rule_registry/alerts'); + }); + + it('should return a 404 when superuser accesses not-existent alert', async () => { + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix()}${TEST_URL}`) + .set('kbn-xsrf', 'true') + .send({ + ids: ['this id does not exist'], + status: 'closed', + index: APM_ALERT_INDEX, + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(404); + }); + + it('should return a 404 when superuser accesses not-existent alerts as data index', async () => { + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix()}${TEST_URL}`) + .set('kbn-xsrf', 'true') + .send({ + ids: [APM_ALERT_ID], + status: 'closed', + index: 'this index does not exist', + _version: Buffer.from(JSON.stringify([0, 1]), 'utf8').toString('base64'), + }) + .expect(404); + }); + + it(`${superUser.username} should be able to update alert ${APM_ALERT_ID} in ${SPACE2}/${APM_ALERT_INDEX}`, async () => { + await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts'); // since this is a success case, reload the test data immediately beforehand + await supertestWithoutAuth + .post(`${getSpaceUrlPrefix(SPACE2)}${TEST_URL}`) + .set('kbn-xsrf', 'true') + .send({ + ids: [APM_ALERT_ID], + status: 'closed', + index: APM_ALERT_INDEX, + _version: ALERT_VERSION, + }) + .expect(200); + }); + }); +}; diff --git a/yarn.lock b/yarn.lock index 79ab3a6e42e28..d81db5a5c179d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23204,10 +23204,10 @@ react-popper@^2.2.4: react-fast-compare "^3.0.1" warning "^4.0.2" -react-query@^3.13.10: - version "3.13.10" - resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.13.10.tgz#b6a05e22a5debb6e2df79ada588179771cbd7df8" - integrity sha512-wFvKhEDnOVL5bFL+9KPgNsiOOei1Ad+l6l1awCBuoX7xMG+SXXKDOF2uuZFsJe0w6gdthdWN+00021yepTR31g== +react-query@^3.18.1: + version "3.18.1" + resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.18.1.tgz#893b5475a7b4add099e007105317446f7a2cd310" + integrity sha512-17lv3pQxU9n+cB5acUv0/cxNTjo9q8G+RsedC6Ax4V9D8xEM7Q5xf9xAbCPdEhDrrnzPjTls9fQEABKRSi7OJA== dependencies: "@babel/runtime" "^7.5.5" broadcast-channel "^3.4.1"