diff --git a/.ci/package-testing/Jenkinsfile b/.ci/package-testing/Jenkinsfile new file mode 100644 index 00000000000000..b749c1fe2e9af0 --- /dev/null +++ b/.ci/package-testing/Jenkinsfile @@ -0,0 +1,29 @@ +#!/bin/groovy +library 'kibana-pipeline-library' +kibanaLibrary.load() +kibanaPipeline(timeoutMinutes: 300) { + slackNotifications.onFailure { + ciStats.trackBuild { + workers.ci(ramDisk: false, name: "package-build", size: 'l', runErrorReporter: false) { + withGcpServiceAccount.fromVaultSecret('secret/kibana-issues/dev/ci-artifacts-key', 'value') { + kibanaPipeline.bash("test/scripts/jenkins_xpack_package_build.sh", "Package builds") + } + } + def packageTypes = ['deb', 'docker', 'rpm'] + def workers = [:] + packageTypes.each { type -> + workers["package-${type}"] = { + testPackage(type) + } + } + parallel(workers) + } + } +} +def testPackage(packageType) { + workers.ci(ramDisk: false, name: "package-${packageType}", size: 's', runErrorReporter: false) { + withGcpServiceAccount.fromVaultSecret('secret/kibana-issues/dev/ci-artifacts-key', 'value') { + kibanaPipeline.bash("test/scripts/jenkins_xpack_package_${packageType}.sh", "Execute package testing for ${packageType}") + } + } +} diff --git a/.gitignore b/.gitignore index ce8fd38b18a929..f9855520cb1103 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ npm-debug.log* .ci/runbld .ci/bash_standard_lib.sh .gradle +.vagrant ## @cypress/snapshot from apm plugin snapshots.js diff --git a/docs/developer/contributing/development-package-tests.asciidoc b/docs/developer/contributing/development-package-tests.asciidoc new file mode 100644 index 00000000000000..10c09d6cae8c07 --- /dev/null +++ b/docs/developer/contributing/development-package-tests.asciidoc @@ -0,0 +1,64 @@ +[[development-package-tests]] +== Package Testing + +Packaging tests use Vagrant virtual machines as hosts and Ansible for +provisioning and assertions. Kibana distributions are copied from the +target folder into each VM and installed, along with required +dependencies. + +=== Setup + +* https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html[Ansible] ++ +``` +# Ubuntu +sudo apt-get install python3-pip libarchive-tools +pip3 install --user ansible + +# Darwin +brew install python3 +pip3 install --user ansible +``` +* https://www.vagrantup.com/downloads[Vagrant] +* https://www.virtualbox.org/wiki/Downloads[Virtualbox] + +=== Machines + +[cols=",,",options="header",] +|=== +|Hostname |IP |Description +|deb |192.168.50.5 |Installation of Kibana’s deb package +|rpm |192.168.50.6 |Installation of Kibana’s rpm package +|docker |192.168.50.7 |Installation of Kibana’s docker image +|=== + +=== Running + +``` +# Build distributions +node scripts/build --all-platforms --debug --no-oss + +cd test/package + +# Setup virtual machine and networking +vagrant up --no-provision + +# Install Kibana and run OS level tests +# This step can be repeated when adding new tests, it ensures machine state - installations won't run twice +vagrant provision + +# Running functional tests +node scripts/es snapshot \ + -E network.bind_host=127.0.0.1,192.168.50.1 \ + -E discovery.type=single-node \ + --license=trial +TEST_KIBANA_URL=http://elastic:changeme@:5601 \ +TEST_ES_URL=http://elastic:changeme@192.168.50.1:9200 \ + node scripts/functional_test_runner.js --include-tag=smoke +``` + +=== Cleanup + +.... +vagrant destroy +.... diff --git a/docs/developer/contributing/development-tests.asciidoc b/docs/developer/contributing/development-tests.asciidoc index 715b1a15ab5ede..e7a36d28667282 100644 --- a/docs/developer/contributing/development-tests.asciidoc +++ b/docs/developer/contributing/development-tests.asciidoc @@ -74,6 +74,7 @@ to learn more about using the node scripts we provide for building * <> * <> * <> +* <> include::development-functional-tests.asciidoc[leveloffset=+1] @@ -81,6 +82,8 @@ include::development-unit-tests.asciidoc[leveloffset=+1] include::development-accessibility-tests.asciidoc[leveloffset=+1] +include::development-package-tests.asciidoc[leveloffset=+1] + [discrete] === Cross-browser compatibility diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index 8f033029cfac42..4e8bbf76eaacb0 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -82,10 +82,12 @@ yarn kbn watch-bazel - @kbn/i18n - @kbn/legacy-logging - @kbn/logging +- @kbn/mapbox-gl +- @kbn/monaco - @kbn/securitysolution-es-utils -- kbn/securitysolution-io-ts-alerting-types -- kbn/securitysolution-io-ts-list-types -- kbn/securitysolution-io-ts-types +- @kbn/securitysolution-io-ts-alerting-types +- @kbn/securitysolution-io-ts-list-types +- @kbn/securitysolution-io-ts-types - @kbn/securitysolution-io-ts-utils - @kbn/securitysolution-list-api - @kbn/securitysolution-list-constants diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 50ed0d2652c6f0..71f141d1ed5d6e 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -41,7 +41,7 @@ You can configure the following settings in the `kibana.yml` file. [cols="2*<"] |=== | `xpack.actions.enabled` - | Feature toggle that enables Actions in {kib}. Defaults to `true`. + | Feature toggle that enables Actions in {kib}. Default: `true`. | `xpack.actions.allowedHosts` {ess-icon} | A list of hostnames that {kib} is allowed to connect to when built-in actions are triggered. It defaults to `[*]`, allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly added to the allowed hosts. An empty list `[]` can be used to block built-in actions from making any external connections. + @@ -50,7 +50,7 @@ You can configure the following settings in the `kibana.yml` file. | `xpack.actions.customHostSettings` {ess-icon} | A list of custom host settings to override existing global settings. - Defaults to an empty list. + + Default: an empty list. + + Each entry in the list must have a `url` property, to associate a connection type (mail or https), hostname and port with the remaining options in the @@ -70,6 +70,7 @@ You can configure the following settings in the `kibana.yml` file. xpack.actions.customHostSettings: - url: smtp://mail.example.com:465 tls: + verificationMode: 'full' certificateAuthoritiesFiles: [ 'one.crt' ] certificateAuthoritiesData: | -----BEGIN CERTIFICATE----- @@ -79,7 +80,9 @@ xpack.actions.customHostSettings: requireTLS: true - url: https://webhook.example.com tls: + // legacy rejectUnauthorized: false + verificationMode: 'none' -- [cols="2*<"] @@ -115,10 +118,16 @@ xpack.actions.customHostSettings: | `xpack.actions.customHostSettings[n]` `.tls.rejectUnauthorized` {ess-icon} - | A boolean value indicating whether to bypass server certificate validation. + | Deprecated. Use <> instead. A boolean value indicating whether to bypass server certificate validation. Overrides the general `xpack.actions.rejectUnauthorized` configuration for requests made for this hostname/port. +|[[action-config-custom-host-verification-mode]] `xpack.actions.customHostSettings[n]` +`.tls.verificationMode` + | Controls the verification of the server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection to the host server. Valid values are `full`, `certificate`, and `none`. + Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. Overrides the general `xpack.actions.tls.verificationMode` configuration + for requests made for this hostname/port. + | `xpack.actions.customHostSettings[n]` `.tls.certificateAuthoritiesFiles` | A file name or list of file names of PEM-encoded certificate files to use @@ -137,10 +146,10 @@ xpack.actions.customHostSettings: | `xpack.actions` `.preconfiguredAlertHistoryEsIndex` {ess-icon} - | Enables a preconfigured alert history {es} <> connector. Defaults to `false`. + | Enables a preconfigured alert history {es} <> connector. Default: `false`. | `xpack.actions.preconfigured` - | Specifies preconfigured connector IDs and configs. Defaults to {}. + | Specifies preconfigured connector IDs and configs. Default: {}. | `xpack.actions.proxyUrl` {ess-icon} | Specifies the proxy URL to use, if using a proxy for actions. By default, no proxy is used. @@ -152,27 +161,44 @@ xpack.actions.customHostSettings: | Specifies hostnames which should only use the proxy, if using a proxy for actions. The value is an array of hostnames as strings. By default, no hosts will use the proxy, but if an action's hostname is in this list, the proxy will be used. The settings `xpack.actions.proxyBypassHosts` and `xpack.actions.proxyOnlyHosts` cannot be used at the same time. | `xpack.actions.proxyHeaders` {ess-icon} - | Specifies HTTP headers for the proxy, if using a proxy for actions. Defaults to {}. + | Specifies HTTP headers for the proxy, if using a proxy for actions. Default: {}. a|`xpack.actions.` `proxyRejectUnauthorizedCertificates` {ess-icon} - | Set to `false` to bypass certificate validation for the proxy, if using a proxy for actions. Defaults to `true`. + | Deprecated. Use <> instead. Set to `false` to bypass certificate validation for the proxy, if using a proxy for actions. Default: `true`. + +|[[action-config-proxy-verification-mode]] +`xpack.actions[n]` +`.tls.proxyVerificationMode` {ess-icon} +| Controls the verification for the proxy server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection to the proxy server. Valid values are `full`, `certificate`, and `none`. +Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. | `xpack.actions.rejectUnauthorized` {ess-icon} - | Set to `false` to bypass certificate validation for actions. Defaults to `true`. + + | Deprecated. Use <> instead. Set to `false` to bypass certificate validation for actions. Default: `true`. + + As an alternative to setting `xpack.actions.rejectUnauthorized`, you can use the setting `xpack.actions.customHostSettings` to set TLS options for specific servers. +|[[action-config-verification-mode]] +`xpack.actions[n]` +`.tls.verificationMode` {ess-icon} +| Controls the verification for the server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection for actions. Valid values are `full`, `certificate`, and `none`. + Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. + + + + As an alternative to setting `xpack.actions.tls.verificationMode`, you can use the setting + `xpack.actions.customHostSettings` to set TLS options for specific servers. + + + | `xpack.actions.maxResponseContentLength` {ess-icon} - | Specifies the max number of bytes of the http response for requests to external resources. Defaults to 1000000 (1MB). + | Specifies the max number of bytes of the http response for requests to external resources. Default: 1000000 (1MB). | `xpack.actions.responseTimeout` {ess-icon} | Specifies the time allowed for requests to external resources. Requests that take longer are aborted. The time is formatted as: + + `[ms,s,m,h,d,w,M,Y]` + + - For example, `20m`, `24h`, `7d`, `1w`. Defaults to `60s`. + For example, `20m`, `24h`, `7d`, `1w`. Default: `60s`. |=== diff --git a/package.json b/package.json index 936f985498ab17..1369b1d105aa45 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,7 @@ "@kbn/io-ts-utils": "link:packages/kbn-io-ts-utils", "@kbn/legacy-logging": "link:bazel-bin/packages/kbn-legacy-logging/npm_module", "@kbn/logging": "link:bazel-bin/packages/kbn-logging/npm_module", - "@kbn/monaco": "link:packages/kbn-monaco", + "@kbn/monaco": "link:bazel-bin/packages/kbn-monaco/npm_module", "@kbn/securitysolution-list-constants": "link:bazel-bin/packages/kbn-securitysolution-list-constants/npm_module", "@kbn/securitysolution-es-utils": "link:bazel-bin/packages/kbn-securitysolution-es-utils/npm_module", "@kbn/securitysolution-io-ts-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-types/npm_module", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 43528e0ae41629..c885666f7a916e 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -25,6 +25,7 @@ filegroup( "//packages/kbn-legacy-logging:build", "//packages/kbn-logging:build", "//packages/kbn-mapbox-gl:build", + "//packages/kbn-monaco:build", "//packages/kbn-plugin-generator:build", "//packages/kbn-securitysolution-list-constants:build", "//packages/kbn-securitysolution-io-ts-types:build", diff --git a/packages/kbn-babel-preset/BUILD.bazel b/packages/kbn-babel-preset/BUILD.bazel index 06b788010bdf51..f5ebc153b9e1a0 100644 --- a/packages/kbn-babel-preset/BUILD.bazel +++ b/packages/kbn-babel-preset/BUILD.bazel @@ -34,6 +34,7 @@ DEPS = [ "@npm//@babel/preset-typescript", "@npm//babel-plugin-add-module-exports", "@npm//babel-plugin-styled-components", + "@npm//babel-plugin-transform-react-remove-prop-types", ] js_library( diff --git a/packages/kbn-monaco/BUILD.bazel b/packages/kbn-monaco/BUILD.bazel new file mode 100644 index 00000000000000..3a25568dfd811d --- /dev/null +++ b/packages/kbn-monaco/BUILD.bazel @@ -0,0 +1,108 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") +load("@npm//webpack-cli:index.bzl", webpack = "webpack_cli") + +PKG_BASE_NAME = "kbn-monaco" +PKG_REQUIRE_NAME = "@kbn/monaco" + +SOURCE_FILES = glob( + [ + "src/**/*", + ], + exclude = [ + "**/*.test.*", + "**/README.md", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", + "README.md" +] + +SRC_DEPS = [ + "//packages/kbn-babel-preset", + "//packages/kbn-i18n", + "@npm//antlr4ts", + "@npm//babel-loader", + "@npm//monaco-editor", + "@npm//raw-loader", + "@npm//regenerator-runtime", +] + +TYPES_DEPS = [ + "@npm//@types/jest", + "@npm//@types/node", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +webpack( + name = "target_web", + data = DEPS + [ + ":src", + ":webpack.config.js", + ], + output_dir = True, + args = [ + "--config", + "$(location webpack.config.js)", + "--output-path", + "$(@D)", + "--env.prod", + "--display=minimal" + ], +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_project( + name = "tsc", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + declaration = True, + declaration_dir = "target_types", + declaration_map = True, + incremental = True, + out_dir = "target_node", + source_map = True, + root_dir = ".", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_BASE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = DEPS + [":target_web", ":tsc"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-monaco/package.json b/packages/kbn-monaco/package.json index e818351e7e4700..4f372d8ae62dd4 100644 --- a/packages/kbn-monaco/package.json +++ b/packages/kbn-monaco/package.json @@ -2,12 +2,10 @@ "name": "@kbn/monaco", "version": "1.0.0", "private": true, - "main": "./target/index.js", - "types": "./target/index.d.ts", + "main": "target_node/src/index.js", + "types": "target_types/src/index.d.ts", "license": "SSPL-1.0 OR Elastic License 2.0", "scripts": { - "build": "node ./scripts/build.js", - "kbn:bootstrap": "yarn build --dev", "build:antlr4ts": "../../node_modules/antlr4ts-cli/antlr4ts ./src/painless/antlr/painless_lexer.g4 ./src/painless/antlr/painless_parser.g4 && node ./scripts/fix_generated_antlr.js" } } diff --git a/packages/kbn-monaco/scripts/build.js b/packages/kbn-monaco/scripts/build.js deleted file mode 100644 index 96159b4ad0dca8..00000000000000 --- a/packages/kbn-monaco/scripts/build.js +++ /dev/null @@ -1,53 +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 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. - */ - -const path = require('path'); -const del = require('del'); -const supportsColor = require('supports-color'); -const { run } = require('@kbn/dev-utils'); - -const TARGET_BUILD_DIR = path.resolve(__dirname, '../target'); -const ROOT_DIR = path.resolve(__dirname, '../'); -const WEBPACK_CONFIG_PATH = path.resolve(ROOT_DIR, 'webpack.config.js'); - -run( - async ({ procRunner, log, flags }) => { - log.info('Deleting old output'); - - await del(TARGET_BUILD_DIR); - - const cwd = ROOT_DIR; - const env = { ...process.env }; - if (supportsColor.stdout) { - env.FORCE_COLOR = 'true'; - } - - await procRunner.run('worker', { - cmd: 'webpack', - args: ['--config', WEBPACK_CONFIG_PATH, flags.dev ? '--env.dev' : '--env.prod'], - wait: true, - env, - cwd, - }); - - await procRunner.run('tsc ', { - cmd: 'tsc', - args: [], - wait: true, - env, - cwd, - }); - - log.success('Complete'); - }, - { - flags: { - boolean: ['dev'], - }, - } -); diff --git a/packages/kbn-monaco/src/register_globals.ts b/packages/kbn-monaco/src/register_globals.ts index a07d979e2022b6..4047ddedeca42d 100644 --- a/packages/kbn-monaco/src/register_globals.ts +++ b/packages/kbn-monaco/src/register_globals.ts @@ -11,11 +11,11 @@ import { PainlessLang } from './painless'; import { EsqlLang } from './esql'; import { monaco } from './monaco_imports'; // @ts-ignore -import xJsonWorkerSrc from '!!raw-loader!../target/public/xjson.editor.worker.js'; +import xJsonWorkerSrc from '!!raw-loader!../../target_web/xjson.editor.worker.js'; // @ts-ignore -import defaultWorkerSrc from '!!raw-loader!../target/public/default.editor.worker.js'; +import defaultWorkerSrc from '!!raw-loader!../../target_web/default.editor.worker.js'; // @ts-ignore -import painlessWorkerSrc from '!!raw-loader!../target/public/painless.editor.worker.js'; +import painlessWorkerSrc from '!!raw-loader!../../target_web/painless.editor.worker.js'; /** * Register languages and lexer rules diff --git a/packages/kbn-monaco/tsconfig.json b/packages/kbn-monaco/tsconfig.json index e6ec96b12c6cf1..3c17118337899c 100644 --- a/packages/kbn-monaco/tsconfig.json +++ b/packages/kbn-monaco/tsconfig.json @@ -1,10 +1,12 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "incremental": false, - "outDir": "./target", + "incremental": true, + "declarationDir": "./target_types", + "outDir": "./target_node", "declaration": true, "declarationMap": true, + "rootDir": ".", "sourceMap": true, "sourceRoot": "../../../../packages/kbn-monaco/src", "types": [ @@ -13,6 +15,6 @@ ] }, "include": [ - "src/**/*" + "src/**/*", ] } diff --git a/packages/kbn-monaco/webpack.config.js b/packages/kbn-monaco/webpack.config.js index 5ee00565e6494d..d035134565463e 100644 --- a/packages/kbn-monaco/webpack.config.js +++ b/packages/kbn-monaco/webpack.config.js @@ -18,7 +18,7 @@ const createLangWorkerConfig = (lang) => { mode: 'production', entry, output: { - path: path.resolve(__dirname, 'target/public'), + path: path.resolve(__dirname, 'target_web'), filename: `${lang}.editor.worker.js`, }, resolve: { diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index c284be4487a5f0..162606585c43e5 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -7,8 +7,5 @@ "build": "node scripts/build", "kbn:bootstrap": "node scripts/build --dev", "kbn:watch": "node scripts/build --dev --watch" - }, - "dependencies": { - "@kbn/monaco": "link:../kbn-monaco" } } \ No newline at end of file diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker index 47b5888da4ce87..a1838c571ea0be 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker @@ -175,6 +175,8 @@ kibana_vars=( xpack.actions.rejectUnauthorized xpack.actions.maxResponseContentLength xpack.actions.responseTimeout + xpack.actions.tls.verificationMode + xpack.actions.tls.proxyVerificationMode xpack.alerts.healthCheck.interval xpack.alerts.invalidateApiKeysTask.interval xpack.alerts.invalidateApiKeysTask.removalDelay diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 0d5ecab40fbc4e..d523f78a9f5896 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -40,6 +40,7 @@ export const IGNORE_FILE_GLOBS = [ 'vars/*', '.ci/pipeline-library/**/*', 'packages/kbn-test/jest-preset.js', + 'test/package/Vagrantfile', // filename must match language code which requires capital letters '**/translations/*.json', diff --git a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts index bb95e9e4c38b89..4ebca5ba8965ef 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts @@ -147,7 +147,10 @@ function createExtractPanelReferencesMigration( }; } -type ValueOrReferenceInput = SavedObjectEmbeddableInput & { attributes?: SerializableValue }; +type ValueOrReferenceInput = SavedObjectEmbeddableInput & { + attributes?: SerializableValue; + savedVis?: SerializableValue; +}; // Runs the embeddable migrations on each panel const migrateByValuePanels = ( @@ -158,19 +161,21 @@ const migrateByValuePanels = ( // Skip if panelsJSON is missing otherwise this will cause saved object import to fail when // importing objects without panelsJSON. At development time of this, there is no guarantee each saved // object has panelsJSON in all previous versions of kibana. - if (typeof attributes.panelsJSON !== 'string') { - return attributes; + if (typeof attributes?.panelsJSON !== 'string') { + return doc; } const panels = JSON.parse(attributes.panelsJSON) as SavedDashboardPanel[]; // Same here, prevent failing saved object import if ever panels aren't an array. if (!Array.isArray(panels)) { - return attributes; + return doc; } const newPanels: SavedDashboardPanel[] = []; panels.forEach((panel) => { // Convert each panel into a state that can be passed to EmbeddablesSetup.migrate const originalPanelState = convertSavedDashboardPanelToPanelState(panel); - if (originalPanelState.explicitInput.attributes) { + + // saved vis is used to store by value input for Visualize. This should eventually be renamed to `attributes` to align with Lens and Maps + if (originalPanelState.explicitInput.attributes || originalPanelState.explicitInput.savedVis) { // If this panel is by value, migrate the state using embeddable migrations const migratedInput = deps.embeddable.migrate( { diff --git a/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts b/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts new file mode 100644 index 00000000000000..6f214745e12912 --- /dev/null +++ b/src/plugins/visualizations/server/embeddable/visualize_embeddable_factory.ts @@ -0,0 +1,52 @@ +/* + * 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 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. + */ + +import { flow } from 'lodash'; +import { EmbeddableRegistryDefinition } from 'src/plugins/embeddable/server'; +import { SerializableState } from '../../../kibana_utils/common'; +import { + commonAddSupportOfDualIndexSelectionModeInTSVB, + commonHideTSVBLastValueIndicator, + commonRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel, +} from '../migrations/visualization_common_migrations'; + +const byValueAddSupportOfDualIndexSelectionModeInTSVB = (state: SerializableState) => { + return { + ...state, + savedVis: commonAddSupportOfDualIndexSelectionModeInTSVB(state.savedVis), + }; +}; + +const byValueHideTSVBLastValueIndicator = (state: SerializableState) => { + return { + ...state, + savedVis: commonHideTSVBLastValueIndicator(state.savedVis), + }; +}; + +const byValueRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel = (state: SerializableState) => { + return { + ...state, + savedVis: commonRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel(state.savedVis), + }; +}; + +export const visualizeEmbeddableFactory = (): EmbeddableRegistryDefinition => { + return { + id: 'visualization', + migrations: { + // These migrations are run in 7.13.1 for `by value` panels because the 7.13 release window was missed. + '7.13.1': (state) => + flow( + byValueAddSupportOfDualIndexSelectionModeInTSVB, + byValueHideTSVBLastValueIndicator, + byValueRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel + )(state), + }, + }; +}; diff --git a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts new file mode 100644 index 00000000000000..3f09f19d9ac63a --- /dev/null +++ b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts @@ -0,0 +1,44 @@ +/* + * 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 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. + */ + +export const commonAddSupportOfDualIndexSelectionModeInTSVB = (visState: any) => { + if (visState && visState.type === 'metrics') { + const { params } = visState; + + if (typeof params?.index_pattern === 'string') { + params.use_kibana_indexes = false; + } + } + return visState; +}; + +export const commonHideTSVBLastValueIndicator = (visState: any) => { + if (visState && visState.type === 'metrics' && visState.params.type !== 'timeseries') { + return { + ...visState, + params: { + ...visState.params, + hide_last_value_indicator: true, + }, + }; + } + return visState; +}; + +export const commonRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel = (visState: any) => { + if (visState && visState.type === 'metrics') { + const { params } = visState; + + delete params.default_index_pattern; + delete params.default_timefield; + + return visState; + } + + return visState; +}; diff --git a/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts similarity index 97% rename from src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts rename to src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts index b082737cce288d..dbe5482c442b7c 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization_migrations.test.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { visualizationSavedObjectTypeMigrations } from './visualization_migrations'; +import { visualizationSavedObjectTypeMigrations } from './visualization_saved_object_migrations'; import { SavedObjectMigrationContext, SavedObjectMigrationFn } from 'kibana/server'; const savedObjectMigrationContext = (null as unknown) as SavedObjectMigrationContext; @@ -1977,4 +1977,44 @@ describe('migration visualization', () => { expect(params).not.toHaveProperty('default_timefield'); }); }); + + describe('7.13.0 and 7.13.1 tsvb migrations can run twice', () => { + const migrate = (doc: any) => + visualizationSavedObjectTypeMigrations['7.13.0']( + doc as Parameters[0], + savedObjectMigrationContext + ); + + const migrateAgain = (doc: any) => + visualizationSavedObjectTypeMigrations['7.13.1']( + doc as Parameters[0], + savedObjectMigrationContext + ); + + const createTestDocWithType = (type: string) => ({ + attributes: { + title: 'My Vis', + description: 'This is my super cool vis.', + visState: `{"type":"metrics","params":{"type":"${type}","default_index_pattern":"test", "default_timefield":"test", "index_pattern":"testme"}}`, + }, + }); + + it('the migrations can be applied twice without breaking anything', () => { + const migratedTestDoc = migrate(createTestDocWithType('markdown')); + const { params } = JSON.parse(migratedTestDoc.attributes.visState); + + expect(params.hide_last_value_indicator).toBeTruthy(); + expect(params).not.toHaveProperty('default_index_pattern'); + expect(params).not.toHaveProperty('default_timefield'); + expect(params.use_kibana_indexes).toBeFalsy(); + + const migratedTestDocNew = migrateAgain(migratedTestDoc); + const visState = JSON.parse(migratedTestDocNew.attributes.visState); + + expect(visState.params.hide_last_value_indicator).toBeTruthy(); + expect(visState.params).not.toHaveProperty('default_index_pattern'); + expect(visState.params).not.toHaveProperty('default_timefield'); + expect(params.use_kibana_indexes).toBeFalsy(); + }); + }); }); diff --git a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts similarity index 96% rename from src/plugins/visualizations/server/saved_objects/visualization_migrations.ts rename to src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts index 093255d65a7a8c..b9885588b6f760 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts @@ -11,6 +11,11 @@ import { cloneDeep, get, omit, has, flow, forOwn } from 'lodash'; import { SavedObjectMigrationFn } from 'kibana/server'; import { DEFAULT_QUERY_LANGUAGE } from '../../../data/common'; +import { + commonAddSupportOfDualIndexSelectionModeInTSVB, + commonHideTSVBLastValueIndicator, + commonRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel, +} from './visualization_common_migrations'; const migrateIndexPattern: SavedObjectMigrationFn = (doc) => { const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON'); @@ -799,22 +804,16 @@ const addSupportOfDualIndexSelectionModeInTSVB: SavedObjectMigrationFn visState = JSON.parse(visStateJSON); } catch (e) { // Let it go, the data is invalid and we'll leave it as is + return doc; } - if (visState && visState.type === 'metrics') { - const { params } = visState; - - if (typeof params?.index_pattern === 'string') { - params.use_kibana_indexes = false; - } - - return { - ...doc, - attributes: { - ...doc.attributes, - visState: JSON.stringify(visState), - }, - }; - } + const newVisState = commonAddSupportOfDualIndexSelectionModeInTSVB(visState); + return { + ...doc, + attributes: { + ...doc.attributes, + visState: JSON.stringify(newVisState), + }, + }; } return doc; }; @@ -929,25 +928,17 @@ const migrateVislibAreaLineBarTypes: SavedObjectMigrationFn = (doc) => const hideTSVBLastValueIndicator: SavedObjectMigrationFn = (doc) => { try { const visState = JSON.parse(doc.attributes.visState); - - if (visState && visState.type === 'metrics' && visState.params.type !== 'timeseries') - return { - ...doc, - attributes: { - ...doc.attributes, - visState: JSON.stringify({ - ...visState, - params: { - ...visState.params, - hide_last_value_indicator: true, - }, - }), - }, - }; + const newVisState = commonHideTSVBLastValueIndicator(visState); + return { + ...doc, + attributes: { + ...doc.attributes, + visState: JSON.stringify(newVisState), + }, + }; } catch (e) { // Let it go, the data is invalid and we'll leave it as is } - return doc; }; @@ -962,23 +953,17 @@ const removeDefaultIndexPatternAndTimeFieldFromTSVBModel: SavedObjectMigrationFn visState = JSON.parse(visStateJSON); } catch (e) { // Let it go, the data is invalid and we'll leave it as is - } - if (visState && visState.type === 'metrics') { - const { params } = visState; - - delete params.default_index_pattern; - delete params.default_timefield; - - return { - ...doc, - attributes: { - ...doc.attributes, - visState: JSON.stringify(visState), - }, - }; + return doc; } } - return doc; + const newVisState = commonRemoveDefaultIndexPatternAndTimeFieldFromTSVBModel(visState); + return { + ...doc, + attributes: { + ...doc.attributes, + visState: JSON.stringify(newVisState), + }, + }; }; export const visualizationSavedObjectTypeMigrations = { @@ -1021,4 +1006,10 @@ export const visualizationSavedObjectTypeMigrations = { hideTSVBLastValueIndicator, removeDefaultIndexPatternAndTimeFieldFromTSVBModel ), + '7.13.1': flow( + // duplicate these migrations in case a broken by value panel is added to the library + addSupportOfDualIndexSelectionModeInTSVB, + hideTSVBLastValueIndicator, + removeDefaultIndexPatternAndTimeFieldFromTSVBModel + ), }; diff --git a/src/plugins/visualizations/server/plugin.ts b/src/plugins/visualizations/server/plugin.ts index fa64e28a39dc77..5a5a80b2689d6e 100644 --- a/src/plugins/visualizations/server/plugin.ts +++ b/src/plugins/visualizations/server/plugin.ts @@ -24,6 +24,8 @@ import { visualizationSavedObjectType } from './saved_objects'; import { VisualizationsPluginSetup, VisualizationsPluginStart } from './types'; import { registerVisualizationsCollector } from './usage_collector'; +import { EmbeddableSetup } from '../../embeddable/server'; +import { visualizeEmbeddableFactory } from './embeddable/visualize_embeddable_factory'; export class VisualizationsPlugin implements Plugin { @@ -35,7 +37,10 @@ export class VisualizationsPlugin this.config = initializerContext.config.legacy.globalConfig$; } - public setup(core: CoreSetup, plugins: { usageCollection?: UsageCollectionSetup }) { + public setup( + core: CoreSetup, + plugins: { usageCollection?: UsageCollectionSetup; embeddable: EmbeddableSetup } + ) { this.logger.debug('visualizations: Setup'); core.savedObjects.registerType(visualizationSavedObjectType); @@ -59,6 +64,8 @@ export class VisualizationsPlugin registerVisualizationsCollector(plugins.usageCollection, this.config); } + plugins.embeddable.registerEmbeddableFactory(visualizeEmbeddableFactory()); + return {}; } diff --git a/src/plugins/visualizations/server/saved_objects/visualization.ts b/src/plugins/visualizations/server/saved_objects/visualization.ts index fb7ab3d1531cc6..880e277294fc3a 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization.ts +++ b/src/plugins/visualizations/server/saved_objects/visualization.ts @@ -7,7 +7,7 @@ */ import { SavedObjectsType } from 'kibana/server'; -import { visualizationSavedObjectTypeMigrations } from './visualization_migrations'; +import { visualizationSavedObjectTypeMigrations } from '../migrations/visualization_saved_object_migrations'; export const visualizationSavedObjectType: SavedObjectsType = { name: 'visualization', diff --git a/test/functional/page_objects/time_picker.ts b/test/functional/page_objects/time_picker.ts index d3b6edaffdbd32..4d0930c3ff932d 100644 --- a/test/functional/page_objects/time_picker.ts +++ b/test/functional/page_objects/time_picker.ts @@ -30,9 +30,9 @@ export function TimePickerProvider({ getService, getPageObjects }: FtrProviderCo const testSubjects = getService('testSubjects'); const { header } = getPageObjects(['header']); const kibanaServer = getService('kibanaServer'); - const MenuToggle = getService('MenuToggle'); + const menuToggle = getService('menuToggle'); - const quickSelectTimeMenuToggle = new MenuToggle({ + const quickSelectTimeMenuToggle = menuToggle.create({ name: 'QuickSelectTime Menu', menuTestSubject: 'superDatePickerQuickMenu', toggleButtonTestSubject: 'superDatePickerToggleQuickMenuButton', diff --git a/test/functional/services/embedding.ts b/test/functional/services/embedding.ts index f3d5340e41fa6a..e394aff19ab8b6 100644 --- a/test/functional/services/embedding.ts +++ b/test/functional/services/embedding.ts @@ -6,24 +6,20 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function EmbeddingProvider({ getService, getPageObjects }: FtrProviderContext) { - const browser = getService('browser'); - const log = getService('log'); - const PageObjects = getPageObjects(['header']); +export class EmbeddingService extends FtrService { + private readonly browser = this.ctx.getService('browser'); + private readonly log = this.ctx.getService('log'); + private readonly PageObjects = this.ctx.getPageObjects(['header']); - class Embedding { - /** - * Opens current page in embeded mode - */ - public async openInEmbeddedMode(): Promise { - const currentUrl = await browser.getCurrentUrl(); - log.debug(`Opening in embedded mode: ${currentUrl}`); - await browser.get(`${currentUrl}&embed=true`); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + /** + * Opens current page in embeded mode + */ + public async openInEmbeddedMode(): Promise { + const currentUrl = await this.browser.getCurrentUrl(); + this.log.debug(`Opening in embedded mode: ${currentUrl}`); + await this.browser.get(`${currentUrl}&embed=true`); + await this.PageObjects.header.waitUntilLoadingHasFinished(); } - - return new Embedding(); } diff --git a/test/functional/services/index.ts b/test/functional/services/index.ts index 03c43ffc302146..a509141390f676 100644 --- a/test/functional/services/index.ts +++ b/test/functional/services/index.ts @@ -26,16 +26,16 @@ import { DashboardVisualizationsService, } from './dashboard'; import { DocTableService } from './doc_table'; -import { EmbeddingProvider } from './embedding'; +import { EmbeddingService } from './embedding'; import { FilterBarService } from './filter_bar'; import { FlyoutService } from './flyout'; import { GlobalNavService } from './global_nav'; import { InspectorService } from './inspector'; import { FieldEditorService } from './field_editor'; import { ManagementMenuService } from './management'; -import { QueryBarProvider } from './query_bar'; +import { QueryBarService } from './query_bar'; import { RemoteProvider } from './remote'; -import { RenderableProvider } from './renderable'; +import { RenderableService } from './renderable'; import { ToastsService } from './toasts'; import { DataGridService } from './data_grid'; import { @@ -44,9 +44,9 @@ import { VegaDebugInspectorViewService, } from './visualizations'; import { ListingTableService } from './listing_table'; -import { SavedQueryManagementComponentProvider } from './saved_query_management_component'; +import { SavedQueryManagementComponentService } from './saved_query_management_component'; import { KibanaSupertestProvider } from './supertest'; -import { MenuToggleProvider } from './menu_toggle'; +import { MenuToggleService } from './menu_toggle'; import { MonacoEditorProvider } from './monaco_editor'; export const services = { @@ -54,7 +54,7 @@ export const services = { __webdriver__: RemoteProvider, filterBar: FilterBarService, - queryBar: QueryBarProvider, + queryBar: QueryBarService, find: FindProvider, testSubjects: TestSubjects, docTable: DocTableService, @@ -70,8 +70,8 @@ export const services = { flyout: FlyoutService, comboBox: ComboBoxService, dataGrid: DataGridService, - embedding: EmbeddingProvider, - renderable: RenderableProvider, + embedding: EmbeddingService, + renderable: RenderableService, browser: BrowserProvider, pieChart: PieChartService, inspector: InspectorService, @@ -80,10 +80,10 @@ export const services = { appsMenu: AppsMenuService, globalNav: GlobalNavService, toasts: ToastsService, - savedQueryManagementComponent: SavedQueryManagementComponentProvider, + savedQueryManagementComponent: SavedQueryManagementComponentService, elasticChart: ElasticChartService, supertest: KibanaSupertestProvider, managementMenu: ManagementMenuService, monacoEditor: MonacoEditorProvider, - MenuToggle: MenuToggleProvider, + menuToggle: MenuToggleService, }; diff --git a/test/functional/services/menu_toggle.ts b/test/functional/services/menu_toggle.ts index 866d73bd9df251..4de66a5697775d 100644 --- a/test/functional/services/menu_toggle.ts +++ b/test/functional/services/menu_toggle.ts @@ -6,61 +6,56 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function MenuToggleProvider({ getService }: FtrProviderContext) { - const log = getService('log'); - const retry = getService('retry'); - const testSubjects = getService('testSubjects'); - - interface Options { - name: string; - menuTestSubject: string; - toggleButtonTestSubject: string; - } - - return class MenuToggle { - private readonly name: string; - private readonly menuTestSubject: string; - private readonly toggleButtonTestSubject: string; +interface Options { + name: string; + menuTestSubject: string; + toggleButtonTestSubject: string; +} - constructor(options: Options) { - this.name = options.name; - this.menuTestSubject = options.menuTestSubject; - this.toggleButtonTestSubject = options.toggleButtonTestSubject; - } +export class MenuToggleService extends FtrService { + private readonly log = this.ctx.getService('log'); + private readonly retry = this.ctx.getService('retry'); + private readonly testSubjects = this.ctx.getService('testSubjects'); - async open() { - await this.setState(true); - } + create(options: Options) { + const { log, retry, testSubjects } = this; + const { name, menuTestSubject, toggleButtonTestSubject } = options; - async close() { - await this.setState(false); - } - - private async setState(expectedState: boolean) { + async function setState(expectedState: boolean) { log.debug( - `setting menu open state [name=${this.name}] [state=${expectedState ? 'open' : 'closed'}]` + `setting menu open state [name=${name}] [state=${expectedState ? 'open' : 'closed'}]` ); await retry.try(async () => { // if the menu is clearly in the expected state already, bail out quickly if so - const isOpen = await testSubjects.exists(this.menuTestSubject, { timeout: 1000 }); + const isOpen = await testSubjects.exists(menuTestSubject, { timeout: 1000 }); if (isOpen === expectedState) { return; } // toggle the view state by clicking the button - await testSubjects.click(this.toggleButtonTestSubject); + await testSubjects.click(toggleButtonTestSubject); if (expectedState === true) { // wait for up to 10 seconds for the menu to show up, otherwise fail and retry - await testSubjects.existOrFail(this.menuTestSubject, { timeout: 10000 }); + await testSubjects.existOrFail(menuTestSubject, { timeout: 10000 }); } else { // wait for the form to hide, otherwise fail and retry - await testSubjects.waitForDeleted(this.menuTestSubject); + await testSubjects.waitForDeleted(menuTestSubject); } }); } - }; + + return { + async open() { + await setState(true); + }, + + async close() { + await setState(false); + }, + }; + } } diff --git a/test/functional/services/query_bar.ts b/test/functional/services/query_bar.ts index 2c4cd3b8db131f..31586d92d92a9d 100644 --- a/test/functional/services/query_bar.ts +++ b/test/functional/services/query_bar.ts @@ -7,82 +7,76 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; -export function QueryBarProvider({ getService, getPageObjects }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const retry = getService('retry'); - const log = getService('log'); - const PageObjects = getPageObjects(['header', 'common']); - const find = getService('find'); - const browser = getService('browser'); +export class QueryBarService extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly retry = this.ctx.getService('retry'); + private readonly log = this.ctx.getService('log'); + private readonly PageObjects = this.ctx.getPageObjects(['header', 'common']); + private readonly find = this.ctx.getService('find'); + private readonly browser = this.ctx.getService('browser'); - class QueryBar { - async getQueryString(): Promise { - return await testSubjects.getAttribute('queryInput', 'value'); - } + async getQueryString(): Promise { + return await this.testSubjects.getAttribute('queryInput', 'value'); + } - public async setQuery(query: string): Promise { - log.debug(`QueryBar.setQuery(${query})`); - // Extra caution used because of flaky test here: https://github.com/elastic/kibana/issues/16978 doesn't seem - // to be actually setting the query in the query input based off - await retry.try(async () => { - await testSubjects.click('queryInput'); + public async setQuery(query: string): Promise { + this.log.debug(`QueryBar.setQuery(${query})`); + // Extra caution used because of flaky test here: https://github.com/elastic/kibana/issues/16978 doesn't seem + // to be actually setting the query in the query input based off + await this.retry.try(async () => { + await this.testSubjects.click('queryInput'); - // testSubjects.setValue uses input.clearValue which wasn't working, but input.clearValueWithKeyboard does. - // So the following lines do the same thing as input.setValue but with input.clearValueWithKeyboard instead. - const input = await find.activeElement(); - await input.clearValueWithKeyboard(); - await input.type(query); - const currentQuery = await this.getQueryString(); - if (currentQuery !== query) { - throw new Error( - `Failed to set query input to ${query}, instead query is ${currentQuery}` - ); - } - }); - } - - public async clearQuery(): Promise { - await this.setQuery(''); - await PageObjects.common.pressTabKey(); // move outside of input into language switcher - await PageObjects.common.pressTabKey(); // move outside of language switcher so time picker appears - } + // this.testSubjects.setValue uses input.clearValue which wasn't working, but input.clearValueWithKeyboard does. + // So the following lines do the same thing as input.setValue but with input.clearValueWithKeyboard instead. + const input = await this.find.activeElement(); + await input.clearValueWithKeyboard(); + await input.type(query); + const currentQuery = await this.getQueryString(); + if (currentQuery !== query) { + throw new Error(`Failed to set query input to ${query}, instead query is ${currentQuery}`); + } + }); + } - public async submitQuery(): Promise { - log.debug('QueryBar.submitQuery'); - await testSubjects.click('queryInput'); - await PageObjects.common.pressEnterKey(); - await PageObjects.header.waitUntilLoadingHasFinished(); - } + public async clearQuery(): Promise { + await this.setQuery(''); + await this.PageObjects.common.pressTabKey(); // move outside of input into language switcher + await this.PageObjects.common.pressTabKey(); // move outside of language switcher so time picker appears + } - public async clickQuerySubmitButton(): Promise { - await testSubjects.click('querySubmitButton'); - } + public async submitQuery(): Promise { + this.log.debug('QueryBar.submitQuery'); + await this.testSubjects.click('queryInput'); + await this.PageObjects.common.pressEnterKey(); + await this.PageObjects.header.waitUntilLoadingHasFinished(); + } - public async switchQueryLanguage(lang: 'kql' | 'lucene'): Promise { - await testSubjects.click('switchQueryLanguageButton'); - const kqlToggle = await testSubjects.find('languageToggle'); - const currentLang = - (await kqlToggle.getAttribute('aria-checked')) === 'true' ? 'kql' : 'lucene'; - if (lang !== currentLang) { - await kqlToggle.click(); - } + public async clickQuerySubmitButton(): Promise { + await this.testSubjects.click('querySubmitButton'); + } - await browser.pressKeys(browser.keys.ESCAPE); // close popover - await this.expectQueryLanguageOrFail(lang); // make sure lang is switched + public async switchQueryLanguage(lang: 'kql' | 'lucene'): Promise { + await this.testSubjects.click('switchQueryLanguageButton'); + const kqlToggle = await this.testSubjects.find('languageToggle'); + const currentLang = + (await kqlToggle.getAttribute('aria-checked')) === 'true' ? 'kql' : 'lucene'; + if (lang !== currentLang) { + await kqlToggle.click(); } - public async expectQueryLanguageOrFail(lang: 'kql' | 'lucene'): Promise { - const queryLanguageButton = await testSubjects.find('switchQueryLanguageButton'); - expect((await queryLanguageButton.getVisibleText()).toLowerCase()).to.eql(lang); - } + await this.browser.pressKeys(this.browser.keys.ESCAPE); // close popover + await this.expectQueryLanguageOrFail(lang); // make sure lang is switched + } - public async getSuggestions() { - const suggestions = await testSubjects.findAll('autoCompleteSuggestionText'); - return Promise.all(suggestions.map((suggestion) => suggestion.getVisibleText())); - } + public async expectQueryLanguageOrFail(lang: 'kql' | 'lucene'): Promise { + const queryLanguageButton = await this.testSubjects.find('switchQueryLanguageButton'); + expect((await queryLanguageButton.getVisibleText()).toLowerCase()).to.eql(lang); } - return new QueryBar(); + public async getSuggestions() { + const suggestions = await this.testSubjects.findAll('autoCompleteSuggestionText'); + return Promise.all(suggestions.map((suggestion) => suggestion.getVisibleText())); + } } diff --git a/test/functional/services/renderable.ts b/test/functional/services/renderable.ts index 42ea808bd64ba4..da298b6ec0343e 100644 --- a/test/functional/services/renderable.ts +++ b/test/functional/services/renderable.ts @@ -6,49 +6,45 @@ * Side Public License, v 1. */ -import { FtrProviderContext } from '../ftr_provider_context'; +import { FtrService } from '../ftr_provider_context'; const RENDER_COMPLETE_SELECTOR = '[data-render-complete="true"]'; const RENDER_COMPLETE_PENDING_SELECTOR = '[data-render-complete="false"]'; const DATA_LOADING_SELECTOR = '[data-loading]'; -export function RenderableProvider({ getService }: FtrProviderContext) { - const log = getService('log'); - const retry = getService('retry'); - const find = getService('find'); +export class RenderableService extends FtrService { + private readonly log = this.ctx.getService('log'); + private readonly retry = this.ctx.getService('retry'); + private readonly find = this.ctx.getService('find'); - class Renderable { - /** - * This method waits for a certain number of objects to finish rendering and loading, which is indicated - * by a couple tags. The RENDER_COMPLETE_SELECTOR indicates that it's done initially loading up. Some - * visualizations also add a DATA_LOADING_SELECTOR when the internal data is loading. This test will not - * return if any of those tags are found. - * @param count {Number} Number of RENDER_COMPLETE_SELECTORs to wait for. - */ - public async waitForRender(count: number = 1): Promise { - log.debug(`Renderable.waitForRender for ${count} elements`); - await retry.try(async () => { - const completedElements = await find.allByCssSelector(RENDER_COMPLETE_SELECTOR); - if (completedElements.length < count) { - const pendingElements = await find.allByCssSelector(RENDER_COMPLETE_PENDING_SELECTOR); - const pendingElementNames = []; - for (const pendingElement of pendingElements) { - const title = await pendingElement.getAttribute('data-title'); - pendingElementNames.push(title); - } - throw new Error(`${ - completedElements.length - } elements completed rendering, still waiting on a total of ${count} - specifically:\n${pendingElementNames.join('\n')}`); + /** + * This method waits for a certain number of objects to finish rendering and loading, which is indicated + * by a couple tags. The RENDER_COMPLETE_SELECTOR indicates that it's done initially loading up. Some + * visualizations also add a DATA_LOADING_SELECTOR when the internal data is loading. This test will not + * return if any of those tags are found. + * @param count {Number} Number of RENDER_COMPLETE_SELECTORs to wait for. + */ + public async waitForRender(count: number = 1): Promise { + this.log.debug(`Renderable.waitForRender for ${count} elements`); + await this.retry.try(async () => { + const completedElements = await this.find.allByCssSelector(RENDER_COMPLETE_SELECTOR); + if (completedElements.length < count) { + const pendingElements = await this.find.allByCssSelector(RENDER_COMPLETE_PENDING_SELECTOR); + const pendingElementNames = []; + for (const pendingElement of pendingElements) { + const title = await pendingElement.getAttribute('data-title'); + pendingElementNames.push(title); } + throw new Error(`${ + completedElements.length + } elements completed rendering, still waiting on a total of ${count} + specifically:\n${pendingElementNames.join('\n')}`); + } - const stillLoadingElements = await find.allByCssSelector(DATA_LOADING_SELECTOR, 1000); - if (stillLoadingElements.length > 0) { - throw new Error(`${stillLoadingElements.length} elements still loading contents`); - } - }); - } + const stillLoadingElements = await this.find.allByCssSelector(DATA_LOADING_SELECTOR, 1000); + if (stillLoadingElements.length > 0) { + throw new Error(`${stillLoadingElements.length} elements still loading contents`); + } + }); } - - return new Renderable(); } diff --git a/test/functional/services/saved_query_management_component.ts b/test/functional/services/saved_query_management_component.ts index d4fa34f224547c..aabe8c0aebb0c6 100644 --- a/test/functional/services/saved_query_management_component.ts +++ b/test/functional/services/saved_query_management_component.ts @@ -7,210 +7,205 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../ftr_provider_context'; - -export function SavedQueryManagementComponentProvider({ - getService, - getPageObjects, -}: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - const queryBar = getService('queryBar'); - const retry = getService('retry'); - const config = getService('config'); - const PageObjects = getPageObjects(['common']); - - class SavedQueryManagementComponent { - public async getCurrentlyLoadedQueryID() { - await this.openSavedQueryManagementComponent(); - try { - return await testSubjects.getVisibleText('~saved-query-list-item-selected'); - } catch { - return undefined; - } - } - - public async saveNewQuery( - name: string, - description: string, - includeFilters: boolean, - includeTimeFilter: boolean - ) { - await this.openSaveCurrentQueryModal(); - await this.submitSaveQueryForm(name, description, includeFilters, includeTimeFilter); +import { FtrService } from '../ftr_provider_context'; + +export class SavedQueryManagementComponentService extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly queryBar = this.ctx.getService('queryBar'); + private readonly retry = this.ctx.getService('retry'); + private readonly config = this.ctx.getService('config'); + private readonly PageObjects = this.ctx.getPageObjects(['common']); + + public async getCurrentlyLoadedQueryID() { + await this.openSavedQueryManagementComponent(); + try { + return await this.testSubjects.getVisibleText('~saved-query-list-item-selected'); + } catch { + return undefined; } + } - public async saveNewQueryWithNameError(name?: string) { - await this.openSaveCurrentQueryModal(); - if (name) { - await testSubjects.setValue('saveQueryFormTitle', name); - } - - // Form input validation only happens onBlur. Clicking the save button should de-focus the - // input element and the validation should prevent a save from actually happening if there's - // an error. - await testSubjects.click('savedQueryFormSaveButton'); - - await retry.waitForWithTimeout('save button to be disabled', 1000, async () => { - const saveQueryFormSaveButtonStatus = await testSubjects.isEnabled( - 'savedQueryFormSaveButton' - ); - return saveQueryFormSaveButtonStatus === false; - }); + public async saveNewQuery( + name: string, + description: string, + includeFilters: boolean, + includeTimeFilter: boolean + ) { + await this.openSaveCurrentQueryModal(); + await this.submitSaveQueryForm(name, description, includeFilters, includeTimeFilter); + } - await testSubjects.click('savedQueryFormCancelButton'); + public async saveNewQueryWithNameError(name?: string) { + await this.openSaveCurrentQueryModal(); + if (name) { + await this.testSubjects.setValue('saveQueryFormTitle', name); } - public async saveCurrentlyLoadedAsNewQuery( - name: string, - description: string, - includeFilters: boolean, - includeTimeFilter: boolean - ) { - await this.openSavedQueryManagementComponent(); - await testSubjects.click('saved-query-management-save-as-new-button'); - await this.submitSaveQueryForm(name, description, includeFilters, includeTimeFilter); - } + // Form input validation only happens onBlur. Clicking the save button should de-focus the + // input element and the validation should prevent a save from actually happening if there's + // an error. + await this.testSubjects.click('savedQueryFormSaveButton'); - public async updateCurrentlyLoadedQuery( - description: string, - includeFilters: boolean, - includeTimeFilter: boolean - ) { - await this.openSavedQueryManagementComponent(); - await testSubjects.click('saved-query-management-save-changes-button'); - await this.submitSaveQueryForm(null, description, includeFilters, includeTimeFilter); - } + await this.retry.waitForWithTimeout('save button to be disabled', 1000, async () => { + const saveQueryFormSaveButtonStatus = await this.testSubjects.isEnabled( + 'savedQueryFormSaveButton' + ); + return saveQueryFormSaveButtonStatus === false; + }); - public async loadSavedQuery(title: string) { - await this.openSavedQueryManagementComponent(); - await testSubjects.click(`~load-saved-query-${title}-button`); - await retry.try(async () => { - await this.openSavedQueryManagementComponent(); - const selectedSavedQueryText = await testSubjects.getVisibleText( - '~saved-query-list-item-selected' - ); - expect(selectedSavedQueryText).to.eql(title); - }); - await this.closeSavedQueryManagementComponent(); - } + await this.testSubjects.click('savedQueryFormCancelButton'); + } - public async deleteSavedQuery(title: string) { - await this.openSavedQueryManagementComponent(); - await testSubjects.click(`~delete-saved-query-${title}-button`); - await PageObjects.common.clickConfirmOnModal(); - } + public async saveCurrentlyLoadedAsNewQuery( + name: string, + description: string, + includeFilters: boolean, + includeTimeFilter: boolean + ) { + await this.openSavedQueryManagementComponent(); + await this.testSubjects.click('saved-query-management-save-as-new-button'); + await this.submitSaveQueryForm(name, description, includeFilters, includeTimeFilter); + } - async clearCurrentlyLoadedQuery() { + public async updateCurrentlyLoadedQuery( + description: string, + includeFilters: boolean, + includeTimeFilter: boolean + ) { + await this.openSavedQueryManagementComponent(); + await this.testSubjects.click('saved-query-management-save-changes-button'); + await this.submitSaveQueryForm(null, description, includeFilters, includeTimeFilter); + } + + public async loadSavedQuery(title: string) { + await this.openSavedQueryManagementComponent(); + await this.testSubjects.click(`~load-saved-query-${title}-button`); + await this.retry.try(async () => { await this.openSavedQueryManagementComponent(); - await testSubjects.click('saved-query-management-clear-button'); - await this.closeSavedQueryManagementComponent(); - const queryString = await queryBar.getQueryString(); - expect(queryString).to.be.empty(); - } + const selectedSavedQueryText = await this.testSubjects.getVisibleText( + '~saved-query-list-item-selected' + ); + expect(selectedSavedQueryText).to.eql(title); + }); + await this.closeSavedQueryManagementComponent(); + } - async submitSaveQueryForm( - title: string | null, - description: string, - includeFilters: boolean, - includeTimeFilter: boolean - ) { - if (title) { - await testSubjects.setValue('saveQueryFormTitle', title); - } - await testSubjects.setValue('saveQueryFormDescription', description); - - const currentIncludeFiltersValue = - (await testSubjects.getAttribute('saveQueryFormIncludeFiltersOption', 'aria-checked')) === - 'true'; - if (currentIncludeFiltersValue !== includeFilters) { - await testSubjects.click('saveQueryFormIncludeFiltersOption'); - } - - const currentIncludeTimeFilterValue = - (await testSubjects.getAttribute( - 'saveQueryFormIncludeTimeFilterOption', - 'aria-checked' - )) === 'true'; - if (currentIncludeTimeFilterValue !== includeTimeFilter) { - await testSubjects.click('saveQueryFormIncludeTimeFilterOption'); - } - - await testSubjects.click('savedQueryFormSaveButton'); - } + public async deleteSavedQuery(title: string) { + await this.openSavedQueryManagementComponent(); + await this.testSubjects.click(`~delete-saved-query-${title}-button`); + await this.PageObjects.common.clickConfirmOnModal(); + } - async savedQueryExist(title: string) { - await this.openSavedQueryManagementComponent(); - const exists = testSubjects.exists(`~load-saved-query-${title}-button`); - await this.closeSavedQueryManagementComponent(); - return exists; - } + async clearCurrentlyLoadedQuery() { + await this.openSavedQueryManagementComponent(); + await this.testSubjects.click('saved-query-management-clear-button'); + await this.closeSavedQueryManagementComponent(); + const queryString = await this.queryBar.getQueryString(); + expect(queryString).to.be.empty(); + } - async savedQueryExistOrFail(title: string) { - await this.openSavedQueryManagementComponent(); - await testSubjects.existOrFail(`~load-saved-query-${title}-button`); + async submitSaveQueryForm( + title: string | null, + description: string, + includeFilters: boolean, + includeTimeFilter: boolean + ) { + if (title) { + await this.testSubjects.setValue('saveQueryFormTitle', title); } + await this.testSubjects.setValue('saveQueryFormDescription', description); - async savedQueryTextExist(text: string) { - await this.openSavedQueryManagementComponent(); - const queryString = await queryBar.getQueryString(); - expect(queryString).to.eql(text); + const currentIncludeFiltersValue = + (await this.testSubjects.getAttribute( + 'saveQueryFormIncludeFiltersOption', + 'aria-checked' + )) === 'true'; + if (currentIncludeFiltersValue !== includeFilters) { + await this.testSubjects.click('saveQueryFormIncludeFiltersOption'); } - async savedQueryMissingOrFail(title: string) { - await retry.try(async () => { - await this.openSavedQueryManagementComponent(); - await testSubjects.missingOrFail(`~load-saved-query-${title}-button`); - }); - await this.closeSavedQueryManagementComponent(); + const currentIncludeTimeFilterValue = + (await this.testSubjects.getAttribute( + 'saveQueryFormIncludeTimeFilterOption', + 'aria-checked' + )) === 'true'; + if (currentIncludeTimeFilterValue !== includeTimeFilter) { + await this.testSubjects.click('saveQueryFormIncludeTimeFilterOption'); } - async openSavedQueryManagementComponent() { - const isOpenAlready = await testSubjects.exists('saved-query-management-popover'); - if (isOpenAlready) return; + await this.testSubjects.click('savedQueryFormSaveButton'); + } - await retry.waitFor('saved query management popover to have any text', async () => { - await testSubjects.click('saved-query-management-popover-button'); - const queryText = await testSubjects.getVisibleText('saved-query-management-popover'); - return queryText.length > 0; - }); - } + async savedQueryExist(title: string) { + await this.openSavedQueryManagementComponent(); + const exists = this.testSubjects.exists(`~load-saved-query-${title}-button`); + await this.closeSavedQueryManagementComponent(); + return exists; + } - async closeSavedQueryManagementComponent() { - const isOpenAlready = await testSubjects.exists('saved-query-management-popover'); - if (!isOpenAlready) return; + async savedQueryExistOrFail(title: string) { + await this.openSavedQueryManagementComponent(); + await this.testSubjects.existOrFail(`~load-saved-query-${title}-button`); + } - await retry.try(async () => { - await testSubjects.click('saved-query-management-popover-button'); - await testSubjects.missingOrFail('saved-query-management-popover'); - }); - } + async savedQueryTextExist(text: string) { + await this.openSavedQueryManagementComponent(); + const queryString = await this.queryBar.getQueryString(); + expect(queryString).to.eql(text); + } - async openSaveCurrentQueryModal() { + async savedQueryMissingOrFail(title: string) { + await this.retry.try(async () => { await this.openSavedQueryManagementComponent(); + await this.testSubjects.missingOrFail(`~load-saved-query-${title}-button`); + }); + await this.closeSavedQueryManagementComponent(); + } - await retry.try(async () => { - await testSubjects.click('saved-query-management-save-button'); - await testSubjects.existOrFail('saveQueryForm', { - timeout: config.get('timeouts.waitForExists'), - }); - }); - } + async openSavedQueryManagementComponent() { + const isOpenAlready = await this.testSubjects.exists('saved-query-management-popover'); + if (isOpenAlready) return; - async saveNewQueryMissingOrFail() { - await this.openSavedQueryManagementComponent(); - await testSubjects.missingOrFail('saved-query-management-save-button'); - } + await this.retry.waitFor('saved query management popover to have any text', async () => { + await this.testSubjects.click('saved-query-management-popover-button'); + const queryText = await this.testSubjects.getVisibleText('saved-query-management-popover'); + return queryText.length > 0; + }); + } - async updateCurrentlyLoadedQueryMissingOrFail() { - await this.openSavedQueryManagementComponent(); - await testSubjects.missingOrFail('saved-query-management-save-changes-button'); - } + async closeSavedQueryManagementComponent() { + const isOpenAlready = await this.testSubjects.exists('saved-query-management-popover'); + if (!isOpenAlready) return; - async deleteSavedQueryMissingOrFail(title: string) { - await this.openSavedQueryManagementComponent(); - await testSubjects.missingOrFail(`delete-saved-query-${title}-button`); - } + await this.retry.try(async () => { + await this.testSubjects.click('saved-query-management-popover-button'); + await this.testSubjects.missingOrFail('saved-query-management-popover'); + }); + } + + async openSaveCurrentQueryModal() { + await this.openSavedQueryManagementComponent(); + + await this.retry.try(async () => { + await this.testSubjects.click('saved-query-management-save-button'); + await this.testSubjects.existOrFail('saveQueryForm', { + timeout: this.config.get('timeouts.waitForExists'), + }); + }); + } + + async saveNewQueryMissingOrFail() { + await this.openSavedQueryManagementComponent(); + await this.testSubjects.missingOrFail('saved-query-management-save-button'); } - return new SavedQueryManagementComponent(); + async updateCurrentlyLoadedQueryMissingOrFail() { + await this.openSavedQueryManagementComponent(); + await this.testSubjects.missingOrFail('saved-query-management-save-changes-button'); + } + + async deleteSavedQueryMissingOrFail(title: string) { + await this.openSavedQueryManagementComponent(); + await this.testSubjects.missingOrFail(`delete-saved-query-${title}-button`); + } } diff --git a/test/package/Vagrantfile b/test/package/Vagrantfile new file mode 100644 index 00000000000000..34c29eb2cefe71 --- /dev/null +++ b/test/package/Vagrantfile @@ -0,0 +1,27 @@ +Vagrant.configure("2") do |config| + config.vm.synced_folder '../../target/', '/packages' + + config.vm.define "deb" do |deb| + deb.vm.box = 'elastic/debian-9-x86_64' + deb.vm.provision "ansible" do |ansible| + ansible.playbook = "deb.yml" + end + deb.vm.network "private_network", ip: "192.168.50.5" + end + + config.vm.define "rpm" do |rpm| + rpm.vm.box = 'elastic/centos-7-x86_64' + rpm.vm.provision "ansible" do |ansible| + ansible.playbook = "rpm.yml" + end + rpm.vm.network "private_network", ip: "192.168.50.6" + end + + config.vm.define "docker" do |docker| + docker.vm.box = 'elastic/ubuntu-18.04-x86_64' + docker.vm.provision "ansible" do |ansible| + ansible.playbook = "docker.yml" + end + docker.vm.network "private_network", ip: "192.168.50.7" + end +end diff --git a/test/package/deb.yml b/test/package/deb.yml new file mode 100644 index 00000000000000..294657e99473f7 --- /dev/null +++ b/test/package/deb.yml @@ -0,0 +1,11 @@ +- name: test kibana deb package + hosts: deb + roles: + - install_kibana_deb + - assert_keystore_available + - assert_keystore_cli + - assert_kibana_yml + - assert_kibana_listening + - assert_kibana_available + - assert_kibana_log + - assert_kibana_data diff --git a/test/package/docker.yml b/test/package/docker.yml new file mode 100644 index 00000000000000..6da20360c174ad --- /dev/null +++ b/test/package/docker.yml @@ -0,0 +1,7 @@ +- name: test kibana docker package + hosts: docker + roles: + - install_docker + - install_kibana_docker + - assert_kibana_listening + - assert_kibana_available diff --git a/test/package/group_vars/all.yml b/test/package/group_vars/all.yml new file mode 100644 index 00000000000000..cedf02162271ab --- /dev/null +++ b/test/package/group_vars/all.yml @@ -0,0 +1,2 @@ +elasticsearch_username: kibana_system +elasticsearch_password: changeme diff --git a/test/package/roles/assert_keystore_available/tasks/main.yml b/test/package/roles/assert_keystore_available/tasks/main.yml new file mode 100644 index 00000000000000..cc0b3c187aa27e --- /dev/null +++ b/test/package/roles/assert_keystore_available/tasks/main.yml @@ -0,0 +1,13 @@ +- name: stat + become: yes + register: keystore + stat: + path: /etc/kibana/kibana.keystore + +- name: 0660 root:kibana + assert: + that: + - keystore.stat.exists + - keystore.stat.mode == "0660" + - keystore.stat.pw_name == "root" + - keystore.stat.gr_name == "kibana" diff --git a/test/package/roles/assert_keystore_cli/tasks/main.yml b/test/package/roles/assert_keystore_cli/tasks/main.yml new file mode 100644 index 00000000000000..4ca2622aa5c014 --- /dev/null +++ b/test/package/roles/assert_keystore_cli/tasks/main.yml @@ -0,0 +1,22 @@ +- name: "add server.name: package-testing" + become: yes + command: + cmd: /usr/share/kibana/bin/kibana-keystore add server.name --stdin + stdin: package-testing + register: kibana_keystore_add + +- debug: + msg: "{{ kibana_keystore_add.stdout }}" + +- name: register kibana-keystore list + become: yes + command: /usr/share/kibana/bin/kibana-keystore list + register: kibana_keystore_list + +- debug: + msg: "{{ kibana_keystore_list.stdout }}" + +- name: assert kibana-keystore list contains server.name + assert: + that: + - kibana_keystore_list.stdout == "server.name" diff --git a/test/package/roles/assert_kibana_available/tasks/main.yml b/test/package/roles/assert_kibana_available/tasks/main.yml new file mode 100644 index 00000000000000..b096f9b87350d2 --- /dev/null +++ b/test/package/roles/assert_kibana_available/tasks/main.yml @@ -0,0 +1,8 @@ +- name: "localhost:5601/api/status" + uri: + url: "http://localhost:5601/api/status" + status_code: [200, 401] + register: result + until: result.status != 503 + retries: 3 + delay: 30 diff --git a/test/package/roles/assert_kibana_data/tasks/main.yml b/test/package/roles/assert_kibana_data/tasks/main.yml new file mode 100644 index 00000000000000..76fba22e3697a9 --- /dev/null +++ b/test/package/roles/assert_kibana_data/tasks/main.yml @@ -0,0 +1,13 @@ +- name: stat /var/lib/kibana + become: yes + register: kibana_data_directory + stat: + path: /var/lib/kibana + +- name: /var/lib/kibana 2750 kibana:kibana + assert: + that: + - kibana_log_directory.stat.exists + - kibana_log_directory.stat.mode == "2750" + - kibana_log_directory.stat.pw_name == "kibana" + - kibana_log_directory.stat.gr_name == "kibana" diff --git a/test/package/roles/assert_kibana_listening/tasks/main.yml b/test/package/roles/assert_kibana_listening/tasks/main.yml new file mode 100644 index 00000000000000..3c072ce426404a --- /dev/null +++ b/test/package/roles/assert_kibana_listening/tasks/main.yml @@ -0,0 +1,2 @@ +- name: localhost:5601 + wait_for: host=localhost port=5601 timeout=60 diff --git a/test/package/roles/assert_kibana_log/tasks/main.yml b/test/package/roles/assert_kibana_log/tasks/main.yml new file mode 100644 index 00000000000000..d0c2d36caa0efd --- /dev/null +++ b/test/package/roles/assert_kibana_log/tasks/main.yml @@ -0,0 +1,27 @@ +- name: stat /var/log/kibana + become: yes + register: kibana_log_directory + stat: + path: /var/log/kibana + +- name: /var/log/kibana 2750 kibana:kibana + assert: + that: + - kibana_log_directory.stat.exists + - kibana_log_directory.stat.mode == "2750" + - kibana_log_directory.stat.pw_name == "kibana" + - kibana_log_directory.stat.gr_name == "kibana" + +- name: stat /var/log/kibana/kibana.log + become: yes + register: kibana_log + stat: + path: /var/log/kibana/kibana.log + +- name: /var/log/kibana/kibana.log 0644 kibana:kibana + assert: + that: + - kibana_log.stat.exists + - kibana_log.stat.mode == "0644" + - kibana_log.stat.pw_name == "kibana" + - kibana_log.stat.gr_name == "kibana" diff --git a/test/package/roles/assert_kibana_pid/tasks/main.yml b/test/package/roles/assert_kibana_pid/tasks/main.yml new file mode 100644 index 00000000000000..a77ab2a743d1fa --- /dev/null +++ b/test/package/roles/assert_kibana_pid/tasks/main.yml @@ -0,0 +1,27 @@ +- name: stat /run/kibana + become: yes + register: kibana_pid_directory + stat: + path: /run/kibana + +- name: /run/kibana 0775 kibana:kibana + assert: + that: + - kibana_pid_directory.stat.exists + - kibana_pid_directory.stat.mode == "0775" + - kibana_pid_directory.stat.pw_name == "kibana" + - kibana_pid_directory.stat.gr_name == "kibana" + +- name: stat /run/kibana/kibana.pid + become: yes + register: kibana_pid + stat: + path: /run/kibana/kibana.pid + +- name: /run/kibana/kibana.pid 0644 kibana:kibana + assert: + that: + - kibana_pid.stat.exists + - kibana_pid.stat.mode == "0644" + - kibana_pid.stat.pw_name == "kibana" + - kibana_pid.stat.gr_name == "kibana" diff --git a/test/package/roles/assert_kibana_yml/tasks/main.yml b/test/package/roles/assert_kibana_yml/tasks/main.yml new file mode 100644 index 00000000000000..02f6c1b7dd1e4b --- /dev/null +++ b/test/package/roles/assert_kibana_yml/tasks/main.yml @@ -0,0 +1,27 @@ +- name: stat /etc/kibana + become: yes + register: kibana_yml_directory + stat: + path: /etc/kibana + +- name: /etc/kibana 2750 root:kibana + assert: + that: + - kibana_yml_directory.stat.exists + - kibana_yml_directory.stat.mode == "2750" + - kibana_yml_directory.stat.pw_name == "root" + - kibana_yml_directory.stat.gr_name == "kibana" + +- name: stat /etc/kibana/kibana.yml + become: yes + register: kibana_yml + stat: + path: /etc/kibana/kibana.yml + +- name: /etc/kibana/kibana.yml 0660 root:kibana + assert: + that: + - kibana_yml.stat.exists + - kibana_yml.stat.mode == "0660" + - kibana_yml.stat.pw_name == "root" + - kibana_yml.stat.gr_name == "kibana" diff --git a/test/package/roles/install_docker/tasks/main.yml b/test/package/roles/install_docker/tasks/main.yml new file mode 100644 index 00000000000000..c985fdcb350ea7 --- /dev/null +++ b/test/package/roles/install_docker/tasks/main.yml @@ -0,0 +1,39 @@ +- name: install dependencies + become: yes + apt: + name: '{{ docker_dependencies }}' + state: present + update_cache: yes + +- name: add docker gpg key + become: yes + apt_key: + url: https://download.docker.com/linux/ubuntu/gpg + state: present + +- name: fetch ubuntu version + shell: lsb_release -cs + register: ubuntu_version + changed_when: false + +- name: add docker repository + become: yes + apt_repository: + repo: 'deb [arch=amd64] https://download.docker.com/linux/ubuntu {{ ubuntu_version.stdout }} stable' + state: present + +- name: update apt packages + become: yes + apt: + update_cache: yes + +- name: install docker + become: yes + apt: + name: 'docker-ce' + state: present + +- name: install docker sdk + become: yes + pip: + name: docker diff --git a/test/package/roles/install_docker/vars/main.yml b/test/package/roles/install_docker/vars/main.yml new file mode 100644 index 00000000000000..ba561ba2e2d91f --- /dev/null +++ b/test/package/roles/install_docker/vars/main.yml @@ -0,0 +1,7 @@ +docker_dependencies: + - apt-transport-https + - ca-certificates + - curl + - gnupg-agent + - software-properties-common + - python3-pip diff --git a/test/package/roles/install_kibana_deb/tasks/main.yml b/test/package/roles/install_kibana_deb/tasks/main.yml new file mode 100644 index 00000000000000..570fd2d06f173d --- /dev/null +++ b/test/package/roles/install_kibana_deb/tasks/main.yml @@ -0,0 +1,34 @@ +- name: install dependencies + become: yes + apt: + name: + - libnss3 + - fonts-liberation + - libfontconfig + state: latest + +- name: find deb package + find: + paths: /packages/ + patterns: kibana-*-amd64.deb + register: kibana_deb + +- name: install + become: yes + apt: + deb: "{{ kibana_deb.files[0].path }}" + state: present + +- name: copy configuration + become: yes + template: + src: templates/kibana.yml + dest: /etc/kibana/kibana.yml + register: config + +- name: start kibana + become: yes + systemd: + state: started + name: kibana + daemon_reload: yes diff --git a/test/package/roles/install_kibana_docker/tasks/main.yml b/test/package/roles/install_kibana_docker/tasks/main.yml new file mode 100644 index 00000000000000..6d2f0a2caed1c1 --- /dev/null +++ b/test/package/roles/install_kibana_docker/tasks/main.yml @@ -0,0 +1,26 @@ +- name: find docker image + find: + paths: /packages/ + patterns: kibana-*-docker-image.tar.gz + register: kibana_docker + +- name: load image + become: yes + docker_image: + name: kibana + load_path: "{{ kibana_docker.files[0].path }}" + timeout: 300 + source: load + state: present + +- name: start kibana + become: yes + docker_container: + name: kibana + image: "{{ kibana_docker.files[0].path | basename| regex_replace('kibana-(.*)-docker-image.tar.gz', 'docker.elastic.co/kibana/kibana:\\1') }}" + network_mode: host + env: + SERVER_HOST: 0.0.0.0 + ELASTICSEARCH_HOSTS: http://192.168.50.1:9200 + ELASTICSEARCH_USERNAME: "{{ elasticsearch_username }}" + ELASTICSEARCH_PASSWORD: "{{ elasticsearch_password }}" diff --git a/test/package/roles/install_kibana_rpm/tasks/main.yml b/test/package/roles/install_kibana_rpm/tasks/main.yml new file mode 100644 index 00000000000000..d29dba33f19b80 --- /dev/null +++ b/test/package/roles/install_kibana_rpm/tasks/main.yml @@ -0,0 +1,45 @@ +- name: install dependencies + become: yes + yum: + name: + - nss + - fontconfig + - freetype + state: latest + +- name: find rpm package + find: + paths: /packages/ + patterns: kibana-*-x86_64.rpm + register: kibana_rpm + +- name: install + become: yes + yum: + name: "{{ kibana_rpm.files[0].path }}" + state: present + disable_gpg_check: yes + +- name: copy configuration + become: yes + template: + src: templates/kibana.yml + dest: /etc/kibana/kibana.yml + register: config + +- name: open port 5601/tcp + become: yes + command: + cmd: firewall-cmd --zone=public --permanent --add-port=5601/tcp + +- name: reload firewall + become: yes + command: + cmd: firewall-cmd --reload + +- name: start kibana + become: yes + systemd: + state: started + name: kibana + daemon_reload: yes diff --git a/test/package/roles/upgrade_apt_packages/tasks/main.yml b/test/package/roles/upgrade_apt_packages/tasks/main.yml new file mode 100644 index 00000000000000..7dd4000d88ad23 --- /dev/null +++ b/test/package/roles/upgrade_apt_packages/tasks/main.yml @@ -0,0 +1,6 @@ +- name: upgrade apt packages + become: yes + apt: + name: '*' + state: latest + update_cache: yes diff --git a/test/package/roles/upgrade_yum_packages/tasks/main.yml b/test/package/roles/upgrade_yum_packages/tasks/main.yml new file mode 100644 index 00000000000000..fa874772cedcc2 --- /dev/null +++ b/test/package/roles/upgrade_yum_packages/tasks/main.yml @@ -0,0 +1,6 @@ +- name: upgrade yum packages + become: yes + yum: + name: '*' + state: latest + update_cache: yes diff --git a/test/package/rpm.yml b/test/package/rpm.yml new file mode 100644 index 00000000000000..456c2bdf18b72f --- /dev/null +++ b/test/package/rpm.yml @@ -0,0 +1,11 @@ +- name: test kibana rpm package + hosts: rpm + roles: + - install_kibana_rpm + - assert_keystore_available + - assert_keystore_cli + - assert_kibana_yml + - assert_kibana_listening + - assert_kibana_available + - assert_kibana_log + - assert_kibana_data diff --git a/test/package/templates/kibana.yml b/test/package/templates/kibana.yml new file mode 100644 index 00000000000000..a5e44b7acb0189 --- /dev/null +++ b/test/package/templates/kibana.yml @@ -0,0 +1,5 @@ +server.host: 0.0.0.0 + +elasticsearch.hosts: http://192.168.50.1:9200 +elasticsearch.username: "{{ elasticsearch_username }}" +elasticsearch.password: "{{ elasticsearch_password }}" diff --git a/test/scripts/jenkins_xpack_package_build.sh b/test/scripts/jenkins_xpack_package_build.sh new file mode 100755 index 00000000000000..698129a2d253bd --- /dev/null +++ b/test/scripts/jenkins_xpack_package_build.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -e + +source src/dev/ci_setup/setup_env.sh + +export TMP=/tmp +export TMPDIR=/tmp + +node scripts/build --all-platforms --debug --no-oss + +gsutil -q -m cp 'target/*' "gs://ci-artifacts.kibana.dev/package-testing/$GIT_COMMIT/" diff --git a/test/scripts/jenkins_xpack_package_deb.sh b/test/scripts/jenkins_xpack_package_deb.sh new file mode 100755 index 00000000000000..42098a6464f530 --- /dev/null +++ b/test/scripts/jenkins_xpack_package_deb.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -e + +source src/dev/ci_setup/setup_env.sh + +gsutil -q -m cp "gs://ci-artifacts.kibana.dev/package-testing/$GIT_COMMIT/kibana-*.deb" ./target + +export VAGRANT_CWD=test/package +vagrant up deb --no-provision + +node scripts/es snapshot \ + -E network.bind_host=127.0.0.1,192.168.50.1 \ + -E discovery.type=single-node \ + --license=trial & +while ! timeout 1 bash -c "echo > /dev/tcp/localhost/9200"; do sleep 30; done + +vagrant provision deb + +export TEST_BROWSER_HEADLESS=1 +export TEST_KIBANA_URL=http://elastic:changeme@192.168.50.5:5601 +export TEST_ES_URL=http://elastic:changeme@192.168.50.1:9200 + +cd x-pack +node scripts/functional_test_runner.js --include-tag=smoke diff --git a/test/scripts/jenkins_xpack_package_docker.sh b/test/scripts/jenkins_xpack_package_docker.sh new file mode 100755 index 00000000000000..6cae2258303805 --- /dev/null +++ b/test/scripts/jenkins_xpack_package_docker.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -e + +source src/dev/ci_setup/setup_env.sh + +gsutil -q -m cp "gs://ci-artifacts.kibana.dev/package-testing/$GIT_COMMIT/kibana-[0-9]*-docker-image.tar.gz" ./target + +export VAGRANT_CWD=test/package +vagrant up docker --no-provision + +node scripts/es snapshot \ + -E network.bind_host=127.0.0.1,192.168.50.1 \ + -E discovery.type=single-node \ + --license=trial & +while ! timeout 1 bash -c "echo > /dev/tcp/localhost/9200"; do sleep 30; done + +vagrant provision docker + +export TEST_BROWSER_HEADLESS=1 +export TEST_KIBANA_URL=http://elastic:changeme@192.168.50.7:5601 +export TEST_ES_URL=http://elastic:changeme@192.168.50.1:9200 + +cd x-pack +node scripts/functional_test_runner.js --include-tag=smoke diff --git a/test/scripts/jenkins_xpack_package_rpm.sh b/test/scripts/jenkins_xpack_package_rpm.sh new file mode 100755 index 00000000000000..6aa7754ee4b213 --- /dev/null +++ b/test/scripts/jenkins_xpack_package_rpm.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -e + +source src/dev/ci_setup/setup_env.sh + +gsutil -q -m cp "gs://ci-artifacts.kibana.dev/package-testing/$GIT_COMMIT/kibana-*.rpm" ./target + +export VAGRANT_CWD=test/package +vagrant up rpm --no-provision + +node scripts/es snapshot \ + -E network.bind_host=127.0.0.1,192.168.50.1 \ + -E discovery.type=single-node \ + --license=trial & +while ! timeout 1 bash -c "echo > /dev/tcp/localhost/9200"; do sleep 30; done + +vagrant provision rpm + +export TEST_BROWSER_HEADLESS=1 +export TEST_KIBANA_URL=http://elastic:changeme@192.168.50.6:5601 +export TEST_ES_URL=http://elastic:changeme@192.168.50.1:9200 + +cd x-pack +node scripts/functional_test_runner.js --include-tag=smoke diff --git a/vars/workers.groovy b/vars/workers.groovy index e0c5ddb358d09d..4f9fc789a04b35 100644 --- a/vars/workers.groovy +++ b/vars/workers.groovy @@ -103,6 +103,7 @@ def base(Map params, Closure closure) { "PR_TARGET_BRANCH=${env.ghprbTargetBranch ?: ''}", "PR_AUTHOR=${env.ghprbPullAuthorLogin ?: ''}", "TEST_BROWSER_HEADLESS=1", + "GIT_COMMIT=${checkoutInfo.commit}", "GIT_BRANCH=${checkoutInfo.branch}", "TMPDIR=${env.WORKSPACE}/tmp", // For Chrome and anything else that respects it "BUILD_TS_REFS_DISABLE=true", // no need to build ts refs in bootstrap diff --git a/x-pack/examples/alerting_example/server/plugin.ts b/x-pack/examples/alerting_example/server/plugin.ts index f6131679874db2..2420be798ec84f 100644 --- a/x-pack/examples/alerting_example/server/plugin.ts +++ b/x-pack/examples/alerting_example/server/plugin.ts @@ -44,7 +44,12 @@ export class AlertingExamplePlugin implements Plugin { allowedHosts: ['*'], preconfiguredAlertHistoryEsIndex: false, preconfigured: {}, - proxyRejectUnauthorizedCertificates: true, - rejectUnauthorized: true, + proxyRejectUnauthorizedCertificates: true, // legacy + rejectUnauthorized: true, // legacy proxyBypassHosts: undefined, proxyOnlyHosts: undefined, maxResponseContentLength: new ByteSizeValue(1000000), @@ -429,6 +429,10 @@ describe('create()', () => { idleInterval: schema.duration().validate('1h'), pageSize: 100, }, + tls: { + verificationMode: 'full', + proxyVerificationMode: 'full', + }, }); const localActionTypeRegistryParams = { diff --git a/x-pack/plugins/actions/server/actions_config.mock.ts b/x-pack/plugins/actions/server/actions_config.mock.ts index fbd9a8cddbdcb9..19a43951377b67 100644 --- a/x-pack/plugins/actions/server/actions_config.mock.ts +++ b/x-pack/plugins/actions/server/actions_config.mock.ts @@ -15,7 +15,9 @@ const createActionsConfigMock = () => { ensureHostnameAllowed: jest.fn().mockReturnValue({}), ensureUriAllowed: jest.fn().mockReturnValue({}), ensureActionTypeEnabled: jest.fn().mockReturnValue({}), - isRejectUnauthorizedCertificatesEnabled: jest.fn().mockReturnValue(true), + getTLSSettings: jest.fn().mockReturnValue({ + verificationMode: 'full', + }), getProxySettings: jest.fn().mockReturnValue(undefined), getResponseSettings: jest.fn().mockReturnValue({ maxContentLength: 1000000, diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index 925e77ca85fb26..93dad226e0c99b 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -27,8 +27,8 @@ const defaultActionsConfig: ActionsConfig = { enabledActionTypes: [], preconfiguredAlertHistoryEsIndex: false, preconfigured: {}, - proxyRejectUnauthorizedCertificates: true, - rejectUnauthorized: true, + proxyRejectUnauthorizedCertificates: true, // legacy + rejectUnauthorized: true, // legacy maxResponseContentLength: new ByteSizeValue(1000000), responseTimeout: moment.duration(60000), cleanupFailedExecutionsTask: { @@ -37,6 +37,10 @@ const defaultActionsConfig: ActionsConfig = { idleInterval: schema.duration().validate('1h'), pageSize: 100, }, + tls: { + proxyVerificationMode: 'full', + verificationMode: 'full', + }, }; describe('ensureUriAllowed', () => { @@ -305,22 +309,45 @@ describe('getProxySettings', () => { expect(proxySettings?.proxyUrl).toBe(config.proxyUrl); }); - test('returns proxyRejectUnauthorizedCertificates', () => { + test('returns proper verificationMode values, beased on the legacy config option proxyRejectUnauthorizedCertificates', () => { const configTrue: ActionsConfig = { ...defaultActionsConfig, proxyUrl: 'https://proxy.elastic.co', proxyRejectUnauthorizedCertificates: true, }; let proxySettings = getActionsConfigurationUtilities(configTrue).getProxySettings(); - expect(proxySettings?.proxyRejectUnauthorizedCertificates).toBe(true); + expect(proxySettings?.proxyTLSSettings.verificationMode).toBe('full'); const configFalse: ActionsConfig = { ...defaultActionsConfig, proxyUrl: 'https://proxy.elastic.co', proxyRejectUnauthorizedCertificates: false, + tls: {}, + }; + proxySettings = getActionsConfigurationUtilities(configFalse).getProxySettings(); + expect(proxySettings?.proxyTLSSettings.verificationMode).toBe('none'); + }); + + test('returns proper verificationMode value, based on the TLS proxy configuration', () => { + const configTrue: ActionsConfig = { + ...defaultActionsConfig, + proxyUrl: 'https://proxy.elastic.co', + tls: { + proxyVerificationMode: 'full', + }, + }; + let proxySettings = getActionsConfigurationUtilities(configTrue).getProxySettings(); + expect(proxySettings?.proxyTLSSettings.verificationMode).toBe('full'); + + const configFalse: ActionsConfig = { + ...defaultActionsConfig, + proxyUrl: 'https://proxy.elastic.co', + tls: { + proxyVerificationMode: 'none', + }, }; proxySettings = getActionsConfigurationUtilities(configFalse).getProxySettings(); - expect(proxySettings?.proxyRejectUnauthorizedCertificates).toBe(false); + expect(proxySettings?.proxyTLSSettings.verificationMode).toBe('none'); }); test('returns proxy headers', () => { @@ -406,13 +433,13 @@ describe('getProxySettings', () => { { url: 'https://elastic.co', tls: { - rejectUnauthorized: true, + verificationMode: 'full', }, }, { url: 'smtp://elastic.co:123', tls: { - rejectUnauthorized: false, + verificationMode: 'none', }, smtp: { ignoreTLS: true, @@ -437,3 +464,25 @@ describe('getProxySettings', () => { expect(chs).toEqual(undefined); }); }); + +describe('getTLSSettings', () => { + test('returns proper verificationMode value, based on the TLS proxy configuration', () => { + const configTrue: ActionsConfig = { + ...defaultActionsConfig, + tls: { + verificationMode: 'full', + }, + }; + let tlsSettings = getActionsConfigurationUtilities(configTrue).getTLSSettings(); + expect(tlsSettings.verificationMode).toBe('full'); + + const configFalse: ActionsConfig = { + ...defaultActionsConfig, + tls: { + verificationMode: 'none', + }, + }; + tlsSettings = getActionsConfigurationUtilities(configFalse).getTLSSettings(); + expect(tlsSettings.verificationMode).toBe('none'); + }); +}); diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index b8cd5878a8972e..d25101f8279f88 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -14,7 +14,8 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { ActionsConfig, AllowedHosts, EnabledActionTypes, CustomHostSettings } from './config'; import { getCanonicalCustomHostUrl } from './lib/custom_host_settings'; import { ActionTypeDisabledError } from './lib'; -import { ProxySettings, ResponseSettings } from './types'; +import { ProxySettings, ResponseSettings, TLSSettings } from './types'; +import { getTLSSettingsFromConfig } from './builtin_action_types/lib/get_node_tls_options'; export { AllowedHosts, EnabledActionTypes } from './config'; @@ -30,7 +31,7 @@ export interface ActionsConfigurationUtilities { ensureHostnameAllowed: (hostname: string) => void; ensureUriAllowed: (uri: string) => void; ensureActionTypeEnabled: (actionType: string) => void; - isRejectUnauthorizedCertificatesEnabled: () => boolean; + getTLSSettings: () => TLSSettings; getProxySettings: () => undefined | ProxySettings; getResponseSettings: () => ResponseSettings; getCustomHostSettings: (targetUrl: string) => CustomHostSettings | undefined; @@ -93,7 +94,10 @@ function getProxySettingsFromConfig(config: ActionsConfig): undefined | ProxySet proxyBypassHosts: arrayAsSet(config.proxyBypassHosts), proxyOnlyHosts: arrayAsSet(config.proxyOnlyHosts), proxyHeaders: config.proxyHeaders, - proxyRejectUnauthorizedCertificates: config.proxyRejectUnauthorizedCertificates, + proxyTLSSettings: getTLSSettingsFromConfig( + config.tls?.proxyVerificationMode, + config.proxyRejectUnauthorizedCertificates + ), }; } @@ -142,8 +146,8 @@ export function getActionsConfigurationUtilities( isActionTypeEnabled, getProxySettings: () => getProxySettingsFromConfig(config), getResponseSettings: () => getResponseSettingsFromConfig(config), - // returns the global rejectUnauthorized setting - isRejectUnauthorizedCertificatesEnabled: () => config.rejectUnauthorized, + getTLSSettings: () => + getTLSSettingsFromConfig(config.tls?.verificationMode, config.rejectUnauthorized), ensureUriAllowed(uri: string) { if (!isUriAllowed(uri)) { throw new Error(allowListErrorMessage(AllowListingField.URL, uri)); diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 5747b4bbb28f4e..98ea436b17f3e1 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -285,9 +285,9 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], + "getTLSSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], - "isRejectUnauthorizedCertificatesEnabled": [MockFunction], "isUriAllowed": [MockFunction], }, "content": Object { @@ -346,9 +346,9 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], + "getTLSSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], - "isRejectUnauthorizedCertificatesEnabled": [MockFunction], "isUriAllowed": [MockFunction], }, "content": Object { diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts index edc9429e4fac6a..ccd5a044971dfc 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts @@ -18,7 +18,7 @@ import { getCustomAgents } from './get_custom_agents'; const TestUrl = 'https://elastic.co/foo/bar/baz'; const logger = loggingSystemMock.create().get() as jest.Mocked; -const configurationUtilities = actionsConfigMock.create(); +let configurationUtilities = actionsConfigMock.create(); jest.mock('axios'); const axiosMock = (axios as unknown) as jest.Mock; @@ -42,6 +42,7 @@ describe('request', () => { headers: { 'content-type': 'application/json' }, data: { incidentId: '123' }, })); + configurationUtilities = actionsConfigMock.create(); configurationUtilities.getResponseSettings.mockReturnValue({ maxContentLength: 1000000, timeout: 360000, @@ -74,7 +75,9 @@ describe('request', () => { test('it have been called with proper proxy agent for a valid url', async () => { configurationUtilities.getProxySettings.mockReturnValue({ - proxyRejectUnauthorizedCertificates: true, + proxyTLSSettings: { + verificationMode: 'full', + }, proxyUrl: 'https://localhost:1212', proxyBypassHosts: undefined, proxyOnlyHosts: undefined, @@ -107,7 +110,9 @@ describe('request', () => { test('it have been called with proper proxy agent for an invalid url', async () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: ':nope:', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: undefined, proxyOnlyHosts: undefined, }); @@ -136,7 +141,9 @@ describe('request', () => { test('it bypasses with proxyBypassHosts when expected', async () => { configurationUtilities.getProxySettings.mockReturnValue({ - proxyRejectUnauthorizedCertificates: true, + proxyTLSSettings: { + verificationMode: 'full', + }, proxyUrl: 'https://elastic.proxy.co', proxyBypassHosts: new Set(['elastic.co']), proxyOnlyHosts: undefined, @@ -157,7 +164,9 @@ describe('request', () => { test('it does not bypass with proxyBypassHosts when expected', async () => { configurationUtilities.getProxySettings.mockReturnValue({ - proxyRejectUnauthorizedCertificates: true, + proxyTLSSettings: { + verificationMode: 'full', + }, proxyUrl: 'https://elastic.proxy.co', proxyBypassHosts: new Set(['not-elastic.co']), proxyOnlyHosts: undefined, @@ -178,7 +187,9 @@ describe('request', () => { test('it proxies with proxyOnlyHosts when expected', async () => { configurationUtilities.getProxySettings.mockReturnValue({ - proxyRejectUnauthorizedCertificates: true, + proxyTLSSettings: { + verificationMode: 'full', + }, proxyUrl: 'https://elastic.proxy.co', proxyBypassHosts: undefined, proxyOnlyHosts: new Set(['elastic.co']), @@ -199,7 +210,9 @@ describe('request', () => { test('it does not proxy with proxyOnlyHosts when expected', async () => { configurationUtilities.getProxySettings.mockReturnValue({ - proxyRejectUnauthorizedCertificates: true, + proxyTLSSettings: { + verificationMode: 'full', + }, proxyUrl: 'https://elastic.proxy.co', proxyBypassHosts: undefined, proxyOnlyHosts: new Set(['not-elastic.co']), @@ -252,6 +265,7 @@ describe('patch', () => { status: 200, headers: { 'content-type': 'application/json' }, })); + configurationUtilities = actionsConfigMock.create(); configurationUtilities.getResponseSettings.mockReturnValue({ maxContentLength: 1000000, timeout: 360000, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts index 80bf51e19c379c..235fca005e225f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils_connection.test.ts @@ -81,23 +81,25 @@ describe('axios connections', () => { await expect(fn()).rejects.toThrow('certificate'); }); - test('it works with rejectUnauthorized false config', async () => { + test('it works with verificationMode "none" config', async () => { const { url, server } = await createServer(true); testServer = server; const configurationUtilities = getACUfromConfig({ - rejectUnauthorized: false, + tls: { + verificationMode: 'none', + }, }); const res = await request({ axios, url, logger, configurationUtilities }); expect(res.status).toBe(200); }); - test('it works with rejectUnauthorized custom host config', async () => { + test('it works with verificationMode "none" for custom host config', async () => { const { url, server } = await createServer(true); testServer = server; const configurationUtilities = getACUfromConfig({ - customHostSettings: [{ url, tls: { rejectUnauthorized: false } }], + customHostSettings: [{ url, tls: { verificationMode: 'none' } }], }); const res = await request({ axios, url, logger, configurationUtilities }); expect(res.status).toBe(200); @@ -125,7 +127,7 @@ describe('axios connections', () => { await expect(fn()).rejects.toThrow('certificate'); }); - test('it works with incorrect ca in custom host config but rejectUnauthorized false', async () => { + test('it works with incorrect ca in custom host config but verificationMode "none"', async () => { const { url, server } = await createServer(true); testServer = server; @@ -135,7 +137,7 @@ describe('axios connections', () => { url, tls: { certificateAuthoritiesData: CA, - rejectUnauthorized: false, + verificationMode: 'none', }, }, ], @@ -144,12 +146,14 @@ describe('axios connections', () => { expect(res.status).toBe(200); }); - test('it works with incorrect ca in custom host config but rejectUnauthorized config true', async () => { + test('it works with incorrect ca in custom host config but verificationMode config "full"', async () => { const { url, server } = await createServer(true); testServer = server; const configurationUtilities = getACUfromConfig({ - rejectUnauthorized: false, + tls: { + verificationMode: 'none', + }, customHostSettings: [ { url, @@ -169,7 +173,7 @@ describe('axios connections', () => { testServer = server; const configurationUtilities = getACUfromConfig({ - customHostSettings: [{ url: otherUrl, tls: { rejectUnauthorized: false } }], + customHostSettings: [{ url: otherUrl, tls: { verificationMode: 'none' } }], }); const fn = async () => await request({ axios, url, logger, configurationUtilities }); await expect(fn()).rejects.toThrow('certificate'); @@ -251,6 +255,10 @@ const BaseActionsConfig: ActionsConfig = { proxyUrl: undefined, proxyHeaders: undefined, proxyRejectUnauthorizedCertificates: true, + tls: { + proxyVerificationMode: 'full', + verificationMode: 'full', + }, proxyBypassHosts: undefined, proxyOnlyHosts: undefined, rejectUnauthorized: true, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts index 805c22806ce4cf..8b4abe86e271ac 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.test.ts @@ -20,16 +20,19 @@ const targetUrlCanonical = `https://${targetHost}:443`; const nonMatchingUrl = `https://${targetHost}m/foo/bar/baz`; describe('getCustomAgents', () => { - const configurationUtilities = actionsConfigMock.create(); + let configurationUtilities = actionsConfigMock.create(); beforeEach(() => { jest.resetAllMocks(); + configurationUtilities = actionsConfigMock.create(); }); test('get agents for valid proxy URL', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: undefined, proxyOnlyHosts: undefined, }); @@ -41,7 +44,9 @@ describe('getCustomAgents', () => { test('return default agents for invalid proxy URL', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: ':nope: not a valid URL', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: undefined, proxyOnlyHosts: undefined, }); @@ -59,7 +64,9 @@ describe('getCustomAgents', () => { test('returns non-proxy agents for matching proxyBypassHosts', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: new Set([targetHost]), proxyOnlyHosts: undefined, }); @@ -71,7 +78,9 @@ describe('getCustomAgents', () => { test('returns proxy agents for non-matching proxyBypassHosts', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: new Set([targetHost]), proxyOnlyHosts: undefined, }); @@ -87,7 +96,9 @@ describe('getCustomAgents', () => { test('returns proxy agents for matching proxyOnlyHosts', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: undefined, proxyOnlyHosts: new Set([targetHost]), }); @@ -99,7 +110,9 @@ describe('getCustomAgents', () => { test('returns non-proxy agents for non-matching proxyOnlyHosts', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: undefined, proxyOnlyHosts: new Set([targetHost]), }); @@ -116,7 +129,7 @@ describe('getCustomAgents', () => { configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, tls: { - rejectUnauthorized: false, + verificationMode: 'none', certificateAuthoritiesData: 'ca data here', }, }); @@ -128,14 +141,16 @@ describe('getCustomAgents', () => { test('handles custom host settings with proxy', () => { configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: undefined, proxyOnlyHosts: undefined, }); configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, tls: { - rejectUnauthorized: false, + verificationMode: 'none', certificateAuthoritiesData: 'ca data here', }, }); @@ -147,12 +162,14 @@ describe('getCustomAgents', () => { expect(httpsAgent?.options.rejectUnauthorized).toBe(false); }); - test('handles overriding global rejectUnauthorized false', () => { - configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(false); + test('handles overriding global verificationMode "none"', () => { + configurationUtilities.getTLSSettings.mockReturnValue({ + verificationMode: 'none', + }); configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, tls: { - rejectUnauthorized: true, + verificationMode: 'certificate', }, }); @@ -163,12 +180,14 @@ describe('getCustomAgents', () => { expect(httpsAgent?.options.rejectUnauthorized).toBeTruthy(); }); - test('handles overriding global rejectUnauthorized true', () => { - configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(true); + test('handles overriding global verificationMode "full"', () => { + configurationUtilities.getTLSSettings.mockReturnValue({ + verificationMode: 'full', + }); configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, tls: { - rejectUnauthorized: false, + verificationMode: 'none', }, }); @@ -179,19 +198,23 @@ describe('getCustomAgents', () => { expect(httpsAgent?.options.rejectUnauthorized).toBeFalsy(); }); - test('handles overriding global rejectUnauthorized false with a proxy', () => { - configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(false); + test('handles overriding global verificationMode "none" with a proxy', () => { + configurationUtilities.getTLSSettings.mockReturnValue({ + verificationMode: 'none', + }); configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, tls: { - rejectUnauthorized: true, + verificationMode: 'full', }, }); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', // note: this setting doesn't come into play, it's for the connection to // the proxy, not the target url - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: undefined, proxyOnlyHosts: undefined, }); @@ -202,19 +225,23 @@ describe('getCustomAgents', () => { expect(httpsAgent?.options.rejectUnauthorized).toBeTruthy(); }); - test('handles overriding global rejectUnauthorized true with a proxy', () => { - configurationUtilities.isRejectUnauthorizedCertificatesEnabled.mockReturnValue(true); + test('handles overriding global verificationMode "full" with a proxy', () => { + configurationUtilities.getTLSSettings.mockReturnValue({ + verificationMode: 'full', + }); configurationUtilities.getCustomHostSettings.mockReturnValue({ url: targetUrlCanonical, tls: { - rejectUnauthorized: false, + verificationMode: 'none', }, }); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', // note: this setting doesn't come into play, it's for the connection to // the proxy, not the target url - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: undefined, proxyOnlyHosts: undefined, }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts index 6ec926004e73ed..a327ee3ffe931f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_custom_agents.ts @@ -11,6 +11,7 @@ import HttpProxyAgent from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { Logger } from '../../../../../../src/core/server'; import { ActionsConfigurationUtilities } from '../../actions_config'; +import { getNodeTLSOptions, getTLSSettingsFromConfig } from './get_node_tls_options'; interface GetCustomAgentsResponse { httpAgent: HttpAgent | undefined; @@ -22,12 +23,14 @@ export function getCustomAgents( logger: Logger, url: string ): GetCustomAgentsResponse { + const generalTLSSettings = configurationUtilities.getTLSSettings(); + const agentTLSOptions = getNodeTLSOptions(logger, generalTLSSettings.verificationMode); // the default for rejectUnauthorized is the global setting, which can // be overridden (below) with a custom host setting const defaultAgents = { httpAgent: undefined, httpsAgent: new HttpsAgent({ - rejectUnauthorized: configurationUtilities.isRejectUnauthorizedCertificatesEnabled(), + ...agentTLSOptions, }), }; @@ -50,10 +53,18 @@ export function getCustomAgents( agentOptions.ca = tlsSettings.certificateAuthoritiesData; } + const tlsSettingsFromConfig = getTLSSettingsFromConfig( + tlsSettings.verificationMode, + tlsSettings.rejectUnauthorized + ); // see: src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts // This is where the global rejectUnauthorized is overridden by a custom host - if (tlsSettings.rejectUnauthorized !== undefined) { - agentOptions.rejectUnauthorized = tlsSettings.rejectUnauthorized; + const customHostNodeTLSOptions = getNodeTLSOptions( + logger, + tlsSettingsFromConfig.verificationMode + ); + if (customHostNodeTLSOptions.rejectUnauthorized !== undefined) { + agentOptions.rejectUnauthorized = customHostNodeTLSOptions.rejectUnauthorized; } } @@ -96,6 +107,10 @@ export function getCustomAgents( return defaultAgents; } + const proxyNodeTLSOptions = getNodeTLSOptions( + logger, + proxySettings.proxyTLSSettings.verificationMode + ); // At this point, we are going to use a proxy, so we need new agents. // We will though, copy over the calculated tls options from above, into // the https agent. @@ -106,7 +121,7 @@ export function getCustomAgents( protocol: proxyUrl.protocol, headers: proxySettings.proxyHeaders, // do not fail on invalid certs if value is false - rejectUnauthorized: proxySettings.proxyRejectUnauthorizedCertificates, + ...proxyNodeTLSOptions, }) as unknown) as HttpsAgent; // vsCode wasn't convinced HttpsProxyAgent is an https.Agent, so we convinced it diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.test.ts new file mode 100644 index 00000000000000..7d131985053f17 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.test.ts @@ -0,0 +1,70 @@ +/* + * 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 { getNodeTLSOptions, getTLSSettingsFromConfig } from './get_node_tls_options'; +import { Logger } from '../../../../../../src/core/server'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +describe('getNodeTLSOptions', () => { + test('get node.js TLS options: rejectUnauthorized eql true for the verification mode "full"', () => { + const nodeOption = getNodeTLSOptions(logger, 'full'); + expect(nodeOption).toMatchObject({ + rejectUnauthorized: true, + }); + }); + + test('get node.js TLS options: rejectUnauthorized eql true for the verification mode "certificate"', () => { + const nodeOption = getNodeTLSOptions(logger, 'certificate'); + expect(nodeOption.checkServerIdentity).not.toBeNull(); + expect(nodeOption.rejectUnauthorized).toBeTruthy(); + }); + + test('get node.js TLS options: rejectUnauthorized eql false for the verification mode "none"', () => { + const nodeOption = getNodeTLSOptions(logger, 'none'); + expect(nodeOption).toMatchObject({ + rejectUnauthorized: false, + }); + }); + + test('get node.js TLS options: rejectUnauthorized eql true for the verification mode value which does not exist, the logger called with the proper warning message', () => { + const nodeOption = getNodeTLSOptions(logger, 'notexist'); + expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` + Array [ + Array [ + "Unknown ssl verificationMode: notexist", + ], + ] + `); + expect(nodeOption).toMatchObject({ + rejectUnauthorized: true, + }); + }); +}); + +describe('getTLSSettingsFromConfig', () => { + test('get verificationMode eql "none" if legacy rejectUnauthorized eql false', () => { + const nodeOption = getTLSSettingsFromConfig(undefined, false); + expect(nodeOption).toMatchObject({ + verificationMode: 'none', + }); + }); + + test('get verificationMode eql "none" if legacy rejectUnauthorized eql true', () => { + const nodeOption = getTLSSettingsFromConfig(undefined, true); + expect(nodeOption).toMatchObject({ + verificationMode: 'full', + }); + }); + + test('get verificationMode eql "certificate", ignore rejectUnauthorized', () => { + const nodeOption = getTLSSettingsFromConfig('certificate', false); + expect(nodeOption).toMatchObject({ + verificationMode: 'certificate', + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.ts new file mode 100644 index 00000000000000..423e9756b13f8c --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_node_tls_options.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PeerCertificate } from 'tls'; +import { TLSSettings } from '../../types'; +import { Logger } from '../../../../../../src/core/server'; + +export function getNodeTLSOptions( + logger: Logger, + verificationMode?: string +): { + rejectUnauthorized?: boolean; + checkServerIdentity?: ((host: string, cert: PeerCertificate) => Error | undefined) | undefined; +} { + const agentOptions: { + rejectUnauthorized?: boolean; + checkServerIdentity?: ((host: string, cert: PeerCertificate) => Error | undefined) | undefined; + } = {}; + if (!!verificationMode) { + switch (verificationMode) { + case 'none': + agentOptions.rejectUnauthorized = false; + break; + case 'certificate': + agentOptions.rejectUnauthorized = true; + // by default, NodeJS is checking the server identify + agentOptions.checkServerIdentity = () => undefined; + break; + case 'full': + agentOptions.rejectUnauthorized = true; + break; + default: { + logger.warn(`Unknown ssl verificationMode: ${verificationMode}`); + agentOptions.rejectUnauthorized = true; + } + } + // see: src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts + // This is where the global rejectUnauthorized is overridden by a custom host + } + return agentOptions; +} + +export function getTLSSettingsFromConfig( + verificationMode?: 'none' | 'certificate' | 'full', + rejectUnauthorized?: boolean +): TLSSettings { + if (verificationMode) { + return { verificationMode }; + } else if (rejectUnauthorized !== undefined) { + return { verificationMode: rejectUnauthorized ? 'full' : 'none' }; + } + return {}; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index cceeefde71dc2a..9bdb2d94811424 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -76,7 +76,9 @@ describe('send_email module', () => { }, { proxyUrl: 'https://example.com', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: undefined, proxyOnlyHosts: undefined, } @@ -119,7 +121,7 @@ describe('send_email module', () => { `); }); - test('rejectUnauthorized default setting email using not secure host/port', async () => { + test('verificationMode default setting email using not secure host/port', async () => { const sendEmailOptions = getSendEmailOptions({ transport: { host: 'example.com', @@ -236,7 +238,9 @@ describe('send_email module', () => { }, { proxyUrl: 'https://proxy.com', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: new Set(['example.com']), proxyOnlyHosts: undefined, } @@ -268,7 +272,9 @@ describe('send_email module', () => { }, { proxyUrl: 'https://proxy.com', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: new Set(['not-example.com']), proxyOnlyHosts: undefined, } @@ -302,7 +308,9 @@ describe('send_email module', () => { }, { proxyUrl: 'https://proxy.com', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: undefined, proxyOnlyHosts: new Set(['example.com']), } @@ -336,7 +344,7 @@ describe('send_email module', () => { }, { proxyUrl: 'https://proxy.com', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: {}, proxyBypassHosts: undefined, proxyOnlyHosts: new Set(['not-example.com']), } @@ -453,7 +461,7 @@ describe('send_email module', () => { }, { proxyUrl: 'https://proxy.com', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: {}, proxyBypassHosts: undefined, proxyOnlyHosts: undefined, }, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts index 005e73b1fc2f7d..9f601840bc9824 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -12,6 +12,7 @@ import { default as MarkdownIt } from 'markdown-it'; import { Logger } from '../../../../../../src/core/server'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { CustomHostSettings } from '../../config'; +import { getNodeTLSOptions, getTLSSettingsFromConfig } from './get_node_tls_options'; // an email "service" which doesn't actually send, just returns what it would send export const JSON_TRANSPORT_SERVICE = '__json'; @@ -58,7 +59,7 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom // eslint-disable-next-line @typescript-eslint/no-explicit-any const transportConfig: Record = {}; const proxySettings = configurationUtilities.getProxySettings(); - const rejectUnauthorized = configurationUtilities.isRejectUnauthorizedCertificatesEnabled(); + const generalTLSSettings = configurationUtilities.getTLSSettings(); if (hasAuth && user != null && password != null) { transportConfig.auth = { @@ -91,10 +92,10 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom customHostSettings = configurationUtilities.getCustomHostSettings(`smtp://${host}:${port}`); if (proxySettings && useProxy) { - transportConfig.tls = { - // do not fail on invalid certs if value is false - rejectUnauthorized: proxySettings?.proxyRejectUnauthorizedCertificates, - }; + transportConfig.tls = getNodeTLSOptions( + logger, + proxySettings?.proxyTLSSettings.verificationMode + ); transportConfig.proxy = proxySettings.proxyUrl; transportConfig.headers = proxySettings.proxyHeaders; } else if (!transportConfig.secure && user == null && password == null) { @@ -103,7 +104,7 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom // authenticate rarely have valid certs; eg cloud proxy, and npm maildev transportConfig.tls = { rejectUnauthorized: false }; } else { - transportConfig.tls = { rejectUnauthorized }; + transportConfig.tls = getNodeTLSOptions(logger, generalTLSSettings.verificationMode); } // finally, allow customHostSettings to override some of the settings @@ -116,14 +117,16 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom if (tlsSettings?.certificateAuthoritiesData) { tlsConfig.ca = tlsSettings?.certificateAuthoritiesData; } - if (tlsSettings?.rejectUnauthorized !== undefined) { - tlsConfig.rejectUnauthorized = tlsSettings?.rejectUnauthorized; - } + const tlsSettingsFromConfig = getTLSSettingsFromConfig( + tlsSettings?.verificationMode, + tlsSettings?.rejectUnauthorized + ); + const nodeTLSOptions = getNodeTLSOptions(logger, tlsSettingsFromConfig.verificationMode); if (!transportConfig.tls) { - transportConfig.tls = tlsConfig; + transportConfig.tls = { ...tlsConfig, ...nodeTLSOptions }; } else { - transportConfig.tls = { ...transportConfig.tls, ...tlsConfig }; + transportConfig.tls = { ...transportConfig.tls, ...tlsConfig, ...nodeTLSOptions }; } if (smtpSettings?.ignoreTLS) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts index 76612696e8e583..4108424e26ac40 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts @@ -194,7 +194,9 @@ describe('execute()', () => { const configurationUtilities = actionsConfigMock.create(); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: undefined, proxyOnlyHosts: undefined, }); @@ -219,7 +221,9 @@ describe('execute()', () => { const configurationUtilities = actionsConfigMock.create(); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: new Set(['example.com']), proxyOnlyHosts: undefined, }); @@ -244,7 +248,9 @@ describe('execute()', () => { const configurationUtilities = actionsConfigMock.create(); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: new Set(['not-example.com']), proxyOnlyHosts: undefined, }); @@ -269,7 +275,9 @@ describe('execute()', () => { const configurationUtilities = actionsConfigMock.create(); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: undefined, proxyOnlyHosts: new Set(['example.com']), }); @@ -294,7 +302,9 @@ describe('execute()', () => { const configurationUtilities = actionsConfigMock.create(); configurationUtilities.getProxySettings.mockReturnValue({ proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, + proxyTLSSettings: { + verificationMode: 'none', + }, proxyBypassHosts: undefined, proxyOnlyHosts: new Set(['not-example.com']), }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts index 95088fa5f79656..bf34789e03fae1 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts @@ -170,9 +170,9 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], + "getTLSSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], - "isRejectUnauthorizedCertificatesEnabled": [MockFunction], "isUriAllowed": [MockFunction], }, "data": Object { @@ -234,9 +234,9 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], + "getTLSSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], - "isRejectUnauthorizedCertificatesEnabled": [MockFunction], "isUriAllowed": [MockFunction], }, "data": Object { diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index 00e56303dbe220..b2c865c2f5374c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -293,9 +293,9 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], + "getTLSSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], - "isRejectUnauthorizedCertificatesEnabled": [MockFunction], "isUriAllowed": [MockFunction], }, "data": "some data", @@ -386,9 +386,9 @@ describe('execute()', () => { "getCustomHostSettings": [MockFunction], "getProxySettings": [MockFunction], "getResponseSettings": [MockFunction], + "getTLSSettings": [MockFunction], "isActionTypeEnabled": [MockFunction], "isHostnameAllowed": [MockFunction], - "isRejectUnauthorizedCertificatesEnabled": [MockFunction], "isUriAllowed": [MockFunction], }, "data": "some data", diff --git a/x-pack/plugins/actions/server/config.test.ts b/x-pack/plugins/actions/server/config.test.ts index 4c4fd143369e11..9774bfb05d4ff4 100644 --- a/x-pack/plugins/actions/server/config.test.ts +++ b/x-pack/plugins/actions/server/config.test.ts @@ -177,6 +177,44 @@ describe('config validation', () => { `"[customHostSettings.0.url]: expected value of type [string] but got [undefined]"` ); }); + + test('action with tls configuration', () => { + const config: Record = { + tls: { + verificationMode: 'none', + proxyVerificationMode: 'none', + }, + }; + expect(configSchema.validate(config)).toMatchInlineSnapshot(` + Object { + "allowedHosts": Array [ + "*", + ], + "cleanupFailedExecutionsTask": Object { + "cleanupInterval": "PT5M", + "enabled": true, + "idleInterval": "PT1H", + "pageSize": 100, + }, + "enabled": true, + "enabledActionTypes": Array [ + "*", + ], + "maxResponseContentLength": ByteSizeValue { + "valueInBytes": 1048576, + }, + "preconfigured": Object {}, + "preconfiguredAlertHistoryEsIndex": false, + "proxyRejectUnauthorizedCertificates": true, + "rejectUnauthorized": true, + "responseTimeout": "PT1M", + "tls": Object { + "proxyVerificationMode": "none", + "verificationMode": "none", + }, + } + `); + }); }); // object creator that ensures we can create a property named __proto__ on an diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts index 0dc1aed68f4d0c..8859a2d8881a25 100644 --- a/x-pack/plugins/actions/server/config.ts +++ b/x-pack/plugins/actions/server/config.ts @@ -33,7 +33,16 @@ const customHostSettingsSchema = schema.object({ ), tls: schema.maybe( schema.object({ + /** + * @deprecated in favor of `verificationMode` + **/ rejectUnauthorized: schema.maybe(schema.boolean()), + verificationMode: schema.maybe( + schema.oneOf( + [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], + { defaultValue: 'full' } + ) + ), certificateAuthoritiesFiles: schema.maybe( schema.oneOf([ schema.string({ minLength: 1 }), @@ -68,10 +77,32 @@ export const configSchema = schema.object({ }), proxyUrl: schema.maybe(schema.string()), proxyHeaders: schema.maybe(schema.recordOf(schema.string(), schema.string())), + /** + * @deprecated in favor of `tls.proxyVerificationMode` + **/ proxyRejectUnauthorizedCertificates: schema.boolean({ defaultValue: true }), proxyBypassHosts: schema.maybe(schema.arrayOf(schema.string({ hostname: true }))), proxyOnlyHosts: schema.maybe(schema.arrayOf(schema.string({ hostname: true }))), + /** + * @deprecated in favor of `tls.verificationMode` + **/ rejectUnauthorized: schema.boolean({ defaultValue: true }), + tls: schema.maybe( + schema.object({ + verificationMode: schema.maybe( + schema.oneOf( + [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], + { defaultValue: 'full' } + ) + ), + proxyVerificationMode: schema.maybe( + schema.oneOf( + [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], + { defaultValue: 'full' } + ) + ), + }) + ), maxResponseContentLength: schema.byteSize({ defaultValue: '1mb' }), responseTimeout: schema.duration({ defaultValue: '60s' }), customHostSettings: schema.maybe(schema.arrayOf(customHostSettingsSchema)), diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index 99c6326d60e26d..6a0f06b34d670e 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -8,7 +8,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { PluginInitializerContext, PluginConfigDescriptor } from '../../../../src/core/server'; import { ActionsPlugin } from './plugin'; -import { configSchema, ActionsConfig } from './config'; +import { configSchema, ActionsConfig, CustomHostSettings } from './config'; import { ActionsClient as ActionsClientClass } from './actions_client'; import { ActionsAuthorization as ActionsAuthorizationClass } from './authorization/actions_authorization'; @@ -57,7 +57,37 @@ export const plugin = (initContext: PluginInitializerContext) => new ActionsPlug export const config: PluginConfigDescriptor = { schema: configSchema, - deprecations: ({ renameFromRoot }) => [ + deprecations: ({ renameFromRoot, unused }) => [ renameFromRoot('xpack.actions.whitelistedHosts', 'xpack.actions.allowedHosts'), + (settings, fromPath, addDeprecation) => { + const customHostSettings = settings?.xpack?.actions?.customHostSettings ?? []; + if ( + customHostSettings.find( + (customHostSchema: CustomHostSettings) => + !!customHostSchema.tls && !!customHostSchema.tls.rejectUnauthorized + ) + ) { + addDeprecation({ + message: + '`xpack.actions.customHostSettings[].tls.rejectUnauthorized` is deprecated. Use `xpack.actions.customHostSettings[].tls.verificationMode` instead, with the setting `verificationMode:full` eql to `rejectUnauthorized:true`, and `verificationMode:none` eql to `rejectUnauthorized:false`.', + }); + } + }, + (settings, fromPath, addDeprecation) => { + if (!!settings?.xpack?.actions?.rejectUnauthorized) { + addDeprecation({ + message: + '`xpack.actions.rejectUnauthorized` is deprecated. Use `xpack.actions.verificationMode` instead, with the setting `verificationMode:full` eql to `rejectUnauthorized:true`, and `verificationMode:none` eql to `rejectUnauthorized:false`.', + }); + } + }, + (settings, fromPath, addDeprecation) => { + if (!!settings?.xpack?.actions?.proxyRejectUnauthorizedCertificates) { + addDeprecation({ + message: + '`xpack.actions.proxyRejectUnauthorizedCertificates` is deprecated. Use `xpack.actions.proxyVerificationMode` instead, with the setting `proxyVerificationMode:full` eql to `rejectUnauthorized:true`, and `proxyVerificationMode:none` eql to `rejectUnauthorized:false`.', + }); + } + }, ], }; diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index ea22e90dfed405..c8c9967afca1a7 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -142,10 +142,14 @@ export interface ProxySettings { proxyBypassHosts: Set | undefined; proxyOnlyHosts: Set | undefined; proxyHeaders?: Record; - proxyRejectUnauthorizedCertificates: boolean; + proxyTLSSettings: TLSSettings; } export interface ResponseSettings { maxContentLength: number; timeout: number; } + +export interface TLSSettings { + verificationMode?: 'none' | 'certificate' | 'full'; +} diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index 0d5b0bf415fed9..cb43e534080905 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -326,9 +326,10 @@ server.newPlatform.setup.plugins.alerting.registerType(myRuleType); ## Role Based Access-Control Once you have registered your AlertType, you need to grant your users privileges to use it. -When registering a feature in Kibana, you can specify multiple types of privileges which are granted to users when they're assigned certain roles. -Assuming your feature introduces its own AlertTypes, you'll want to control which roles have all/read privileges for these AlertTypes when they're inside the feature. -In addition, when users are inside your feature, you might want to grant them access to AlertTypes from other features, such as built-in stack rules or rule types provided by other features. +When registering a feature in Kibana you can specify multiple types of privileges which are granted to users when they're assigned certain roles. + +Assuming your feature introduces its own AlertTypes, you'll want to control which roles have all/read privileges for the rules and alerts for these AlertTypes when they're inside the feature. +In addition, when users are inside your feature, you might want to grant them access to rules and alerts for AlertTypes from other features, such as built-in stack rules or rule types provided by other features. You can control all of these abilities by assigning privileges to the Alerting Framework from within your own feature, for example: @@ -337,30 +338,61 @@ features.registerKibanaFeature({ id: 'my-application-id', name: 'My Application', app: [], + alerting: [ + 'my-application-id.my-rule-type', + 'my-application-id.my-restricted-rule-type', + '.index-threshold', + 'xpack.uptime.alerts.actionGroups.tls' + ], privileges: { all: { alerting: { - all: [ - // grant `all` over our own types - 'my-application-id.my-rule-type', - 'my-application-id.my-restricted-rule-type', - // grant `all` over the built-in IndexThreshold - '.index-threshold', - // grant `all` over Uptime's TLS rule type - 'xpack.uptime.alerts.actionGroups.tls' - ], + rule: { + all: [ + // grant `all` over our own types + 'my-application-id.my-rule-type', + 'my-application-id.my-restricted-rule-type', + // grant `all` over the built-in IndexThreshold + '.index-threshold', + // grant `all` over Uptime's TLS rule type + 'xpack.uptime.alerts.actionGroups.tls' + ], + }, + alert: { + all: [ + // grant `all` over our own types + 'my-application-id.my-rule-type', + 'my-application-id.my-restricted-rule-type', + // grant `all` over the built-in IndexThreshold + '.index-threshold', + // grant `all` over Uptime's TLS rule type + 'xpack.uptime.alerts.actionGroups.tls' + ], + } }, }, read: { alerting: { - read: [ - // grant `read` over our own type - 'my-application-id.my-rule-type', - // grant `read` over the built-in IndexThreshold - '.index-threshold', - // grant `read` over Uptime's TLS rule type - 'xpack.uptime.alerts.actionGroups.tls' - ], + rule: { + read: [ + // grant `read` over our own type + 'my-application-id.my-alert-type', + // grant `read` over the built-in IndexThreshold + '.index-threshold', + // grant `read` over Uptime's TLS AlertType + 'xpack.uptime.alerts.actionGroups.tls' + ], + }, + alert: { + read: [ + // grant `read` over our own type + 'my-application-id.my-alert-type', + // grant `read` over the built-in IndexThreshold + '.index-threshold', + // grant `read` over Uptime's TLS AlertType + 'xpack.uptime.alerts.actionGroups.tls' + ], + }, }, }, }, @@ -369,17 +401,21 @@ features.registerKibanaFeature({ In this example we can see the following: -- Our feature grants any user who's assigned the `all` role in our feature the `all` role in the Alerting framework over every rule of the `my-application-id.my-rule-type` type which is created _inside_ the feature. What that means is that this privilege will allow the user to execute any of the `all` operations (listed below) on these rules as long as their `consumer` is `my-application-id`. Below that you'll notice we've done the same with the `read` role, which grants the Alerting Framework's `read` role privileges over these very same rules. -- In addition, our feature grants the same privileges over any rule of type `my-application-id.my-restricted-rule-type`, which is another hypothetical rule type registered by this feature. It's worth noting that this type has been omitted from the `read` role. What this means is that only users with the `all` role will be able to interact with rules of this type. -- Next, lets look at the `.index-threshold` and `xpack.uptime.alerts.actionGroups.tls` types. These have been specified in both `read` and `all`, which means that all the users in the feature will gain privileges over rules of these types (as long as their `consumer` is `my-application-id`). The difference between these two and the previous two is that they are _produced_ by other features! `.index-threshold` is a built-in stack rule type, provided by the _Stack Rules_ feature, and `xpack.uptime.alerts.actionGroups.tls` is a rule type provided by the _Uptime_ feature. Specifying these types here tells the Alerting Framework that as far as the `my-application-id` feature is concerned, the user is privileged to use them (with `all` and `read` applied), but that isn't enough. Using another feature's rule type is only possible if both the producer of the rule type and the consumer of the rule type explicitly grant privileges to do so. In this case, the _Stack Rules_ & _Uptime_ features would have to explicitly add these privileges to a role and this role would have to be granted to this user. +- Our feature grants any user who's assigned the `all` role in our feature the `all` role in the Alerting Framework over every rule and alert of the rule type `my-application-id.my-rule-type` type which is created _inside_ the feature. What that means is that this privilege will allow the user to execute any of the `all` operations (listed below) on these rules and alerts as long as their `consumer` is `my-application-id`. Below that you'll notice we've done the same with the `read` role, which is grants the Alerting Framework's `read` role privileges over these very same rules and alerts. +- In addition, our feature grants the same privileges over any rule or alert of rule type `my-application-id.my-restricted-rule-type`, which is another hypothetical rule type registered by this feature. It's worth noting that this type has been omitted from the `read` role. What this means is that only users with the `all` role will be able to interact with rules and alerts of this rule type. +- Next, let's look at the `.index-threshold` and `xpack.uptime.alerts.actionGroups.tls` types. These have been specified in both `read` and `all`, which means that all the users in the feature will gain privileges over rules and alerts of these rule types (as long as their `consumer` is `my-application-id`). The difference between these two and the previous two is that they are _produced_ by other features! `.index-threshold` is a built-in stack rule type, provided by the _Stack Rules_ feature, and `xpack.uptime.alerts.actionGroups.tls` is a rule type provided by the _Uptime_ feature. Specifying these types here tells the Alerting Framework that as far as the `my-application-id` feature is concerned, the user is privileged to use them (with `all` and `read` applied), but that isn't enough. Using another feature's rule type is only possible if both the producer of the rule type and the consumer of the rule type explicitly grant privileges to do so. In this case, the _Stack Rules_ & _Uptime_ features would have to explicitly add these privileges to a role and this role would have to be granted to this user. -It's important to note that any role can be granted a mix of `all` and `read` privileges accross multiple types, for example: +It's important to note that any role can be granted a mix of `all` and `read` privileges across multiple types, for example: ```typescript features.registerKibanaFeature({ id: 'my-application-id', name: 'My Application', app: [], + alerting: [ + 'my-application-id.my-rule-type', + 'my-application-id.my-restricted-rule-type' + ], privileges: { all: { app: ['my-application-id', 'kibana'], @@ -393,12 +429,22 @@ features.registerKibanaFeature({ read: { app: ['lens', 'kibana'], alerting: { - all: [ - 'my-application-id.my-rule-type' - ], - read: [ - 'my-application-id.my-restricted-rule-type' - ], + rule: { + all: [ + 'my-application-id.my-rule-type' + ], + read: [ + 'my-application-id.my-restricted-rule-type' + ], + }, + alert: { + all: [ + 'my-application-id.my-rule-type' + ], + read: [ + 'my-application-id.my-restricted-rule-type' + ], + }, }, savedObject: { all: [], @@ -414,6 +460,111 @@ features.registerKibanaFeature({ In the above example, note that instead of denying users with the `read` role any access to the `my-application-id.my-restricted-rule-type` type, we've decided that these users _should_ be granted `read` privileges over the _restricted_ rule type. As part of that same change, we also decided that not only should they be allowed to `read` the _restricted_ rule type, but actually, despite having `read` privileges to the feature as a whole, we do actually want to allow them to create our basic 'my-application-id.my-rule-type' rule type, as we consider it an extension of _reading_ data in our feature, rather than _writing_ it. +### Subfeature privileges + +In the above examples, we have been giving the same level of access to both rules and alerts for a particular rule type. There may be cases when you want your feature privilege to allow for escalated or de-escalated privileges for either rules or alerts within a feature. We can use subfeature privileges to achieve this granularity. + +For more information and other examples of subfeature privilege, refer to the [user documentation](https://www.elastic.co/guide/en/kibana/master/development-security.html#example-3-discover). + +```typescript +features.registerKibanaFeature({ + id: 'my-application-id', + name: 'My Application', + app: [], + alerting: [ + 'my-application-id.my-rule-type', + 'my-application-id.my-other-rule-type' + ], + privileges: { + all: { + app: ['my-application-id', 'kibana'], + savedObject: { + all: [], + read: [], + }, + alerting: { + rule: { + all: [ + 'my-application-id.my-rule-type', + 'my-application-id.my-other-rule-type' + ] + }, + alert: { + read: [ + 'my-application-id.my-rule-type', + 'my-application-id.my-other-rule-type' + ] + } + }, + ui: [], + api: [], + }, + read: { + app: ['lens', 'kibana'], + alerting: { + rule: { + read: [ + 'my-application-id.my-rule-type', + 'my-application-id.my-other-rule-type' + ] + }, + alert: { + read: [ + 'my-application-id.my-rule-type', + 'my-application-id.my-other-rule-type' + ] + } + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + api: [], + }, + }, + subFeatures: [ + { + name: 'Manage Alerts', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'alert_manage', + name: 'Manage Alerts', + includeIn: 'all', + alerting: { + alert: { + all: [ + 'my-application-id.my-rule-type', + 'my-application-id.my-other-rule-type' + ], + }, + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + } + ] + } + ] + } + ] +}); +``` + +In the above example, note that the base feature privilege grants users with the `all` role `all` access the rules of the specified rule types but only `read` access to the alerts of the same rule type. In order to get `all` access to the alerts of these rule types, the role must grant the `alert_manage` subfeature privilege. Because the `alert_manage` subfeature privilege has `includeIn` set to `all`, it is _automatically_ included in the `all` feature privilege but can be excluded when the role is defined. + +This subfeature privilege definition allows for the following granularity: + +- `all` privileges to rules for a rule type and `all` privileges to alerts for a rule type +- `all` privileges to rules for a rule type and subprivilege de-escalation to grant only `read` privileges to alerts for a rule type +- `read` privileges to rules for a rule type and `read` privileges to alerts for a rule type +- `read` privileges to rules for a rule type and subprivilege escalation to grant `all` privileges to alerts for a rule type. + ### `read` privileges vs. `all` privileges When a user is granted the `read` role in the Alerting Framework, they will be able to execute the following api calls: 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 1b5e712a3ee696..2227e0cecd0a68 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.test.ts @@ -71,7 +71,12 @@ function mockFeature(appName: string, typeName?: string) { ...(typeName ? { alerting: { - all: [typeName], + rule: { + all: [typeName], + }, + alert: { + all: [typeName], + }, }, } : {}), @@ -85,7 +90,12 @@ function mockFeature(appName: string, typeName?: string) { ...(typeName ? { alerting: { - read: [typeName], + rule: { + read: [typeName], + }, + alert: { + read: [typeName], + }, }, } : {}), @@ -138,7 +148,9 @@ function mockFeatureWithSubFeature(appName: string, typeName: string) { name: 'sub feature alert', includeIn: 'all', alerting: { - all: [typeName], + rule: { + all: [typeName], + }, }, savedObject: { all: [], @@ -151,7 +163,9 @@ function mockFeatureWithSubFeature(appName: string, typeName: string) { name: 'sub feature alert', includeIn: 'read', alerting: { - read: [typeName], + rule: { + read: [typeName], + }, }, savedObject: { all: [], @@ -269,7 +283,7 @@ describe('AlertingAuthorization', () => { expect(alertTypeRegistry.get).toHaveBeenCalledTimes(0); }); - test('ensures the user has privileges to execute the specified rule type, operation and alerting type without consumer when producer and consumer are the same', async () => { + test('ensures the user has privileges to execute rules for the specified rule type and operation without consumer when producer and consumer are the same', async () => { const { authorization } = mockSecurity(); const checkPrivileges: jest.MockedFunction< ReturnType @@ -325,7 +339,63 @@ describe('AlertingAuthorization', () => { `); }); - test('ensures the user has privileges to execute the specified rule type, operation and alerting type without consumer when consumer is exempt', async () => { + test('ensures the user has privileges to execute alerts for the specified rule type and operation without consumer when producer and consumer are the same', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction< + ReturnType + > = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + const alertAuthorization = new AlertingAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + exemptConsumerIds, + }); + + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: true, + privileges: { kibana: [] }, + }); + + await alertAuthorization.ensureAuthorized({ + ruleTypeId: 'myType', + consumer: 'myApp', + operation: WriteOperations.Update, + entity: AlertingAuthorizationEntity.Alert, + }); + + expect(alertTypeRegistry.get).toHaveBeenCalledWith('myType'); + + expect(authorization.actions.alerting.get).toHaveBeenCalledTimes(2); + expect(authorization.actions.alerting.get).toHaveBeenCalledWith( + 'myType', + 'myApp', + 'alert', + 'update' + ); + expect(checkPrivileges).toHaveBeenCalledWith({ + kibana: [mockAuthorizationAction('myType', 'myApp', 'alert', 'update')], + }); + + expect(auditLogger.logAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(auditLogger.logAuthorizationFailure).not.toHaveBeenCalled(); + expect(auditLogger.logAuthorizationSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "some-user", + "myType", + 0, + "myApp", + "update", + "alert", + ] + `); + }); + + test('ensures the user has privileges to execute rules for the specified rule type and operation without consumer when consumer is exempt', async () => { const { authorization } = mockSecurity(); const checkPrivileges: jest.MockedFunction< ReturnType @@ -387,7 +457,69 @@ describe('AlertingAuthorization', () => { `); }); - test('ensures the user has privileges to execute the specified rule type, operation, alerting type and producer when producer is different from consumer', async () => { + test('ensures the user has privileges to execute alerts for the specified rule type and operation without consumer when consumer is exempt', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction< + ReturnType + > = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + const alertAuthorization = new AlertingAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + exemptConsumerIds: ['exemptConsumer'], + }); + + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: true, + privileges: { kibana: [] }, + }); + + await alertAuthorization.ensureAuthorized({ + ruleTypeId: 'myType', + consumer: 'exemptConsumer', + operation: WriteOperations.Update, + entity: AlertingAuthorizationEntity.Alert, + }); + + expect(alertTypeRegistry.get).toHaveBeenCalledWith('myType'); + + expect(authorization.actions.alerting.get).toHaveBeenCalledTimes(2); + expect(authorization.actions.alerting.get).toHaveBeenCalledWith( + 'myType', + 'exemptConsumer', + 'alert', + 'update' + ); + expect(authorization.actions.alerting.get).toHaveBeenCalledWith( + 'myType', + 'myApp', + 'alert', + 'update' + ); + expect(checkPrivileges).toHaveBeenCalledWith({ + kibana: [mockAuthorizationAction('myType', 'myApp', 'alert', 'update')], + }); + + expect(auditLogger.logAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(auditLogger.logAuthorizationFailure).not.toHaveBeenCalled(); + expect(auditLogger.logAuthorizationSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "some-user", + "myType", + 0, + "exemptConsumer", + "update", + "alert", + ] + `); + }); + + test('ensures the user has privileges to execute rules for the specified rule type, operation and producer when producer is different from consumer', async () => { const { authorization } = mockSecurity(); const checkPrivileges: jest.MockedFunction< ReturnType @@ -452,7 +584,72 @@ describe('AlertingAuthorization', () => { `); }); - test('throws if user lacks the required privileges for the consumer', async () => { + test('ensures the user has privileges to execute alerts for the specified rule type, operation and producer when producer is different from consumer', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction< + ReturnType + > = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: true, + privileges: { kibana: [] }, + }); + + const alertAuthorization = new AlertingAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + exemptConsumerIds, + }); + + await alertAuthorization.ensureAuthorized({ + ruleTypeId: 'myType', + consumer: 'myOtherApp', + operation: WriteOperations.Update, + entity: AlertingAuthorizationEntity.Alert, + }); + + expect(alertTypeRegistry.get).toHaveBeenCalledWith('myType'); + + expect(authorization.actions.alerting.get).toHaveBeenCalledTimes(2); + expect(authorization.actions.alerting.get).toHaveBeenCalledWith( + 'myType', + 'myApp', + 'alert', + 'update' + ); + expect(authorization.actions.alerting.get).toHaveBeenCalledWith( + 'myType', + 'myOtherApp', + 'alert', + 'update' + ); + expect(checkPrivileges).toHaveBeenCalledWith({ + kibana: [ + mockAuthorizationAction('myType', 'myOtherApp', 'alert', 'update'), + mockAuthorizationAction('myType', 'myApp', 'alert', 'update'), + ], + }); + + expect(auditLogger.logAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(auditLogger.logAuthorizationFailure).not.toHaveBeenCalled(); + expect(auditLogger.logAuthorizationSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "some-user", + "myType", + 0, + "myOtherApp", + "update", + "alert", + ] + `); + }); + + test('throws if user lacks the required rule privileges for the consumer', async () => { const { authorization } = mockSecurity(); const checkPrivileges: jest.MockedFunction< ReturnType @@ -510,7 +707,7 @@ describe('AlertingAuthorization', () => { `); }); - test('throws if user lacks the required privieleges for the producer', async () => { + test('throws if user lacks the required alert privileges for the consumer', async () => { const { authorization } = mockSecurity(); const checkPrivileges: jest.MockedFunction< ReturnType @@ -532,11 +729,73 @@ describe('AlertingAuthorization', () => { privileges: { kibana: [ { - privilege: mockAuthorizationAction('myType', 'myOtherApp', 'alert', 'create'), + privilege: mockAuthorizationAction('myType', 'myOtherApp', 'alert', 'update'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myType', 'myApp', 'alert', 'update'), authorized: true, }, { - privilege: mockAuthorizationAction('myType', 'myApp', 'alert', 'create'), + privilege: mockAuthorizationAction('myType', 'myAppRulesOnly', 'alert', 'update'), + authorized: false, + }, + ], + }, + }); + + await expect( + alertAuthorization.ensureAuthorized({ + ruleTypeId: 'myType', + consumer: 'myAppRulesOnly', + operation: WriteOperations.Update, + entity: AlertingAuthorizationEntity.Alert, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unauthorized to update a \\"myType\\" alert for \\"myAppRulesOnly\\""` + ); + + expect(auditLogger.logAuthorizationSuccess).not.toHaveBeenCalled(); + expect(auditLogger.logAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(auditLogger.logAuthorizationFailure.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "some-user", + "myType", + 0, + "myAppRulesOnly", + "update", + "alert", + ] + `); + }); + + test('throws if user lacks the required privileges for the producer', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction< + ReturnType + > = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + const alertAuthorization = new AlertingAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + exemptConsumerIds, + }); + + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: false, + privileges: { + kibana: [ + { + privilege: mockAuthorizationAction('myType', 'myOtherApp', 'alert', 'update'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myType', 'myApp', 'alert', 'update'), authorized: false, }, ], @@ -547,11 +806,11 @@ describe('AlertingAuthorization', () => { alertAuthorization.ensureAuthorized({ ruleTypeId: 'myType', consumer: 'myOtherApp', - operation: WriteOperations.Create, + operation: WriteOperations.Update, entity: AlertingAuthorizationEntity.Alert, }) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Unauthorized to create a \\"myType\\" alert by \\"myApp\\""` + `"Unauthorized to update a \\"myType\\" alert by \\"myApp\\""` ); expect(auditLogger.logAuthorizationSuccess).not.toHaveBeenCalled(); @@ -562,13 +821,13 @@ describe('AlertingAuthorization', () => { "myType", 1, "myApp", - "create", + "update", "alert", ] `); }); - test('throws if user lacks the required privieleges for both consumer and producer', async () => { + test('throws if user lacks the required privileges for both consumer and producer', async () => { const { authorization } = mockSecurity(); const checkPrivileges: jest.MockedFunction< ReturnType @@ -662,7 +921,6 @@ describe('AlertingAuthorization', () => { enabledInLicense: true, }; const setOfAlertTypes = new Set([myAppAlertType, myOtherAppAlertType, mySecondAppAlertType]); - test('omits filter when there is no authorization api', async () => { const alertAuthorization = new AlertingAuthorization({ request, @@ -672,7 +930,6 @@ describe('AlertingAuthorization', () => { getSpace, exemptConsumerIds, }); - const { filter, ensureRuleTypeIsAuthorized, @@ -683,13 +940,10 @@ describe('AlertingAuthorization', () => { consumer: 'consumer', }, }); - expect(() => ensureRuleTypeIsAuthorized('someMadeUpType', 'myApp', 'rule')).not.toThrow(); - expect(filter).toEqual(undefined); }); - - test('ensureAlertTypeIsAuthorized is no-op when there is no authorization api', async () => { + test('ensureRuleTypeIsAuthorized is no-op when there is no authorization api', async () => { const alertAuthorization = new AlertingAuthorization({ request, alertTypeRegistry, @@ -698,7 +952,6 @@ describe('AlertingAuthorization', () => { getSpace, exemptConsumerIds, }); - const { ensureRuleTypeIsAuthorized } = await alertAuthorization.getFindAuthorizationFilter( AlertingAuthorizationEntity.Rule, { @@ -709,13 +962,10 @@ describe('AlertingAuthorization', () => { }, } ); - ensureRuleTypeIsAuthorized('someMadeUpType', 'myApp', 'rule'); - expect(auditLogger.logAuthorizationSuccess).not.toHaveBeenCalled(); expect(auditLogger.logAuthorizationFailure).not.toHaveBeenCalled(); }); - test('creates a filter based on the privileged types', async () => { const { authorization } = mockSecurity(); const checkPrivileges: jest.MockedFunction< @@ -727,7 +977,6 @@ describe('AlertingAuthorization', () => { hasAllRequested: true, privileges: { kibana: [] }, }); - const alertAuthorization = new AlertingAuthorization({ request, authorization, @@ -738,7 +987,6 @@ describe('AlertingAuthorization', () => { exemptConsumerIds, }); alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); - expect( ( await alertAuthorization.getFindAuthorizationFilter(AlertingAuthorizationEntity.Rule, { @@ -754,11 +1002,9 @@ describe('AlertingAuthorization', () => { `((path.to.rule.id:myAppAlertType and consumer-field:(myApp or myOtherApp or myAppWithSubFeature)) or (path.to.rule.id:myOtherAppAlertType and consumer-field:(myApp or myOtherApp or myAppWithSubFeature)) or (path.to.rule.id:mySecondAppAlertType and consumer-field:(myApp or myOtherApp or myAppWithSubFeature)))` ) ); - expect(auditLogger.logAuthorizationSuccess).not.toHaveBeenCalled(); }); - - test('creates an `ensureAlertTypeIsAuthorized` function which throws if type is unauthorized', async () => { + test('creates an `ensureRuleTypeIsAuthorized` function which throws if type is unauthorized', async () => { const { authorization } = mockSecurity(); const checkPrivileges: jest.MockedFunction< ReturnType @@ -793,7 +1039,6 @@ describe('AlertingAuthorization', () => { ], }, }); - const alertAuthorization = new AlertingAuthorization({ request, authorization, @@ -804,7 +1049,6 @@ describe('AlertingAuthorization', () => { exemptConsumerIds, }); alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); - const { ensureRuleTypeIsAuthorized } = await alertAuthorization.getFindAuthorizationFilter( AlertingAuthorizationEntity.Alert, { @@ -820,22 +1064,20 @@ describe('AlertingAuthorization', () => { }).toThrowErrorMatchingInlineSnapshot( `"Unauthorized to find a \\"myAppAlertType\\" alert for \\"myOtherApp\\""` ); - expect(auditLogger.logAuthorizationSuccess).not.toHaveBeenCalled(); expect(auditLogger.logAuthorizationFailure).toHaveBeenCalledTimes(1); expect(auditLogger.logAuthorizationFailure.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "some-user", - "myAppAlertType", - 0, - "myOtherApp", - "find", - "alert", - ] - `); + Array [ + "some-user", + "myAppAlertType", + 0, + "myOtherApp", + "find", + "alert", + ] + `); }); - - test('creates an `ensureAlertTypeIsAuthorized` function which is no-op if type is authorized', async () => { + test('creates an `ensureRuleTypeIsAuthorized` function which is no-op if type is authorized', async () => { const { authorization } = mockSecurity(); const checkPrivileges: jest.MockedFunction< ReturnType @@ -870,7 +1112,6 @@ describe('AlertingAuthorization', () => { ], }, }); - const alertAuthorization = new AlertingAuthorization({ request, authorization, @@ -881,7 +1122,6 @@ describe('AlertingAuthorization', () => { exemptConsumerIds, }); alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); - const { ensureRuleTypeIsAuthorized } = await alertAuthorization.getFindAuthorizationFilter( AlertingAuthorizationEntity.Rule, { @@ -895,11 +1135,9 @@ describe('AlertingAuthorization', () => { expect(() => { ensureRuleTypeIsAuthorized('myAppAlertType', 'myOtherApp', 'rule'); }).not.toThrow(); - expect(auditLogger.logAuthorizationSuccess).not.toHaveBeenCalled(); expect(auditLogger.logAuthorizationFailure).not.toHaveBeenCalled(); }); - test('creates an `logSuccessfulAuthorization` function which logs every authorized type', async () => { const { authorization } = mockSecurity(); const checkPrivileges: jest.MockedFunction< @@ -948,7 +1186,6 @@ describe('AlertingAuthorization', () => { ], }, }); - const alertAuthorization = new AlertingAuthorization({ request, authorization, @@ -959,7 +1196,6 @@ describe('AlertingAuthorization', () => { exemptConsumerIds, }); alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); - const { ensureRuleTypeIsAuthorized, logSuccessfulAuthorization, @@ -975,35 +1211,32 @@ describe('AlertingAuthorization', () => { ensureRuleTypeIsAuthorized('mySecondAppAlertType', 'myOtherApp', 'rule'); ensureRuleTypeIsAuthorized('myAppAlertType', 'myOtherApp', 'rule'); }).not.toThrow(); - expect(auditLogger.logAuthorizationSuccess).not.toHaveBeenCalled(); expect(auditLogger.logAuthorizationFailure).not.toHaveBeenCalled(); - logSuccessfulAuthorization(); - expect(auditLogger.logBulkAuthorizationSuccess).toHaveBeenCalledTimes(1); expect(auditLogger.logBulkAuthorizationSuccess.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "some-user", Array [ + "some-user", Array [ - "myAppAlertType", - "myOtherApp", - ], - Array [ - "mySecondAppAlertType", - "myOtherApp", + Array [ + "myAppAlertType", + "myOtherApp", + ], + Array [ + "mySecondAppAlertType", + "myOtherApp", + ], ], - ], - 0, - "find", - "rule", - ] - `); + 0, + "find", + "rule", + ] + `); }); }); - describe('filterByAlertTypeAuthorization', () => { + describe('filterByRuleTypeAuthorization', () => { const myOtherAppAlertType: RegistryAlertType = { actionGroups: [], actionVariables: undefined, diff --git a/x-pack/plugins/apm/server/feature.ts b/x-pack/plugins/apm/server/feature.ts index a340a940f4a3b6..fb0610dffb92e4 100644 --- a/x-pack/plugins/apm/server/feature.ts +++ b/x-pack/plugins/apm/server/feature.ts @@ -38,7 +38,12 @@ export const APM_FEATURE = { read: [], }, alerting: { - all: Object.values(AlertType), + rule: { + all: Object.values(AlertType), + }, + alert: { + all: Object.values(AlertType), + }, }, management: { insightsAndAlerting: ['triggersActions'], @@ -54,7 +59,12 @@ export const APM_FEATURE = { read: [], }, alerting: { - read: Object.values(AlertType), + rule: { + read: Object.values(AlertType), + }, + alert: { + read: Object.values(AlertType), + }, }, management: { insightsAndAlerting: ['triggersActions'], diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.test.tsx new file mode 100644 index 00000000000000..21fc2b235d83cf --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.test.tsx @@ -0,0 +1,40 @@ +/* + * 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 { setMockValues } from '../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButtonEmpty } from '@elastic/eui'; + +import { KibanaHeaderActions } from './kibana_header_actions'; + +describe('KibanaHeaderActions', () => { + const values = { + engineName: 'foo', + }; + + beforeEach(() => { + jest.clearAllMocks(); + setMockValues(values); + }); + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiButtonEmpty).exists()).toBe(true); + }); + + it('does not render a "Query Tester" button if there is no engine available', () => { + setMockValues({ + engineName: '', + }); + const wrapper = shallow(); + expect(wrapper.find(EuiButtonEmpty).exists()).toBe(false); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.tsx new file mode 100644 index 00000000000000..b2e810962df029 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/layout/kibana_header_actions.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues } from 'kea'; + +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { EngineLogic } from '../engine'; + +export const KibanaHeaderActions: React.FC = () => { + const { engineName } = useValues(EngineLogic); + + return ( + + {engineName && ( + + + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.queryTesterButtonLabel', { + defaultMessage: 'Query tester', + })} + + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 2a7f256398381b..287d46c2dec75c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -7,6 +7,7 @@ import { DEFAULT_INITIAL_APP_DATA } from '../../../common/__mocks__'; import { setMockValues, rerender } from '../__mocks__'; +import '../__mocks__/shallow_useeffect.mock'; import '../__mocks__/enterprise_search_url.mock'; import '../__mocks__/react_router_history.mock'; @@ -70,9 +71,10 @@ describe('AppSearchUnconfigured', () => { describe('AppSearchConfigured', () => { let wrapper: ShallowWrapper; + const renderHeaderActions = jest.fn(); beforeAll(() => { - setMockValues({ myRole: {} }); + setMockValues({ myRole: {}, renderHeaderActions }); wrapper = shallow(); }); @@ -83,6 +85,10 @@ describe('AppSearchConfigured', () => { expect(wrapper.find(EngineRouter)).toHaveLength(1); }); + it('renders header actions', () => { + expect(renderHeaderActions).toHaveBeenCalled(); + }); + it('mounts AppLogic with passed initial data props', () => { expect(AppLogic).toHaveBeenCalledWith(DEFAULT_INITIAL_APP_DATA); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index 0b87321d875354..9b59e0e19a5da1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { Route, Redirect, Switch, useRouteMatch } from 'react-router-dom'; import { useValues } from 'kea'; @@ -25,6 +25,7 @@ import { EngineNav, EngineRouter } from './components/engine'; import { EngineCreation } from './components/engine_creation'; import { EnginesOverview, ENGINES_TITLE } from './components/engines'; import { ErrorConnecting } from './components/error_connecting'; +import { KibanaHeaderActions } from './components/layout/kibana_header_actions'; import { Library } from './components/library'; import { MetaEngineCreation } from './components/meta_engine_creation'; import { RoleMappingsRouter } from './components/role_mappings'; @@ -77,8 +78,13 @@ export const AppSearchConfigured: React.FC> = (props) = const { myRole: { canManageEngines, canManageMetaEngines, canViewRoleMappings }, } = useValues(AppLogic(props)); + const { renderHeaderActions } = useValues(KibanaLogic); const { readOnlyMode } = useValues(HttpLogic); + useEffect(() => { + renderHeaderActions(KibanaHeaderActions); + }, []); + return ( {process.env.NODE_ENV === 'development' && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx index 0875e8cf0ec089..1dddf54faa7aff 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/kibana_header_actions.tsx @@ -7,7 +7,7 @@ import React from 'react'; -import { EuiButtonEmpty, EuiText, EuiFlexGroup, EuiFlexItem, EuiHeaderLinks } from '@elastic/eui'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiHeaderLinks } from '@elastic/eui'; import { externalUrl, getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; import { EuiButtonEmptyTo } from '../../../shared/react_router_helpers'; @@ -25,8 +25,9 @@ export const WorkplaceSearchHeaderActions: React.FC = () => { data-test-subj="PersonalDashboardButton" iconType="user" to={PERSONAL_SOURCES_PATH} + size="s" > - {NAV.PERSONAL_DASHBOARD} + {NAV.PERSONAL_DASHBOARD} @@ -35,8 +36,9 @@ export const WorkplaceSearchHeaderActions: React.FC = () => { href={getWorkplaceSearchUrl('/search')} target="_blank" iconType="search" + size="s" > - {NAV.SEARCH} + {NAV.SEARCH} diff --git a/x-pack/plugins/features/common/feature_kibana_privileges.ts b/x-pack/plugins/features/common/feature_kibana_privileges.ts index 7febba197647d0..166ce5b62a0670 100644 --- a/x-pack/plugins/features/common/feature_kibana_privileges.ts +++ b/x-pack/plugins/features/common/feature_kibana_privileges.ts @@ -82,27 +82,50 @@ export interface FeatureKibanaPrivileges { * Alert Types and Alert Types provided by other features to which you wish to grant access. */ alerting?: { - /** - * List of alert types which users should have full read/write access to when granted this privilege. - * @example - * ```ts - * { - * all: ['my-alert-type-within-my-feature'] - * } - * ``` - */ - all?: readonly string[]; - - /** - * List of alert types which users should have read-only access to when granted this privilege. - * @example - * ```ts - * { - * read: ['my-alert-type'] - * } - * ``` - */ - read?: readonly string[]; + rule?: { + /** + * List of rule types which users should have full read/write access to when granted this privilege. + * @example + * ```ts + * { + * all: ['my-alert-type-within-my-feature'] + * } + * ``` + */ + all?: readonly string[]; + /** + * List of rule types which users should have read-only access to when granted this privilege. + * @example + * ```ts + * { + * read: ['my-alert-type'] + * } + * ``` + */ + read?: readonly string[]; + }; + alert?: { + /** + * List of rule types for which users should have full read/write access their alert data to when granted this privilege. + * @example + * ```ts + * { + * all: ['my-alert-type-within-my-feature'] + * } + * ``` + */ + all?: readonly string[]; + /** + * List of rule types for which users should have read-only access to their alert data when granted this privilege. + * @example + * ```ts + * { + * read: ['my-alert-type'] + * } + * ``` + */ + read?: readonly string[]; + }; }; /** * If your feature requires access to specific saved objects, then specify your access needs here. diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index 88712f2ac14c03..64be725e02bbe5 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -512,8 +512,14 @@ Array [ Object { "privilege": Object { "alerting": Object { - "all": Array [], - "read": Array [], + "alert": Object { + "all": Array [], + "read": Array [], + }, + "rule": Object { + "all": Array [], + "read": Array [], + }, }, "api": Array [ "store_search_session", @@ -651,8 +657,14 @@ Array [ Object { "privilege": Object { "alerting": Object { - "all": Array [], - "read": Array [], + "alert": Object { + "all": Array [], + "read": Array [], + }, + "rule": Object { + "all": Array [], + "read": Array [], + }, }, "api": Array [ "store_search_session", @@ -888,8 +900,14 @@ Array [ Object { "privilege": Object { "alerting": Object { - "all": Array [], - "read": Array [], + "alert": Object { + "all": Array [], + "read": Array [], + }, + "rule": Object { + "all": Array [], + "read": Array [], + }, }, "api": Array [], "app": Array [ @@ -1010,8 +1028,14 @@ Array [ Object { "privilege": Object { "alerting": Object { - "all": Array [], - "read": Array [], + "alert": Object { + "all": Array [], + "read": Array [], + }, + "rule": Object { + "all": Array [], + "read": Array [], + }, }, "api": Array [ "store_search_session", @@ -1149,8 +1173,14 @@ Array [ Object { "privilege": Object { "alerting": Object { - "all": Array [], - "read": Array [], + "alert": Object { + "all": Array [], + "read": Array [], + }, + "rule": Object { + "all": Array [], + "read": Array [], + }, }, "api": Array [ "store_search_session", @@ -1386,8 +1416,14 @@ Array [ Object { "privilege": Object { "alerting": Object { - "all": Array [], - "read": Array [], + "alert": Object { + "all": Array [], + "read": Array [], + }, + "rule": Object { + "all": Array [], + "read": Array [], + }, }, "api": Array [], "app": Array [ diff --git a/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.test.ts b/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.test.ts index 6acc29793797fe..75e6eaa4020917 100644 --- a/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.test.ts +++ b/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.test.ts @@ -46,8 +46,14 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - all: ['alerting-all-type'], - read: ['alerting-read-type'], + rule: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, + alert: { + all: ['alerting-all-type'], + read: [], + }, }, ui: ['ui-action'], }, @@ -63,7 +69,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - read: ['alerting-read-type'], + rule: { + read: ['alerting-read-type'], + }, + alert: { + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -93,8 +104,14 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - all: ['alerting-all-type'], - read: ['alerting-read-type'], + rule: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, + alert: { + all: ['alerting-all-type'], + read: [], + }, }, ui: ['ui-action'], }, @@ -113,7 +130,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - read: ['alerting-read-type'], + rule: { + read: ['alerting-read-type'], + }, + alert: { + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -139,8 +161,14 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - all: ['alerting-all-type'], - read: ['alerting-read-type'], + rule: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, + alert: { + all: ['alerting-all-type'], + read: ['alerting-read-type-alerts'], + }, }, ui: ['ui-action'], }, @@ -156,7 +184,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - read: ['alerting-read-type'], + rule: { + read: ['alerting-read-type'], + }, + alert: { + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -187,8 +220,14 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - all: ['alerting-all-type'], - read: ['alerting-read-type'], + rule: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, + alert: { + all: ['alerting-all-type'], + read: ['alerting-read-type-alerts'], + }, }, ui: ['ui-action'], }, @@ -212,11 +251,15 @@ describe('featurePrivilegeIterator', () => { }, savedObject: { all: ['all-type'], - read: ['read-type'], + read: [], }, alerting: { - all: ['alerting-all-type'], - read: ['alerting-read-type'], + rule: { + all: ['alerting-all-type'], + }, + alert: { + read: ['alerting-another-read-type'], + }, }, ui: ['ui-action'], }, @@ -232,7 +275,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - read: ['alerting-read-type'], + rule: { + read: ['alerting-read-type'], + }, + alert: { + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -259,8 +307,9 @@ describe('featurePrivilegeIterator', () => { read: ['read-sub-type'], }, alerting: { - all: ['alerting-all-sub-type'], - read: ['alerting-read-sub-type'], + alert: { + all: ['alerting-all-sub-type'], + }, }, ui: ['ui-sub-type'], }, @@ -290,11 +339,15 @@ describe('featurePrivilegeIterator', () => { }, savedObject: { all: ['all-type'], - read: ['read-type'], + read: [], }, alerting: { - all: ['alerting-all-type'], - read: ['alerting-read-type'], + rule: { + all: ['alerting-all-type'], + }, + alert: { + read: ['alerting-another-read-type'], + }, }, ui: ['ui-action'], }, @@ -313,7 +366,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - read: ['alerting-read-type'], + rule: { + read: ['alerting-read-type'], + }, + alert: { + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -340,8 +398,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - all: ['alerting-all-type'], - read: ['alerting-read-type'], + rule: { + all: ['alerting-all-type'], + }, + alert: { + read: ['alerting-another-read-type'], + }, }, ui: ['ui-action'], }, @@ -357,7 +419,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - read: ['alerting-read-type'], + rule: { + read: ['alerting-read-type'], + }, + alert: { + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -384,8 +451,9 @@ describe('featurePrivilegeIterator', () => { read: ['read-sub-type'], }, alerting: { - all: ['alerting-all-sub-type'], - read: ['alerting-read-sub-type'], + alert: { + all: ['alerting-all-sub-type'], + }, }, ui: ['ui-sub-type'], }, @@ -418,8 +486,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - all: ['alerting-all-type'], - read: ['alerting-read-type'], + rule: { + all: ['alerting-all-type'], + }, + alert: { + read: ['alerting-another-read-type'], + }, }, ui: ['ui-action'], }, @@ -438,7 +510,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - read: ['alerting-read-type'], + rule: { + read: ['alerting-read-type'], + }, + alert: { + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -465,8 +542,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - all: ['alerting-all-type'], - read: ['alerting-read-type'], + rule: { + all: ['alerting-all-type'], + }, + alert: { + read: ['alerting-another-read-type'], + }, }, ui: ['ui-action'], }, @@ -482,7 +563,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - read: ['alerting-read-type'], + rule: { + read: ['alerting-read-type'], + }, + alert: { + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -510,8 +596,9 @@ describe('featurePrivilegeIterator', () => { read: ['read-sub-type'], }, alerting: { - all: ['alerting-all-sub-type'], - read: ['alerting-read-sub-type'], + alert: { + all: ['alerting-all-sub-type'], + }, }, ui: ['ui-sub-type'], }, @@ -545,8 +632,14 @@ describe('featurePrivilegeIterator', () => { read: ['read-type', 'read-sub-type'], }, alerting: { - all: ['alerting-all-type', 'alerting-all-sub-type'], - read: ['alerting-read-type', 'alerting-read-sub-type'], + rule: { + all: ['alerting-all-type'], + read: [], + }, + alert: { + all: ['alerting-all-sub-type'], + read: ['alerting-another-read-type'], + }, }, ui: ['ui-action', 'ui-sub-type'], }, @@ -566,8 +659,14 @@ describe('featurePrivilegeIterator', () => { read: ['read-type', 'read-sub-type'], }, alerting: { - all: ['alerting-all-sub-type'], - read: ['alerting-read-type', 'alerting-read-sub-type'], + rule: { + all: [], + read: ['alerting-read-type'], + }, + alert: { + all: ['alerting-all-sub-type'], + read: ['alerting-read-type'], + }, }, ui: ['ui-action', 'ui-sub-type'], }, @@ -594,8 +693,14 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - all: ['alerting-all-type'], - read: ['alerting-read-type'], + rule: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, + alert: { + all: [], + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -611,7 +716,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - read: ['alerting-read-type'], + rule: { + read: ['alerting-read-type'], + }, + alert: { + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -638,7 +748,9 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - read: ['alerting-read-type'], + alert: { + all: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -671,8 +783,14 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - all: ['alerting-all-type'], - read: ['alerting-read-type'], + rule: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, + alert: { + all: ['alerting-read-type'], + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -691,8 +809,14 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - all: [], - read: ['alerting-read-type'], + rule: { + all: [], + read: ['alerting-read-type'], + }, + alert: { + all: ['alerting-read-type'], + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -719,8 +843,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - all: ['alerting-all-type'], - read: ['alerting-read-type'], + rule: { + all: ['alerting-all-type'], + }, + alert: { + read: ['alerting-another-read-type'], + }, }, ui: ['ui-action'], }, @@ -736,7 +864,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - read: ['alerting-read-type'], + rule: { + read: ['alerting-read-type'], + }, + alert: { + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -764,8 +897,9 @@ describe('featurePrivilegeIterator', () => { read: ['read-sub-type'], }, alerting: { - all: ['alerting-all-sub-type'], - read: ['alerting-read-sub-type'], + alert: { + all: ['alerting-all-sub-type'], + }, }, ui: ['ui-sub-type'], }, @@ -799,8 +933,14 @@ describe('featurePrivilegeIterator', () => { read: ['read-type', 'read-sub-type'], }, alerting: { - all: ['alerting-all-type', 'alerting-all-sub-type'], - read: ['alerting-read-type', 'alerting-read-sub-type'], + rule: { + all: ['alerting-all-type'], + read: [], + }, + alert: { + all: ['alerting-all-sub-type'], + read: ['alerting-another-read-type'], + }, }, ui: ['ui-action', 'ui-sub-type'], }, @@ -819,7 +959,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - read: ['alerting-read-type'], + rule: { + read: ['alerting-read-type'], + }, + alert: { + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -846,8 +991,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - all: ['alerting-all-type'], - read: ['alerting-read-type'], + rule: { + all: ['alerting-all-type'], + }, + alert: { + read: ['alerting-another-read-type'], + }, }, ui: ['ui-action'], }, @@ -863,7 +1012,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - read: ['alerting-read-type'], + rule: { + read: ['alerting-read-type'], + }, + alert: { + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -892,8 +1046,9 @@ describe('featurePrivilegeIterator', () => { read: ['read-sub-type'], }, alerting: { - all: ['alerting-all-sub-type'], - read: ['alerting-read-sub-type'], + alert: { + all: ['alerting-all-sub-type'], + }, }, ui: ['ui-sub-type'], }, @@ -926,8 +1081,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - all: ['alerting-all-type'], - read: ['alerting-read-type'], + rule: { + all: ['alerting-all-type'], + }, + alert: { + read: ['alerting-another-read-type'], + }, }, ui: ['ui-action'], }, @@ -946,7 +1105,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - read: ['alerting-read-type'], + rule: { + read: ['alerting-read-type'], + }, + alert: { + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -999,8 +1163,10 @@ describe('featurePrivilegeIterator', () => { read: ['read-sub-type'], }, alerting: { - all: ['alerting-all-sub-type'], - read: ['alerting-read-sub-type'], + rule: { + all: ['alerting-all-sub-type'], + read: ['alerting-read-sub-type'], + }, }, ui: ['ui-sub-type'], }, @@ -1034,8 +1200,14 @@ describe('featurePrivilegeIterator', () => { read: ['read-sub-type'], }, alerting: { - all: ['alerting-all-sub-type'], - read: ['alerting-read-sub-type'], + rule: { + all: ['alerting-all-sub-type'], + read: ['alerting-read-sub-type'], + }, + alert: { + all: [], + read: [], + }, }, ui: ['ui-sub-type'], }, @@ -1055,8 +1227,14 @@ describe('featurePrivilegeIterator', () => { read: ['read-sub-type'], }, alerting: { - all: ['alerting-all-sub-type'], - read: ['alerting-read-sub-type'], + rule: { + all: ['alerting-all-sub-type'], + read: ['alerting-read-sub-type'], + }, + alert: { + all: [], + read: [], + }, }, ui: ['ui-sub-type'], }, @@ -1083,8 +1261,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - all: ['alerting-all-type'], - read: ['alerting-read-type'], + rule: { + all: ['alerting-all-type'], + }, + alert: { + read: ['alerting-another-read-type'], + }, }, ui: ['ui-action'], }, @@ -1100,7 +1282,12 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - read: ['alerting-read-type'], + rule: { + read: ['alerting-read-type'], + }, + alert: { + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, @@ -1151,8 +1338,14 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - all: ['alerting-all-type'], - read: ['alerting-read-type'], + rule: { + all: ['alerting-all-type'], + read: [], + }, + alert: { + all: [], + read: ['alerting-another-read-type'], + }, }, ui: ['ui-action'], }, @@ -1171,8 +1364,14 @@ describe('featurePrivilegeIterator', () => { read: ['read-type'], }, alerting: { - all: [], - read: ['alerting-read-type'], + rule: { + all: [], + read: ['alerting-read-type'], + }, + alert: { + all: [], + read: ['alerting-read-type'], + }, }, ui: ['ui-action'], }, diff --git a/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.ts b/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.ts index e194a051c8a6e5..b58f72b0fadc06 100644 --- a/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.ts +++ b/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.ts @@ -110,11 +110,26 @@ function mergeWithSubFeatures( ); mergedConfig.alerting = { - all: mergeArrays(mergedConfig.alerting?.all ?? [], subFeaturePrivilege.alerting?.all ?? []), - read: mergeArrays( - mergedConfig.alerting?.read ?? [], - subFeaturePrivilege.alerting?.read ?? [] - ), + rule: { + all: mergeArrays( + mergedConfig.alerting?.rule?.all ?? [], + subFeaturePrivilege.alerting?.rule?.all ?? [] + ), + read: mergeArrays( + mergedConfig.alerting?.rule?.read ?? [], + subFeaturePrivilege.alerting?.rule?.read ?? [] + ), + }, + alert: { + all: mergeArrays( + mergedConfig.alerting?.alert?.all ?? [], + subFeaturePrivilege.alerting?.alert?.all ?? [] + ), + read: mergeArrays( + mergedConfig.alerting?.alert?.read ?? [], + subFeaturePrivilege.alerting?.alert?.read ?? [] + ), + }, }; } return mergedConfig; diff --git a/x-pack/plugins/features/server/feature_registry.test.ts b/x-pack/plugins/features/server/feature_registry.test.ts index 0eb00b43d6f5d4..8e7ed45f33f50f 100644 --- a/x-pack/plugins/features/server/feature_registry.test.ts +++ b/x-pack/plugins/features/server/feature_registry.test.ts @@ -828,7 +828,7 @@ describe('FeatureRegistry', () => { ); }); - it(`prevents privileges from specifying alerting entries that don't exist at the root level`, () => { + it(`prevents privileges from specifying alerting/rule entries that don't exist at the root level`, () => { const feature: KibanaFeatureConfig = { id: 'test-feature', name: 'Test Feature', @@ -838,8 +838,57 @@ describe('FeatureRegistry', () => { privileges: { all: { alerting: { - all: ['foo', 'bar'], - read: ['baz'], + rule: { + all: ['foo', 'bar'], + read: ['baz'], + }, + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + read: { + alerting: { + rule: { + read: ['foo', 'bar', 'baz'], + }, + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature privilege test-feature.all has unknown alerting entries: foo, baz"` + ); + }); + + it(`prevents privileges from specifying alerting/alert entries that don't exist at the root level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + category: { id: 'foo', label: 'foo' }, + alerting: ['bar'], + privileges: { + all: { + alerting: { + alert: { + all: ['foo', 'bar'], + read: ['baz'], + }, }, savedObject: { all: [], @@ -849,7 +898,11 @@ describe('FeatureRegistry', () => { app: [], }, read: { - alerting: { read: ['foo', 'bar', 'baz'] }, + alerting: { + alert: { + read: ['foo', 'bar', 'baz'], + }, + }, savedObject: { all: [], read: [], @@ -869,7 +922,80 @@ describe('FeatureRegistry', () => { ); }); - it(`prevents features from specifying alerting entries that don't exist at the privilege level`, () => { + it(`prevents features from specifying alerting/rule entries that don't exist at the privilege level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + category: { id: 'foo', label: 'foo' }, + alerting: ['foo', 'bar', 'baz'], + privileges: { + all: { + alerting: { + rule: { + all: ['foo'], + }, + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + read: { + alerting: { + rule: { + all: ['foo'], + }, + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool-sub-feature-privilege', + name: 'cool privilege', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + alerting: { + rule: { + all: ['bar'], + }, + }, + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies alerting entries which are not granted to any privileges: baz"` + ); + }); + + it(`prevents features from specifying alerting/alert entries that don't exist at the privilege level`, () => { const feature: KibanaFeatureConfig = { id: 'test-feature', name: 'Test Feature', @@ -878,7 +1004,11 @@ describe('FeatureRegistry', () => { alerting: ['foo', 'bar', 'baz'], privileges: { all: { - alerting: { all: ['foo'] }, + alerting: { + alert: { + all: ['foo'], + }, + }, savedObject: { all: [], read: [], @@ -887,7 +1017,11 @@ describe('FeatureRegistry', () => { app: [], }, read: { - alerting: { all: ['foo'] }, + alerting: { + alert: { + all: ['foo'], + }, + }, savedObject: { all: [], read: [], @@ -912,7 +1046,11 @@ describe('FeatureRegistry', () => { read: [], }, ui: [], - alerting: { all: ['bar'] }, + alerting: { + alert: { + all: ['bar'], + }, + }, }, ], }, @@ -930,7 +1068,47 @@ describe('FeatureRegistry', () => { ); }); - it(`prevents reserved privileges from specifying alerting entries that don't exist at the root level`, () => { + it(`prevents reserved privileges from specifying alerting/rule entries that don't exist at the root level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + category: { id: 'foo', label: 'foo' }, + alerting: ['bar'], + privileges: null, + reserved: { + description: 'something', + privileges: [ + { + id: 'reserved', + privilege: { + alerting: { + rule: { + all: ['foo', 'bar', 'baz'], + }, + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + ], + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature privilege test-feature.reserved has unknown alerting entries: foo, baz"` + ); + }); + + it(`prevents reserved privileges from specifying alerting/alert entries that don't exist at the root level`, () => { const feature: KibanaFeatureConfig = { id: 'test-feature', name: 'Test Feature', @@ -944,7 +1122,11 @@ describe('FeatureRegistry', () => { { id: 'reserved', privilege: { - alerting: { all: ['foo', 'bar', 'baz'] }, + alerting: { + alert: { + all: ['foo', 'bar', 'baz'], + }, + }, savedObject: { all: [], read: [], @@ -966,7 +1148,47 @@ describe('FeatureRegistry', () => { ); }); - it(`prevents features from specifying alerting entries that don't exist at the reserved privilege level`, () => { + it(`prevents features from specifying alerting/rule entries that don't exist at the reserved privilege level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + category: { id: 'foo', label: 'foo' }, + alerting: ['foo', 'bar', 'baz'], + privileges: null, + reserved: { + description: 'something', + privileges: [ + { + id: 'reserved', + privilege: { + alerting: { + rule: { + all: ['foo', 'bar'], + }, + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + ], + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies alerting entries which are not granted to any privileges: baz"` + ); + }); + + it(`prevents features from specifying alerting/alert entries that don't exist at the reserved privilege level`, () => { const feature: KibanaFeatureConfig = { id: 'test-feature', name: 'Test Feature', @@ -980,7 +1202,11 @@ describe('FeatureRegistry', () => { { id: 'reserved', privilege: { - alerting: { all: ['foo', 'bar'] }, + alerting: { + alert: { + all: ['foo', 'bar'], + }, + }, savedObject: { all: [], read: [], diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index 51d3331ac7da15..00272efc8aa782 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -80,8 +80,18 @@ const kibanaPrivilegeSchema = schema.object({ app: schema.maybe(schema.arrayOf(schema.string())), alerting: schema.maybe( schema.object({ - all: schema.maybe(alertingSchema), - read: schema.maybe(alertingSchema), + rule: schema.maybe( + schema.object({ + all: schema.maybe(alertingSchema), + read: schema.maybe(alertingSchema), + }) + ), + alert: schema.maybe( + schema.object({ + all: schema.maybe(alertingSchema), + read: schema.maybe(alertingSchema), + }) + ), }) ), savedObject: schema.object({ @@ -106,8 +116,18 @@ const kibanaIndependentSubFeaturePrivilegeSchema = schema.object({ catalogue: schema.maybe(catalogueSchema), alerting: schema.maybe( schema.object({ - all: schema.maybe(alertingSchema), - read: schema.maybe(alertingSchema), + rule: schema.maybe( + schema.object({ + all: schema.maybe(alertingSchema), + read: schema.maybe(alertingSchema), + }) + ), + alert: schema.maybe( + schema.object({ + all: schema.maybe(alertingSchema), + read: schema.maybe(alertingSchema), + }) + ), }) ), api: schema.maybe(schema.arrayOf(schema.string())), @@ -274,8 +294,8 @@ export function validateKibanaFeature(feature: KibanaFeatureConfig) { } function validateAlertingEntry(privilegeId: string, entry: FeatureKibanaPrivileges['alerting']) { - const all = entry?.all ?? []; - const read = entry?.read ?? []; + const all: string[] = [...(entry?.rule?.all ?? []), ...(entry?.alert?.all ?? [])]; + const read: string[] = [...(entry?.rule?.read ?? []), ...(entry?.alert?.read ?? [])]; all.forEach((privilegeAlertTypes) => unseenAlertTypes.delete(privilegeAlertTypes)); read.forEach((privilegeAlertTypes) => unseenAlertTypes.delete(privilegeAlertTypes)); diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx index d741874a7504c5..b8028547910097 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/settings_flyout/index.tsx @@ -277,7 +277,7 @@ export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { values={{ link: ( @@ -301,9 +301,26 @@ export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { label={i18n.translate('xpack.fleet.settings.elasticsearchUrlLabel', { defaultMessage: 'Elasticsearch hosts', })} - helpText={i18n.translate('xpack.fleet.settings.elasticsearchUrlsHelpTect', { - defaultMessage: 'Specify the Elasticsearch URLs where agents send data.', - })} + helpText={ + + + + ), + }} + /> + } {...inputs.elasticsearchUrl.formRowProps} > diff --git a/x-pack/plugins/infra/server/features.ts b/x-pack/plugins/infra/server/features.ts index aa2c628a23ddd8..91f82e82b33cdf 100644 --- a/x-pack/plugins/infra/server/features.ts +++ b/x-pack/plugins/infra/server/features.ts @@ -34,7 +34,12 @@ export const METRICS_FEATURE = { read: ['index-pattern'], }, alerting: { - all: [METRIC_THRESHOLD_ALERT_TYPE_ID, METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID], + rule: { + all: [METRIC_THRESHOLD_ALERT_TYPE_ID, METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID], + }, + alert: { + all: [METRIC_THRESHOLD_ALERT_TYPE_ID, METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID], + }, }, management: { insightsAndAlerting: ['triggersActions'], @@ -50,7 +55,12 @@ export const METRICS_FEATURE = { read: ['infrastructure-ui-source', 'index-pattern'], }, alerting: { - read: [METRIC_THRESHOLD_ALERT_TYPE_ID, METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID], + rule: { + read: [METRIC_THRESHOLD_ALERT_TYPE_ID, METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID], + }, + alert: { + read: [METRIC_THRESHOLD_ALERT_TYPE_ID, METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID], + }, }, management: { insightsAndAlerting: ['triggersActions'], @@ -83,7 +93,12 @@ export const LOGS_FEATURE = { read: [], }, alerting: { - all: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], + rule: { + all: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], + }, + alert: { + all: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], + }, }, management: { insightsAndAlerting: ['triggersActions'], @@ -95,7 +110,12 @@ export const LOGS_FEATURE = { catalogue: ['infralogging', 'logs'], api: ['infra'], alerting: { - read: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], + rule: { + read: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], + }, + alert: { + read: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], + }, }, management: { insightsAndAlerting: ['triggersActions'], diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index 3037513ccd56e8..a6517894654ede 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -140,7 +140,7 @@ const getDataBounds = function ( let min = Number.MAX_VALUE; let max = Number.MIN_VALUE; axis.series.forEach((series) => { - activeData?.[series.layer].rows.forEach((row) => { + activeData?.[series.layer]?.rows.forEach((row) => { const value = row[series.accessor]; if (!Number.isNaN(value)) { if (value < min) { diff --git a/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.ts new file mode 100644 index 00000000000000..ddc822f37b95bf --- /dev/null +++ b/x-pack/plugins/lens/server/embeddable/lens_embeddable_factory.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 { EmbeddableRegistryDefinition } from 'src/plugins/embeddable/server'; +import { SerializableState } from '../../../../../src/plugins/kibana_utils/common'; +import { DOC_TYPE } from '../../common'; +import { commonRenameOperationsForFormula } from '../migrations/common_migrations'; +import { LensDocShapePre712 } from '../migrations/types'; + +export const lensEmbeddableFactory = (): EmbeddableRegistryDefinition => { + return { + id: DOC_TYPE, + migrations: { + // This migration is run in 7.13.1 for `by value` panels because the 7.13 release window was missed. + '7.13.1': (state) => { + const lensState = (state as unknown) as { attributes: LensDocShapePre712 }; + const migratedLensState = commonRenameOperationsForFormula(lensState.attributes); + return ({ + ...lensState, + attributes: migratedLensState, + } as unknown) as SerializableState; + }, + }, + }; +}; diff --git a/x-pack/plugins/lens/server/__snapshots__/migrations.test.ts.snap b/x-pack/plugins/lens/server/migrations/__snapshots__/saved_object_migrations.test.ts.snap similarity index 100% rename from x-pack/plugins/lens/server/__snapshots__/migrations.test.ts.snap rename to x-pack/plugins/lens/server/migrations/__snapshots__/saved_object_migrations.test.ts.snap diff --git a/x-pack/plugins/lens/server/migrations/common_migrations.ts b/x-pack/plugins/lens/server/migrations/common_migrations.ts new file mode 100644 index 00000000000000..85055e471bac95 --- /dev/null +++ b/x-pack/plugins/lens/server/migrations/common_migrations.ts @@ -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 { cloneDeep } from 'lodash'; +import { LensDocShapePre712, OperationTypePre712, LensDocShapePost712 } from './types'; + +export const commonRenameOperationsForFormula = ( + attributes: LensDocShapePre712 +): LensDocShapePost712 => { + const renameMapping = { + avg: 'average', + cardinality: 'unique_count', + derivative: 'differences', + } as const; + function shouldBeRenamed(op: OperationTypePre712): op is keyof typeof renameMapping { + return op in renameMapping; + } + const newAttributes = cloneDeep(attributes); + const datasourceLayers = newAttributes.state.datasourceStates.indexpattern.layers || {}; + (newAttributes as LensDocShapePost712).state.datasourceStates.indexpattern.layers = Object.fromEntries( + Object.entries(datasourceLayers).map(([layerId, layer]) => { + return [ + layerId, + { + ...layer, + columns: Object.fromEntries( + Object.entries(layer.columns).map(([columnId, column]) => { + const copy = { + ...column, + operationType: shouldBeRenamed(column.operationType) + ? renameMapping[column.operationType] + : column.operationType, + }; + return [columnId, copy]; + }) + ), + }, + ]; + }) + ); + return newAttributes as LensDocShapePost712; +}; diff --git a/x-pack/plugins/lens/server/migrations.test.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts similarity index 98% rename from x-pack/plugins/lens/server/migrations.test.ts rename to x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts index bed19942e52bcd..5478d86e9b14c7 100644 --- a/x-pack/plugins/lens/server/migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.test.ts @@ -5,8 +5,12 @@ * 2.0. */ -import { migrations, LensDocShape } from './migrations'; -import { SavedObjectMigrationContext, SavedObjectMigrationFn } from 'src/core/server'; +import { migrations, LensDocShape } from './saved_object_migrations'; +import { + SavedObjectMigrationContext, + SavedObjectMigrationFn, + SavedObjectUnsanitizedDoc, +} from 'src/core/server'; describe('Lens migrations', () => { describe('7.7.0 missing dimensions in XY', () => { @@ -767,10 +771,7 @@ describe('Lens migrations', () => { }, }; - it('should rename only specific operation types', () => { - const result = migrations['7.13.0'](example, context) as ReturnType< - SavedObjectMigrationFn - >; + const validate = (result: SavedObjectUnsanitizedDoc>) => { const layers = result.attributes.state.datasourceStates.indexpattern.layers; expect(layers).toEqual({ '5ab74ddc-93ca-44e2-9857-ecf85c86b53e': { @@ -832,6 +833,23 @@ describe('Lens migrations', () => { expect(result.attributes.state.query).toEqual(example.attributes.state.query); expect(result.attributes.state.filters).toEqual(example.attributes.state.filters); expect(result.attributes.title).toEqual(example.attributes.title); + }; + + it('should rename only specific operation types', () => { + const result = migrations['7.13.0'](example, context) as ReturnType< + SavedObjectMigrationFn + >; + validate(result); + }); + + it('can be applied multiple times', () => { + const result1 = migrations['7.13.0'](example, context) as ReturnType< + SavedObjectMigrationFn + >; + const result2 = migrations['7.13.1'](result1, context) as ReturnType< + SavedObjectMigrationFn + >; + validate(result2); }); }); }); diff --git a/x-pack/plugins/lens/server/migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts similarity index 80% rename from x-pack/plugins/lens/server/migrations.ts rename to x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index 430c1a6caa6675..ba7004ba67a958 100644 --- a/x-pack/plugins/lens/server/migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -14,7 +14,9 @@ import { SavedObjectUnsanitizedDoc, } from 'src/core/server'; import { Query, Filter } from 'src/plugins/data/public'; -import { PersistableFilter } from '../common'; +import { PersistableFilter } from '../../common'; +import { LensDocShapePost712, LensDocShapePre712 } from './types'; +import { commonRenameOperationsForFormula } from './common_migrations'; interface LensDocShapePre710 { visualizationType: string | null; @@ -106,86 +108,6 @@ interface DatatableStatePost711 { }; } -type OperationTypePre712 = - | 'avg' - | 'cardinality' - | 'derivative' - | 'filters' - | 'terms' - | 'date_histogram' - | 'min' - | 'max' - | 'sum' - | 'median' - | 'percentile' - | 'last_value' - | 'count' - | 'range' - | 'cumulative_sum' - | 'counter_rate' - | 'moving_average'; -type OperationTypePost712 = Exclude< - OperationTypePre712 | 'average' | 'unique_count' | 'differences', - 'avg' | 'cardinality' | 'derivative' ->; -interface LensDocShapePre712 { - visualizationType: string | null; - title: string; - expression: string | null; - state: { - datasourceStates: { - // This is hardcoded as our only datasource - indexpattern: { - layers: Record< - string, - { - columns: Record< - string, - { - operationType: OperationTypePre712; - } - >; - } - >; - }; - }; - visualization: VisualizationState; - query: Query; - filters: Filter[]; - }; -} - -interface LensDocShapePost712 { - visualizationType: string | null; - title: string; - expression: string | null; - state: { - datasourceMetaData: { - filterableIndexPatterns: Array<{ id: string; title: string }>; - }; - datasourceStates: { - // This is hardcoded as our only datasource - indexpattern: { - currentIndexPatternId: string; - layers: Record< - string, - { - columns: Record< - string, - { - operationType: OperationTypePost712; - } - >; - } - >; - }; - }; - visualization: VisualizationState; - query: Query; - filters: Filter[]; - }; -} - /** * Removes the `lens_auto_date` subexpression from a stored expression * string. For example: aggConfigs={lens_auto_date aggConfigs="JSON string"} @@ -471,38 +393,11 @@ const renameOperationsForFormula: SavedObjectMigrationFn< LensDocShapePre712, LensDocShapePost712 > = (doc) => { - const renameMapping = { - avg: 'average', - cardinality: 'unique_count', - derivative: 'differences', - } as const; - function shouldBeRenamed(op: OperationTypePre712): op is keyof typeof renameMapping { - return op in renameMapping; - } const newDoc = cloneDeep(doc); - const datasourceLayers = newDoc.attributes.state.datasourceStates.indexpattern.layers || {}; - (newDoc.attributes as LensDocShapePost712).state.datasourceStates.indexpattern.layers = Object.fromEntries( - Object.entries(datasourceLayers).map(([layerId, layer]) => { - return [ - layerId, - { - ...layer, - columns: Object.fromEntries( - Object.entries(layer.columns).map(([columnId, column]) => { - const copy = { - ...column, - operationType: shouldBeRenamed(column.operationType) - ? renameMapping[column.operationType] - : column.operationType, - }; - return [columnId, copy]; - }) - ), - }, - ]; - }) - ); - return newDoc as SavedObjectUnsanitizedDoc; + return { + ...newDoc, + attributes: commonRenameOperationsForFormula(newDoc.attributes), + }; }; export const migrations: SavedObjectMigrationMap = { @@ -514,4 +409,5 @@ export const migrations: SavedObjectMigrationMap = { '7.11.0': removeSuggestedPriority, '7.12.0': transformTableState, '7.13.0': renameOperationsForFormula, + '7.13.1': renameOperationsForFormula, // duplicate this migration in case a broken by value panel is added to the library }; diff --git a/x-pack/plugins/lens/server/migrations/types.ts b/x-pack/plugins/lens/server/migrations/types.ts new file mode 100644 index 00000000000000..38e079ff380516 --- /dev/null +++ b/x-pack/plugins/lens/server/migrations/types.ts @@ -0,0 +1,89 @@ +/* + * 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 { Query, Filter } from 'src/plugins/data/public'; + +export type OperationTypePre712 = + | 'avg' + | 'cardinality' + | 'derivative' + | 'filters' + | 'terms' + | 'date_histogram' + | 'min' + | 'max' + | 'sum' + | 'median' + | 'percentile' + | 'last_value' + | 'count' + | 'range' + | 'cumulative_sum' + | 'counter_rate' + | 'moving_average'; +export type OperationTypePost712 = Exclude< + OperationTypePre712 | 'average' | 'unique_count' | 'differences', + 'avg' | 'cardinality' | 'derivative' +>; + +export interface LensDocShapePre712 { + visualizationType: string | null; + title: string; + expression: string | null; + state: { + datasourceStates: { + // This is hardcoded as our only datasource + indexpattern: { + layers: Record< + string, + { + columns: Record< + string, + { + operationType: OperationTypePre712; + } + >; + } + >; + }; + }; + query: Query; + visualization: VisualizationState; + filters: Filter[]; + }; +} + +export interface LensDocShapePost712 { + visualizationType: string | null; + title: string; + expression: string | null; + state: { + datasourceMetaData: { + filterableIndexPatterns: Array<{ id: string; title: string }>; + }; + datasourceStates: { + // This is hardcoded as our only datasource + indexpattern: { + currentIndexPatternId: string; + layers: Record< + string, + { + columns: Record< + string, + { + operationType: OperationTypePost712; + } + >; + } + >; + }; + }; + visualization: VisualizationState; + query: Query; + filters: Filter[]; + }; +} diff --git a/x-pack/plugins/lens/server/plugin.tsx b/x-pack/plugins/lens/server/plugin.tsx index 92b14ba509bae9..c23c98cd12aec9 100644 --- a/x-pack/plugins/lens/server/plugin.tsx +++ b/x-pack/plugins/lens/server/plugin.tsx @@ -17,10 +17,13 @@ import { scheduleLensTelemetry, } from './usage'; import { setupSavedObjects } from './saved_objects'; +import { EmbeddableSetup } from '../../../../src/plugins/embeddable/server'; +import { lensEmbeddableFactory } from './embeddable/lens_embeddable_factory'; export interface PluginSetupContract { usageCollection?: UsageCollectionSetup; taskManager?: TaskManagerSetupContract; + embeddable: EmbeddableSetup; } export interface PluginStartContract { @@ -53,6 +56,7 @@ export class LensServerPlugin implements Plugin<{}, {}, {}, {}> { plugins.taskManager ); } + plugins.embeddable.registerEmbeddableFactory(lensEmbeddableFactory()); return {}; } diff --git a/x-pack/plugins/lens/server/saved_objects.ts b/x-pack/plugins/lens/server/saved_objects.ts index 202439abf03761..0266378981fd61 100644 --- a/x-pack/plugins/lens/server/saved_objects.ts +++ b/x-pack/plugins/lens/server/saved_objects.ts @@ -7,7 +7,7 @@ import { CoreSetup } from 'kibana/server'; import { getEditPath } from '../common'; -import { migrations } from './migrations'; +import { migrations } from './migrations/saved_object_migrations'; export function setupSavedObjects(core: CoreSetup) { core.savedObjects.registerType({ diff --git a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts index e1f682678df4b9..e65e19d7def482 100644 --- a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts @@ -13,6 +13,11 @@ import { RENDER_AS, SCALING_TYPES } from '../constants'; import { MapExtent, MapQuery } from './map_descriptor'; import { Filter, TimeRange } from '../../../../../src/plugins/data/common'; +export type Timeslice = { + from: number; + to: number; +}; + // Global map state passed to every layer. export type MapFilters = { buffer?: MapExtent; // extent with additional buffer @@ -22,6 +27,7 @@ export type MapFilters = { refreshTimerLastTriggeredAt?: string; searchSessionId?: string; timeFilters: TimeRange; + timeslice?: Timeslice; zoom: number; }; diff --git a/x-pack/plugins/maps/public/actions/map_actions.test.js b/x-pack/plugins/maps/public/actions/map_actions.test.js index fafafa6b6a0711..77ce23594447f8 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.test.js +++ b/x-pack/plugins/maps/public/actions/map_actions.test.js @@ -271,6 +271,9 @@ describe('map_actions', () => { require('../selectors/map_selectors').getTimeFilters = () => { return timeFilters; }; + require('../selectors/map_selectors').getTimeslice = () => { + return undefined; + }; require('../selectors/map_selectors').getFilters = () => { return filters; }; diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index 4b2d5da31a242f..32e17376acaa75 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -19,6 +19,7 @@ import { getWaitingForMapReadyLayerListRaw, getQuery, getTimeFilters, + getTimeslice, getLayerList, getSearchSessionId, getSearchSessionMapBuffer, @@ -53,6 +54,7 @@ import { MapCenterAndZoom, MapExtent, MapRefreshConfig, + Timeslice, } from '../../common/descriptor_types'; import { INITIAL_LOCATION } from '../../common/constants'; import { scaleBounds } from '../../common/elasticsearch_util'; @@ -226,17 +228,21 @@ function generateQueryTimestamp() { export function setQuery({ query, timeFilters, - filters = [], + timeslice, + filters, forceRefresh = false, searchSessionId, searchSessionMapBuffer, + clearTimeslice, }: { filters?: Filter[]; query?: Query; timeFilters?: TimeRange; + timeslice?: Timeslice; forceRefresh?: boolean; searchSessionId?: string; searchSessionMapBuffer?: MapExtent; + clearTimeslice?: boolean; }) { return async ( dispatch: ThunkDispatch, @@ -248,10 +254,24 @@ export function setQuery({ ? prevQuery.queryLastTriggeredAt : generateQueryTimestamp(); + const prevTimeFilters = getTimeFilters(getState()); + + function getNextTimeslice() { + if ( + clearTimeslice || + (timeFilters !== undefined && !_.isEqual(timeFilters, prevTimeFilters)) + ) { + return undefined; + } + + return timeslice ? timeslice : getTimeslice(getState()); + } + const nextQueryContext = { - timeFilters: timeFilters ? timeFilters : getTimeFilters(getState()), + timeFilters: timeFilters ? timeFilters : prevTimeFilters, + timeslice: getNextTimeslice(), query: { - ...(query ? query : getQuery(getState())), + ...(query ? query : prevQuery), // ensure query changes to trigger re-fetch when "Refresh" clicked queryLastTriggeredAt: forceRefresh ? generateQueryTimestamp() : prevTriggeredAt, }, @@ -261,8 +281,9 @@ export function setQuery({ }; const prevQueryContext = { - timeFilters: getTimeFilters(getState()), - query: getQuery(getState()), + timeFilters: prevTimeFilters, + timeslice: getTimeslice(getState()), + query: prevQuery, filters: getFilters(getState()), searchSessionId: getSearchSessionId(getState()), searchSessionMapBuffer: getSearchSessionMapBuffer(getState()), diff --git a/x-pack/plugins/maps/public/actions/ui_actions.ts b/x-pack/plugins/maps/public/actions/ui_actions.ts index f9c0e324aa5d8b..7809c71d7f6969 100644 --- a/x-pack/plugins/maps/public/actions/ui_actions.ts +++ b/x-pack/plugins/maps/public/actions/ui_actions.ts @@ -10,11 +10,12 @@ import { ThunkDispatch } from 'redux-thunk'; import { MapStoreState } from '../reducers/store'; import { getFlyoutDisplay } from '../selectors/ui_selectors'; import { FLYOUT_STATE } from '../reducers/ui'; -import { trackMapSettings } from './map_actions'; +import { setQuery, trackMapSettings } from './map_actions'; import { setSelectedLayer } from './layer_actions'; export const UPDATE_FLYOUT = 'UPDATE_FLYOUT'; export const SET_IS_LAYER_TOC_OPEN = 'SET_IS_LAYER_TOC_OPEN'; +export const SET_IS_TIME_SLIDER_OPEN = 'SET_IS_TIME_SLIDER_OPEN'; export const SET_FULL_SCREEN = 'SET_FULL_SCREEN'; export const SET_READ_ONLY = 'SET_READ_ONLY'; export const SET_OPEN_TOC_DETAILS = 'SET_OPEN_TOC_DETAILS'; @@ -87,3 +88,20 @@ export function hideTOCDetails(layerId: string) { layerId, }; } + +export function openTimeslider() { + return { + type: SET_IS_TIME_SLIDER_OPEN, + isTimesliderOpen: true, + }; +} + +export function closeTimeslider() { + return (dispatch: ThunkDispatch) => { + dispatch({ + type: SET_IS_TIME_SLIDER_OPEN, + isTimesliderOpen: false, + }); + dispatch(setQuery({ clearTimeslice: true })); + }; +} diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/utils.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/utils.tsx index a7ac9dd9cfb6af..5f42d80de9bbbd 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/utils.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/utils.tsx @@ -136,6 +136,7 @@ export async function getVectorSourceBounds({ sourceQuery: sourceQuery ? sourceQuery : undefined, query: dataFilters.query, timeFilters: dataFilters.timeFilters, + timeslice: dataFilters.timeslice, filters: dataFilters.filters, applyGlobalQuery: source.getApplyGlobalQuery(), applyGlobalTime: source.getApplyGlobalTime(), diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts index 749e3d60582665..23bcd9baed8c0b 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts @@ -218,7 +218,14 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource allFilters.push(extentFilter); } if (searchFilters.applyGlobalTime && (await this.isTimeAware())) { - const filter = getTimeFilter().createFilter(indexPattern, searchFilters.timeFilters); + const timeRange = searchFilters.timeslice + ? { + from: new Date(searchFilters.timeslice.from).toISOString(), + to: new Date(searchFilters.timeslice.to).toISOString(), + mode: 'absolute' as 'absolute', + } + : searchFilters.timeFilters; + const filter = getTimeFilter().createFilter(indexPattern, timeRange); if (filter) { allFilters.push(filter); } diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx index da5a236a20936c..eabc5c4314d628 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.tsx @@ -15,6 +15,7 @@ import { ESSearchSourceResponseMeta, MapExtent, MapQuery, + Timeslice, VectorSourceRequestMeta, VectorSourceSyncMeta, } from '../../../../common/descriptor_types'; @@ -39,6 +40,7 @@ export interface BoundsFilters { query?: MapQuery; sourceQuery?: MapQuery; timeFilters: TimeRange; + timeslice?: Timeslice; } export interface IVectorSource extends ISource { diff --git a/x-pack/plugins/maps/public/classes/util/can_skip_fetch.test.js b/x-pack/plugins/maps/public/classes/util/can_skip_fetch.test.js index 1901b15e8f3506..c13b2fd441cad9 100644 --- a/x-pack/plugins/maps/public/classes/util/can_skip_fetch.test.js +++ b/x-pack/plugins/maps/public/classes/util/can_skip_fetch.test.js @@ -287,4 +287,322 @@ describe('canSkipSourceUpdate', () => { }); }); }); + + describe('isTimeAware', () => { + function createSourceMock() { + return { + isTimeAware: () => { + return true; + }, + isRefreshTimerAware: () => { + return false; + }, + isFilterByMapBounds: () => { + return false; + }, + isFieldAware: () => { + return false; + }, + isQueryAware: () => { + return false; + }, + isGeoGridPrecisionAware: () => { + return false; + }, + }; + } + + describe('applyGlobalTime', () => { + it('can not skip update when applyGlobalTime changes', async () => { + const canSkipUpdate = await canSkipSourceUpdate({ + source: createSourceMock(), + prevDataRequest: new DataRequest({ + dataId: SOURCE_DATA_REQUEST_ID, + dataMeta: { + applyGlobalTime: true, + }, + data: {}, + }), + nextMeta: { + applyGlobalTime: false, + }, + extentAware: false, + }); + + expect(canSkipUpdate).toBe(false); + }); + + it('can skip update when applyGlobalTime does not change', async () => { + const canSkipUpdate = await canSkipSourceUpdate({ + source: createSourceMock(), + prevDataRequest: new DataRequest({ + dataId: SOURCE_DATA_REQUEST_ID, + dataMeta: { + applyGlobalTime: true, + }, + data: {}, + }), + nextMeta: { + applyGlobalTime: true, + }, + extentAware: false, + }); + + expect(canSkipUpdate).toBe(true); + }); + }); + + describe('timeFilters', () => { + it('can not skip update when time range changes', async () => { + const canSkipUpdate = await canSkipSourceUpdate({ + source: createSourceMock(), + prevDataRequest: new DataRequest({ + dataId: SOURCE_DATA_REQUEST_ID, + dataMeta: { + applyGlobalTime: true, + timeFilters: { + from: 'now-15m', + to: 'now', + }, + }, + data: {}, + }), + nextMeta: { + applyGlobalTime: true, + timeFilters: { + from: 'now-7d', + to: 'now', + }, + }, + extentAware: false, + }); + + expect(canSkipUpdate).toBe(false); + }); + + it('can skip update when time range does not change', async () => { + const canSkipUpdate = await canSkipSourceUpdate({ + source: createSourceMock(), + prevDataRequest: new DataRequest({ + dataId: SOURCE_DATA_REQUEST_ID, + dataMeta: { + applyGlobalTime: true, + timeFilters: { + from: 'now-15m', + to: 'now', + }, + }, + data: {}, + }), + nextMeta: { + applyGlobalTime: true, + timeFilters: { + from: 'now-15m', + to: 'now', + }, + }, + extentAware: false, + }); + + expect(canSkipUpdate).toBe(true); + }); + + it('can skip update when time range changes but applyGlobalTime is false', async () => { + const canSkipUpdate = await canSkipSourceUpdate({ + source: createSourceMock(), + prevDataRequest: new DataRequest({ + dataId: SOURCE_DATA_REQUEST_ID, + dataMeta: { + applyGlobalTime: false, + timeFilters: { + from: 'now-15m', + to: 'now', + }, + }, + data: {}, + }), + nextMeta: { + applyGlobalTime: false, + timeFilters: { + from: 'now-7d', + to: 'now', + }, + }, + extentAware: false, + }); + + expect(canSkipUpdate).toBe(true); + }); + }); + + describe('timeslice', () => { + const mockSource = createSourceMock(); + it('can not skip update when timeslice changes (undefined => provided)', async () => { + const canSkipUpdate = await canSkipSourceUpdate({ + source: mockSource, + prevDataRequest: new DataRequest({ + dataId: SOURCE_DATA_REQUEST_ID, + dataMeta: { + applyGlobalTime: true, + timeFilters: { + from: 'now-7d', + to: 'now', + }, + }, + data: {}, + }), + nextMeta: { + applyGlobalTime: true, + timeFilters: { + from: 'now-7d', + to: 'now', + }, + timeslice: { + from: 0, + to: 1000, + }, + }, + extentAware: false, + }); + + expect(canSkipUpdate).toBe(false); + }); + + it('can not skip update when timeslice changes', async () => { + const canSkipUpdate = await canSkipSourceUpdate({ + source: mockSource, + prevDataRequest: new DataRequest({ + dataId: SOURCE_DATA_REQUEST_ID, + dataMeta: { + applyGlobalTime: true, + timeFilters: { + from: 'now-7d', + to: 'now', + }, + timeslice: { + from: 0, + to: 1000, + }, + }, + data: {}, + }), + nextMeta: { + applyGlobalTime: true, + timeFilters: { + from: 'now-7d', + to: 'now', + }, + timeslice: { + from: 1000, + to: 2000, + }, + }, + extentAware: false, + }); + + expect(canSkipUpdate).toBe(false); + }); + + it('can not skip update when timeslice changes (provided => undefined)', async () => { + const canSkipUpdate = await canSkipSourceUpdate({ + source: mockSource, + prevDataRequest: new DataRequest({ + dataId: SOURCE_DATA_REQUEST_ID, + dataMeta: { + applyGlobalTime: true, + timeFilters: { + from: 'now-7d', + to: 'now', + }, + timeslice: { + from: 0, + to: 1000, + }, + }, + data: {}, + }), + nextMeta: { + applyGlobalTime: true, + timeFilters: { + from: 'now-7d', + to: 'now', + }, + }, + extentAware: false, + }); + + expect(canSkipUpdate).toBe(false); + }); + + it('can skip update when timeslice does not change', async () => { + const canSkipUpdate = await canSkipSourceUpdate({ + source: mockSource, + prevDataRequest: new DataRequest({ + dataId: SOURCE_DATA_REQUEST_ID, + dataMeta: { + applyGlobalTime: true, + timeFilters: { + from: 'now-7d', + to: 'now', + }, + timeslice: { + from: 0, + to: 1000, + }, + }, + data: {}, + }), + nextMeta: { + applyGlobalTime: true, + timeFilters: { + from: 'now-7d', + to: 'now', + }, + timeslice: { + from: 0, + to: 1000, + }, + }, + extentAware: false, + }); + + expect(canSkipUpdate).toBe(true); + }); + + it('can skip update when timeslice changes but applyGlobalTime is false', async () => { + const canSkipUpdate = await canSkipSourceUpdate({ + source: mockSource, + prevDataRequest: new DataRequest({ + dataId: SOURCE_DATA_REQUEST_ID, + dataMeta: { + applyGlobalTime: false, + timeFilters: { + from: 'now-7d', + to: 'now', + }, + timeslice: { + from: 0, + to: 1000, + }, + }, + data: {}, + }), + nextMeta: { + applyGlobalTime: false, + timeFilters: { + from: 'now-7d', + to: 'now', + }, + timeslice: { + from: 1000, + to: 2000, + }, + }, + extentAware: false, + }); + + expect(canSkipUpdate).toBe(true); + }); + }); + }); }); diff --git a/x-pack/plugins/maps/public/classes/util/can_skip_fetch.ts b/x-pack/plugins/maps/public/classes/util/can_skip_fetch.ts index 575c99432f508c..1f2678f40eecd3 100644 --- a/x-pack/plugins/maps/public/classes/util/can_skip_fetch.ts +++ b/x-pack/plugins/maps/public/classes/util/can_skip_fetch.ts @@ -89,10 +89,12 @@ export async function canSkipSourceUpdate({ let updateDueToApplyGlobalTime = false; let updateDueToTime = false; + let updateDueToTimeslice = false; if (timeAware) { updateDueToApplyGlobalTime = prevMeta.applyGlobalTime !== nextMeta.applyGlobalTime; if (nextMeta.applyGlobalTime) { updateDueToTime = !_.isEqual(prevMeta.timeFilters, nextMeta.timeFilters); + updateDueToTimeslice = !_.isEqual(prevMeta.timeslice, nextMeta.timeslice); } } @@ -148,6 +150,7 @@ export async function canSkipSourceUpdate({ return ( !updateDueToApplyGlobalTime && !updateDueToTime && + !updateDueToTimeslice && !updateDueToRefreshTimer && !updateDueToExtentChange && !updateDueToFields && diff --git a/x-pack/plugins/maps/public/connected_components/_index.scss b/x-pack/plugins/maps/public/connected_components/_index.scss index 9a49dbe550efb2..56fcdc91659743 100644 --- a/x-pack/plugins/maps/public/connected_components/_index.scss +++ b/x-pack/plugins/maps/public/connected_components/_index.scss @@ -1,4 +1,5 @@ @import 'map_container/map_container'; +@import 'timeslider/index'; @import 'edit_layer_panel/index'; @import 'right_side_controls/index'; @import 'toolbar_overlay/index'; diff --git a/x-pack/plugins/maps/public/connected_components/map_container/index.ts b/x-pack/plugins/maps/public/connected_components/map_container/index.ts index cda96792fc6d68..408137b6f23b35 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/index.ts +++ b/x-pack/plugins/maps/public/connected_components/map_container/index.ts @@ -13,6 +13,7 @@ import { getFlyoutDisplay, getIsFullScreen } from '../../selectors/ui_selectors' import { triggerRefreshTimer, cancelAllInFlightRequests, exitFullScreen } from '../../actions'; import { areLayersLoaded, + getLayerList, getRefreshConfig, getMapInitError, getMapSettings, @@ -30,6 +31,7 @@ function mapStateToProps(state: MapStoreState) { mapInitError: getMapInitError(state), indexPatternIds: getQueryableUniqueIndexPatternIds(state), settings: getMapSettings(state), + layerList: getLayerList(state), }; } diff --git a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx index 9128cebf480ed2..02374932a4c703 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx @@ -15,6 +15,7 @@ import { Filter } from 'src/plugins/data/public'; import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; import { MBMap } from '../mb_map'; import { RightSideControls } from '../right_side_controls'; +import { Timeslider } from '../timeslider'; import { ToolbarOverlay } from '../toolbar_overlay'; import { EditLayerPanel } from '../edit_layer_panel'; import { AddLayerPanel } from '../add_layer_panel'; @@ -29,6 +30,7 @@ import { registerLayerWizards } from '../../classes/layers/load_layer_wizards'; import { RenderToolTipContent } from '../../classes/tooltips/tooltip_property'; import { GeoFieldWithIndex } from '../../components/geo_field_with_index'; import { MapRefreshConfig } from '../../../common/descriptor_types'; +import { ILayer } from '../../classes/layers/layer'; const RENDER_COMPLETE_EVENT = 'renderComplete'; @@ -50,12 +52,15 @@ export interface Props { title?: string; description?: string; settings: MapSettings; + layerList: ILayer[]; } interface State { isInitialLoadRenderTimeoutComplete: boolean; domId: string; geoFields: GeoFieldWithIndex[]; + showFitToBoundsButton: boolean; + showTimesliderButton: boolean; } export class MapContainer extends Component { @@ -70,16 +75,22 @@ export class MapContainer extends Component { isInitialLoadRenderTimeoutComplete: false, domId: uuid(), geoFields: [], + showFitToBoundsButton: false, + showTimesliderButton: false, }; componentDidMount() { this._isMounted = true; this._setRefreshTimer(); + this._loadShowFitToBoundsButton(); + this._loadShowTimesliderButton(); registerLayerWizards(); } componentDidUpdate() { this._setRefreshTimer(); + this._loadShowFitToBoundsButton(); + this._loadShowTimesliderButton(); if (this.props.areLayersLoaded && !this._isInitalLoadRenderTimerStarted) { this._isInitalLoadRenderTimerStarted = true; this._startInitialLoadRenderTimer(); @@ -111,7 +122,36 @@ export class MapContainer extends Component { } }; - _loadGeoFields = async (nextIndexPatternIds: string[]) => { + async _loadShowFitToBoundsButton() { + const promises = this.props.layerList.map(async (layer) => { + return await layer.isFittable(); + }); + const showFitToBoundsButton = (await Promise.all(promises)).some((isFittable) => isFittable); + if (this._isMounted && this.state.showFitToBoundsButton !== showFitToBoundsButton) { + this.setState({ showFitToBoundsButton }); + } + } + + async _loadShowTimesliderButton() { + if (!this.props.settings.showTimesliderToggleButton) { + if (this.state.showTimesliderButton) { + this.setState({ showTimesliderButton: false }); + } + return; + } + + const promises = this.props.layerList.map(async (layer) => { + return await layer.isFilteredByGlobalTime(); + }); + const showTimesliderButton = (await Promise.all(promises)).some( + (isFilteredByGlobalTime) => isFilteredByGlobalTime + ); + if (this._isMounted && this.state.showTimesliderButton !== showTimesliderButton) { + this.setState({ showTimesliderButton }); + } + } + + async _loadGeoFields(nextIndexPatternIds: string[]) { if (_.isEqual(nextIndexPatternIds, this._prevIndexPatternIds)) { // all ready loaded index pattern ids return; @@ -143,7 +183,7 @@ export class MapContainer extends Component { } this.setState({ geoFields }); - }; + } _setRefreshTimer = () => { const { isPaused, interval } = this.props.refreshConfig; @@ -258,11 +298,15 @@ export class MapContainer extends Component { geoFields={this.state.geoFields} getFilterActions={getFilterActions} getActionContext={getActionContext} + showFitToBoundsButton={this.state.showFitToBoundsButton} + showTimesliderButton={this.state.showTimesliderButton} /> )} + + * { + align-items: center; + } +} + +.mapTimeslider__close { + position: absolute; + top: $euiSizeXS; + right: $euiSizeXS; +} + +.mapTimeslider__timeWindow { + display: flex; + flex: 1; + margin-right: $euiSizeS; + font-size: $euiFontSizeS; +} + +.mapTimeslider__controls { + margin-left: $euiSizeS; +} + +.mapTimeslider__innerPanel { + display: inline-flex; + // background: $euiColorLightestShade; + border-radius: $euiBorderRadiusSmall; + padding: $euiSizeXS; + display: inline-flex; + align-items: center; +} diff --git a/x-pack/plugins/maps/public/connected_components/timeslider/index.ts b/x-pack/plugins/maps/public/connected_components/timeslider/index.ts new file mode 100644 index 00000000000000..90d29f9ae06358 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/timeslider/index.ts @@ -0,0 +1,42 @@ +/* + * 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 { AnyAction } from 'redux'; +import { ThunkDispatch } from 'redux-thunk'; +import { connect } from 'react-redux'; +import { Timeslider } from './timeslider'; +import { closeTimeslider, setQuery } from '../../actions'; +import { getTimeFilters } from '../../selectors/map_selectors'; +import { getIsTimesliderOpen } from '../../selectors/ui_selectors'; +import { MapStoreState } from '../../reducers/store'; +import { Timeslice } from '../../../common/descriptor_types'; + +function mapStateToProps(state: MapStoreState) { + return { + isTimesliderOpen: getIsTimesliderOpen(state), + timeRange: getTimeFilters(state), + }; +} + +function mapDispatchToProps(dispatch: ThunkDispatch) { + return { + closeTimeslider: () => { + dispatch(closeTimeslider()); + }, + setTimeslice: (timeslice: Timeslice) => { + dispatch( + setQuery({ + forceRefresh: false, + timeslice, + }) + ); + }, + }; +} + +const connected = connect(mapStateToProps, mapDispatchToProps)(Timeslider); +export { connected as Timeslider }; diff --git a/x-pack/plugins/maps/public/connected_components/timeslider/time_utils.test.ts b/x-pack/plugins/maps/public/connected_components/timeslider/time_utils.test.ts new file mode 100644 index 00000000000000..16973b5a84478c --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/timeslider/time_utils.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getInterval } from './time_utils'; + +describe('getInterval', () => { + test('should provide interval of 1 day for 7 day range', () => { + expect(getInterval(1617630946622, 1618235746622)).toBe(86400000); + }); + + test('should provide interval of 3 hours for 24 hour range', () => { + expect(getInterval(1618150382531, 1618236782531)).toBe(10800000); + }); + + test('should provide interval of 90 minues for 12 hour range', () => { + expect(getInterval(1618193892632, 1618237092632)).toBe(5400000); + }); + + test('should provide interval of 30 minues for 4 hour range', () => { + expect(getInterval(1618222509189, 1618236909189)).toBe(1800000); + }); + + test('should provide interval of 10 minues for 1 hour range', () => { + expect(getInterval(1618233266459, 1618236866459)).toBe(600000); + }); +}); diff --git a/x-pack/plugins/maps/public/connected_components/timeslider/time_utils.ts b/x-pack/plugins/maps/public/connected_components/timeslider/time_utils.ts new file mode 100644 index 00000000000000..7195851aafe2c0 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/timeslider/time_utils.ts @@ -0,0 +1,84 @@ +/* + * 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 moment from 'moment-timezone'; +import { EuiRangeTick } from '@elastic/eui/src/components/form/range/range_ticks'; +import { calcAutoIntervalNear } from '../../../../../../src/plugins/data/common'; +import { getUiSettings } from '../../kibana_services'; + +function getTimezone() { + const detectedTimezone = moment.tz.guess(); + const dateFormatTZ = getUiSettings().get('dateFormat:tz', 'Browser'); + + return dateFormatTZ === 'Browser' ? detectedTimezone : dateFormatTZ; +} + +function getScaledDateFormat(interval: number): string { + if (interval >= moment.duration(1, 'y').asMilliseconds()) { + return 'YYYY'; + } + + if (interval >= moment.duration(1, 'd').asMilliseconds()) { + return 'MMM D'; + } + + if (interval >= moment.duration(6, 'h').asMilliseconds()) { + return 'Do HH'; + } + + if (interval >= moment.duration(1, 'h').asMilliseconds()) { + return 'HH:mm'; + } + + if (interval >= moment.duration(1, 'm').asMilliseconds()) { + return 'HH:mm'; + } + + if (interval >= moment.duration(1, 's').asMilliseconds()) { + return 'mm:ss'; + } + + return 'ss.SSS'; +} + +export function epochToKbnDateFormat(epoch: number): string { + const dateFormat = getUiSettings().get('dateFormat', 'MMM D, YYYY @ HH:mm:ss.SSS'); + const timezone = getTimezone(); + return moment.tz(epoch, timezone).format(dateFormat); +} + +export function getInterval(min: number, max: number, steps = 6): number { + const duration = max - min; + let interval = calcAutoIntervalNear(steps, duration).asMilliseconds(); + // Sometimes auto interval is not quite right and returns 2X or 3X requested ticks + // Adjust the interval to get closer to the requested number of ticks + const actualSteps = duration / interval; + if (actualSteps > steps * 1.5) { + const factor = Math.round(actualSteps / steps); + interval *= factor; + } else if (actualSteps < 5) { + interval *= 0.5; + } + return interval; +} + +export function getTicks(min: number, max: number, interval: number): EuiRangeTick[] { + const format = getScaledDateFormat(interval); + const timezone = getTimezone(); + + let tick = Math.ceil(min / interval) * interval; + const ticks: EuiRangeTick[] = []; + while (tick < max) { + ticks.push({ + value: tick, + label: moment.tz(tick, timezone).format(format), + }); + tick += interval; + } + + return ticks; +} diff --git a/x-pack/plugins/maps/public/connected_components/timeslider/timeslider.tsx b/x-pack/plugins/maps/public/connected_components/timeslider/timeslider.tsx new file mode 100644 index 00000000000000..0b7bcb115eb954 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/timeslider/timeslider.tsx @@ -0,0 +1,176 @@ +/* + * 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 _ from 'lodash'; +import React, { Component } from 'react'; +import { EuiButtonIcon, EuiDualRange, EuiText } from '@elastic/eui'; +import { EuiRangeTick } from '@elastic/eui/src/components/form/range/range_ticks'; +import { i18n } from '@kbn/i18n'; +import { epochToKbnDateFormat, getInterval, getTicks } from './time_utils'; +import { TimeRange } from '../../../../../../src/plugins/data/common'; +import { getTimeFilter } from '../../kibana_services'; +import { Timeslice } from '../../../common/descriptor_types'; + +export interface Props { + closeTimeslider: () => void; + setTimeslice: (timeslice: Timeslice) => void; + isTimesliderOpen: boolean; + timeRange: TimeRange; +} + +interface State { + max: number; + min: number; + range: number; + timeslice: [number, number]; + ticks: EuiRangeTick[]; +} + +function prettyPrintTimeslice(timeslice: [number, number]) { + return `${epochToKbnDateFormat(timeslice[0])} - ${epochToKbnDateFormat(timeslice[1])}`; +} + +// Why Timeslider and KeyedTimeslider? +// Using react 'key' property to ensure new KeyedTimeslider instance whenever props.timeRange changes +export function Timeslider(props: Props) { + return props.isTimesliderOpen ? ( + + ) : null; +} + +class KeyedTimeslider extends Component { + private _isMounted: boolean = false; + + constructor(props: Props) { + super(props); + const timeRangeBounds = getTimeFilter().calculateBounds(props.timeRange); + if (timeRangeBounds.min === undefined || timeRangeBounds.max === undefined) { + throw new Error( + 'Unable to create Timeslider component, timeRangeBounds min or max are undefined' + ); + } + const min = timeRangeBounds.min.valueOf(); + const max = timeRangeBounds.max.valueOf(); + const interval = getInterval(min, max); + const timeslice: [number, number] = [min, max]; + + this.state = { + max, + min, + range: interval, + ticks: getTicks(min, max, interval), + timeslice, + }; + } + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidMount() { + this._isMounted = true; + // auto-select range between first tick and second tick + this._onChange([this.state.ticks[0].value, this.state.ticks[1].value]); + } + + _doesTimesliceCoverTimerange() { + return this.state.timeslice[0] === this.state.min && this.state.timeslice[1] === this.state.max; + } + + _onDualControlChange = (value: [number | string, number | string]) => { + this.setState({ range: (value[1] as number) - (value[0] as number) }, () => { + this._onChange(value as [number, number]); + }); + }; + + _onChange = (value: [number, number]) => { + this.setState({ + timeslice: value, + }); + this._propagateChange(value); + }; + + _onNext = () => { + const from = + this._doesTimesliceCoverTimerange() || this.state.timeslice[1] === this.state.max + ? this.state.ticks[0].value + : this.state.timeslice[1]; + const to = from + this.state.range; + this._onChange([from, to <= this.state.max ? to : this.state.max]); + }; + + _onPrevious = () => { + const to = + this._doesTimesliceCoverTimerange() || this.state.timeslice[0] === this.state.min + ? this.state.ticks[this.state.ticks.length - 1].value + : this.state.timeslice[0]; + const from = to - this.state.range; + this._onChange([from < this.state.min ? this.state.min : from, to]); + }; + + _propagateChange = _.debounce((value: [number, number]) => { + if (this._isMounted) { + this.props.setTimeslice({ from: value[0], to: value[1] }); + } + }, 300); + + render() { + return ( +
+
+ + +
+ {prettyPrintTimeslice(this.state.timeslice)} +
+ +
+
+ + +
+
+
+ +
+ +
+
+ ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/__snapshots__/toolbar_overlay.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/__snapshots__/toolbar_overlay.test.tsx.snap index 506767fcd47065..168a070b077441 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/__snapshots__/toolbar_overlay.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/__snapshots__/toolbar_overlay.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Must render zoom tools 1`] = ` +exports[`Should only show set view control 1`] = `
- - - `; -exports[`Must zoom tools and draw filter tools 1`] = ` +exports[`Should show all controls 1`] = ` + + + `; diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx index 64e163cd96a92d..f975bc293d8239 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx @@ -9,68 +9,27 @@ import React from 'react'; import { EuiButtonIcon, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { ILayer } from '../../../classes/layers/layer'; export interface Props { - layerList: ILayer[]; fitToBounds: () => void; } -interface State { - canFit: boolean; -} - -export class FitToData extends React.Component { - _isMounted: boolean = false; - - state = { canFit: false }; - - componentDidMount(): void { - this._isMounted = true; - this._loadCanFit(); - } - - componentWillUnmount(): void { - this._isMounted = false; - } - - componentDidUpdate(): void { - this._loadCanFit(); - } - - async _loadCanFit() { - const promises = this.props.layerList.map(async (layer) => { - return await layer.isFittable(); - }); - const canFit = (await Promise.all(promises)).some((isFittable) => isFittable); - if (this._isMounted && this.state.canFit !== canFit) { - this.setState({ - canFit, - }); - } - } - - render() { - if (!this.state.canFit) { - return null; - } - - return ( - - - - ); - } +export function FitToData(props: Props) { + return ( + + + + ); } diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/index.ts b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/index.ts index 99fddff5153b4f..b4322c93097f0e 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/index.ts +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/index.ts @@ -10,13 +10,10 @@ import { ThunkDispatch } from 'redux-thunk'; import { connect } from 'react-redux'; import { MapStoreState } from '../../../reducers/store'; import { fitToDataBounds } from '../../../actions'; -import { getLayerList } from '../../../selectors/map_selectors'; import { FitToData } from './fit_to_data'; function mapStateToProps(state: MapStoreState) { - return { - layerList: getLayerList(state), - }; + return {}; } function mapDispatchToProps(dispatch: ThunkDispatch) { diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/timeslider_toggle_button/clock_play_icon.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/timeslider_toggle_button/clock_play_icon.tsx new file mode 100644 index 00000000000000..bc24889d2ec2c0 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/timeslider_toggle_button/clock_play_icon.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 React, { FunctionComponent } from 'react'; + +interface Props { + title?: string; + titleId?: string; +} + +export const ClockPlayIcon: FunctionComponent = ({ title, titleId, ...props }) => ( + + {title ? {title} : null} + + + + +); diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/timeslider_toggle_button/index.ts b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/timeslider_toggle_button/index.ts new file mode 100644 index 00000000000000..b31747f7e6a9f7 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/timeslider_toggle_button/index.ts @@ -0,0 +1,34 @@ +/* + * 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 { AnyAction } from 'redux'; +import { ThunkDispatch } from 'redux-thunk'; +import { connect } from 'react-redux'; +import { TimesliderToggleButton } from './timeslider_toggle_button'; +import { closeTimeslider, openTimeslider } from '../../../actions'; +import { getIsTimesliderOpen } from '../../../selectors/ui_selectors'; +import { MapStoreState } from '../../../reducers/store'; + +function mapStateToProps(state: MapStoreState) { + return { + isTimesliderOpen: getIsTimesliderOpen(state), + }; +} + +function mapDispatchToProps(dispatch: ThunkDispatch) { + return { + closeTimeslider: () => { + dispatch(closeTimeslider()); + }, + openTimeslider: () => { + dispatch(openTimeslider()); + }, + }; +} + +const connected = connect(mapStateToProps, mapDispatchToProps)(TimesliderToggleButton); +export { connected as TimesliderToggleButton }; diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/timeslider_toggle_button/timeslider_toggle_button.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/timeslider_toggle_button/timeslider_toggle_button.tsx new file mode 100644 index 00000000000000..9332c2baaa5029 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/timeslider_toggle_button/timeslider_toggle_button.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonIcon, EuiPanel } from '@elastic/eui'; +import { ClockPlayIcon } from './clock_play_icon'; + +export interface Props { + isTimesliderOpen: boolean; + openTimeslider: () => void; + closeTimeslider: () => void; +} + +export function TimesliderToggleButton(props: Props) { + function onClick() { + if (props.isTimesliderOpen) { + props.closeTimeslider(); + } else { + props.openTimeslider(); + } + } + + const label = props.isTimesliderOpen + ? i18n.translate('xpack.maps.timesliderToggleButton.closeLabel', { + defaultMessage: 'Close timeslider', + }) + : i18n.translate('xpack.maps.timesliderToggleButton.openLabel', { + defaultMessage: 'Open timeslider', + }); + + return ( + + + + ); +} diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.test.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.test.tsx index d8ac971ae3983a..28b5ab9c78f401 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.test.tsx +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.test.tsx @@ -19,12 +19,14 @@ jest.mock('../../kibana_services', () => { import { ToolbarOverlay } from './toolbar_overlay'; -test('Must render zoom tools', async () => { - const component = shallow(); +test('Should only show set view control', async () => { + const component = shallow( + + ); expect(component).toMatchSnapshot(); }); -test('Must zoom tools and draw filter tools', async () => { +test('Should show all controls', async () => { const geoFieldWithIndex = { geoFieldName: 'myGeoFieldName', geoFieldType: 'geo_point', @@ -35,6 +37,8 @@ test('Must zoom tools and draw filter tools', async () => { {}} geoFields={[geoFieldWithIndex]} + showFitToBoundsButton={true} + showTimesliderButton={true} /> ); expect(component).toMatchSnapshot(); diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.tsx index c5208bc254fc89..41c6c1f7c4a7cd 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.tsx +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/toolbar_overlay.tsx @@ -12,6 +12,7 @@ import { ActionExecutionContext, Action } from 'src/plugins/ui_actions/public'; import { SetViewControl } from './set_view_control'; import { ToolsControl } from './tools_control'; import { FitToData } from './fit_to_data'; +import { TimesliderToggleButton } from './timeslider_toggle_button'; import { GeoFieldWithIndex } from '../../components/geo_field_with_index'; export interface Props { @@ -19,25 +20,33 @@ export interface Props { geoFields: GeoFieldWithIndex[]; getFilterActions?: () => Promise; getActionContext?: () => ActionExecutionContext; + showFitToBoundsButton: boolean; + showTimesliderButton: boolean; } export function ToolbarOverlay(props: Props) { - function renderToolsControl() { - const { addFilters, geoFields, getFilterActions, getActionContext } = props; - if (!addFilters || !geoFields.length) { - return null; - } - - return ( + const toolsButton = + props.addFilters && props.geoFields.length ? ( - ); - } + ) : null; + + const fitToBoundsButton = props.showFitToBoundsButton ? ( + + + + ) : null; + + const timesliderToogleButon = props.showTimesliderButton ? ( + + + + ) : null; return ( - - - + {fitToBoundsButton} + + {toolsButton} - {renderToolsControl()} + {timesliderToogleButon} ); } diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index 65fdbca3285420..f9eae1c90a1646 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -162,6 +162,11 @@ export class MapEmbeddable const store = this._savedMap.getStore(); store.dispatch(setReadOnly(true)); store.dispatch(disableScrollZoom()); + store.dispatch( + setMapSettings({ + showTimesliderToggleButton: false, + }) + ); this._dispatchSetQuery({ forceRefresh: false, diff --git a/x-pack/plugins/maps/public/reducers/map/default_map_settings.ts b/x-pack/plugins/maps/public/reducers/map/default_map_settings.ts index 8ecaa8dfc2bf5e..c73bf46d4bc0c9 100644 --- a/x-pack/plugins/maps/public/reducers/map/default_map_settings.ts +++ b/x-pack/plugins/maps/public/reducers/map/default_map_settings.ts @@ -25,6 +25,7 @@ export function getDefaultMapSettings(): MapSettings { minZoom: MIN_ZOOM, showScaleControl: false, showSpatialFilters: true, + showTimesliderToggleButton: true, spatialFiltersAlpa: 0.3, spatialFiltersFillColor: '#DA8B45', spatialFiltersLineColor: '#DA8B45', diff --git a/x-pack/plugins/maps/public/reducers/map/map.ts b/x-pack/plugins/maps/public/reducers/map/map.ts index daeba8e9982b07..591215efcddae5 100644 --- a/x-pack/plugins/maps/public/reducers/map/map.ts +++ b/x-pack/plugins/maps/public/reducers/map/map.ts @@ -73,6 +73,7 @@ export const DEFAULT_MAP_STATE: MapState = { extent: undefined, mouseCoordinates: undefined, timeFilters: undefined, + timeslice: undefined, query: undefined, filters: [], refreshConfig: undefined, @@ -218,13 +219,21 @@ export function map(state: MapState = DEFAULT_MAP_STATE, action: any) { }; return { ...state, mapState: { ...state.mapState, ...newMapState } }; case SET_QUERY: - const { query, timeFilters, filters, searchSessionId, searchSessionMapBuffer } = action; + const { + query, + timeFilters, + timeslice, + filters, + searchSessionId, + searchSessionMapBuffer, + } = action; return { ...state, mapState: { ...state.mapState, query, timeFilters, + timeslice, filters, searchSessionId, searchSessionMapBuffer, diff --git a/x-pack/plugins/maps/public/reducers/map/types.ts b/x-pack/plugins/maps/public/reducers/map/types.ts index 6b10b4a66fb611..e8dd7261e3dd34 100644 --- a/x-pack/plugins/maps/public/reducers/map/types.ts +++ b/x-pack/plugins/maps/public/reducers/map/types.ts @@ -15,6 +15,7 @@ import { MapExtent, MapQuery, MapRefreshConfig, + Timeslice, TooltipState, } from '../../../common/descriptor_types'; import { INITIAL_LOCATION } from '../../../common/constants'; @@ -31,6 +32,7 @@ export type MapContext = { lon: number; }; timeFilters?: TimeRange; + timeslice?: Timeslice; query?: MapQuery; filters: Filter[]; refreshConfig?: MapRefreshConfig; @@ -61,6 +63,7 @@ export type MapSettings = { minZoom: number; showScaleControl: boolean; showSpatialFilters: boolean; + showTimesliderToggleButton: boolean; spatialFiltersAlpa: number; spatialFiltersFillColor: string; spatialFiltersLineColor: string; diff --git a/x-pack/plugins/maps/public/reducers/ui.ts b/x-pack/plugins/maps/public/reducers/ui.ts index 676ac6ce12efeb..9f948ce3d64739 100644 --- a/x-pack/plugins/maps/public/reducers/ui.ts +++ b/x-pack/plugins/maps/public/reducers/ui.ts @@ -12,6 +12,7 @@ import { getMapsCapabilities } from '../kibana_services'; import { UPDATE_FLYOUT, SET_IS_LAYER_TOC_OPEN, + SET_IS_TIME_SLIDER_OPEN, SET_FULL_SCREEN, SET_READ_ONLY, SET_OPEN_TOC_DETAILS, @@ -31,6 +32,7 @@ export type MapUiState = { isFullScreen: boolean; isReadOnly: boolean; isLayerTOCOpen: boolean; + isTimesliderOpen: boolean; openTOCDetails: string[]; }; @@ -41,6 +43,7 @@ export const DEFAULT_MAP_UI_STATE = { isFullScreen: false, isReadOnly: !getMapsCapabilities().save, isLayerTOCOpen: DEFAULT_IS_LAYER_TOC_OPEN, + isTimesliderOpen: false, // storing TOC detail visibility outside of map.layerList because its UI state and not map rendering state. // This also makes for easy read/write access for embeddables. openTOCDetails: [], @@ -53,6 +56,8 @@ export function ui(state: MapUiState = DEFAULT_MAP_UI_STATE, action: any) { return { ...state, flyoutDisplay: action.display }; case SET_IS_LAYER_TOC_OPEN: return { ...state, isLayerTOCOpen: action.isLayerTOCOpen }; + case SET_IS_TIME_SLIDER_OPEN: + return { ...state, isTimesliderOpen: action.isTimesliderOpen }; case SET_FULL_SCREEN: return { ...state, isFullScreen: action.isFullScreen }; case SET_READ_ONLY: diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.test.ts b/x-pack/plugins/maps/public/selectors/map_selectors.test.ts index a7374650d04511..cf739957a39937 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.test.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.test.ts @@ -57,6 +57,7 @@ describe('getDataFilters', () => { }; const mapZoom = 4; const timeFilters = { to: '2001-01-01', from: '2001-12-31' }; + const timeslice = undefined; const refreshTimerLastTriggeredAt = '2001-01-01T00:00:00'; const query = undefined; const filters: Filter[] = []; @@ -74,6 +75,7 @@ describe('getDataFilters', () => { mapBuffer, mapZoom, timeFilters, + timeslice, refreshTimerLastTriggeredAt, query, filters, @@ -89,6 +91,7 @@ describe('getDataFilters', () => { mapBuffer, mapZoom, timeFilters, + timeslice, refreshTimerLastTriggeredAt, query, filters, diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.ts b/x-pack/plugins/maps/public/selectors/map_selectors.ts index 4f3bfbe303cb95..5be14737cff15c 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.ts @@ -176,6 +176,8 @@ export const getMouseCoordinates = ({ map }: MapStoreState) => map.mapState.mous export const getTimeFilters = ({ map }: MapStoreState): TimeRange => map.mapState.timeFilters ? map.mapState.timeFilters : getTimeFilter().getTime(); +export const getTimeslice = ({ map }: MapStoreState) => map.mapState.timeslice; + export const getQuery = ({ map }: MapStoreState): MapQuery | undefined => map.mapState.query; export const getFilters = ({ map }: MapStoreState): Filter[] => map.mapState.filters; @@ -234,6 +236,7 @@ export const getDataFilters = createSelector( getMapBuffer, getMapZoom, getTimeFilters, + getTimeslice, getRefreshTimerLastTriggeredAt, getQuery, getFilters, @@ -244,6 +247,7 @@ export const getDataFilters = createSelector( mapBuffer, mapZoom, timeFilters, + timeslice, refreshTimerLastTriggeredAt, query, filters, @@ -255,6 +259,7 @@ export const getDataFilters = createSelector( buffer: searchSessionId && searchSessionMapBuffer ? searchSessionMapBuffer : mapBuffer, zoom: mapZoom, timeFilters, + timeslice, refreshTimerLastTriggeredAt, query, filters, diff --git a/x-pack/plugins/maps/public/selectors/ui_selectors.ts b/x-pack/plugins/maps/public/selectors/ui_selectors.ts index e5c83bd0f8f4ab..c9ec8bac6d83ae 100644 --- a/x-pack/plugins/maps/public/selectors/ui_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/ui_selectors.ts @@ -11,6 +11,7 @@ import { FLYOUT_STATE } from '../reducers/ui'; export const getFlyoutDisplay = ({ ui }: MapStoreState): FLYOUT_STATE => ui.flyoutDisplay; export const getIsLayerTOCOpen = ({ ui }: MapStoreState): boolean => ui.isLayerTOCOpen; +export const getIsTimesliderOpen = ({ ui }: MapStoreState): boolean => ui.isTimesliderOpen; export const getOpenTOCDetails = ({ ui }: MapStoreState): string[] => ui.openTOCDetails; export const getIsFullScreen = ({ ui }: MapStoreState): boolean => ui.isFullScreen; export const getIsReadOnly = ({ ui }: MapStoreState): boolean => ui.isReadOnly; diff --git a/x-pack/plugins/ml/common/types/capabilities.ts b/x-pack/plugins/ml/common/types/capabilities.ts index 1e6a76caf70e9a..3545a85305c178 100644 --- a/x-pack/plugins/ml/common/types/capabilities.ts +++ b/x-pack/plugins/ml/common/types/capabilities.ts @@ -117,8 +117,12 @@ export function getPluginPrivileges() { read: savedObjects, }, alerting: { - all: Object.values(ML_ALERT_TYPES), - read: [], + rule: { + all: Object.values(ML_ALERT_TYPES), + }, + alert: { + all: Object.values(ML_ALERT_TYPES), + }, }, }, user: { @@ -132,8 +136,12 @@ export function getPluginPrivileges() { read: savedObjects, }, alerting: { - all: [], - read: Object.values(ML_ALERT_TYPES), + rule: { + read: Object.values(ML_ALERT_TYPES), + }, + alert: { + read: Object.values(ML_ALERT_TYPES), + }, }, }, apmUser: { diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/ml/datafeed_windows_rare_user_type10_remote_login.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/ml/datafeed_windows_rare_user_type10_remote_login.json index 719adf68207b09..a66f0a7c2607fe 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/ml/datafeed_windows_rare_user_type10_remote_login.json +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_winlogbeat_auth/ml/datafeed_windows_rare_user_type10_remote_login.json @@ -7,9 +7,35 @@ "query": { "bool": { "filter": [ - {"term": {"event.type": "authentication_success"}}, - {"term": {"winlog.event_data.LogonType": "10"}}, - {"term": {"agent.type": "winlogbeat"}} + { + "term": { + "winlog.event_data.LogonType": "10" + } + } + ], + "must": [ + { + "bool": { + "should": [ + { + "match": { + "event.type": { + "query": "authentication_success", + "operator": "OR" + } + } + }, + { + "match": { + "event.action": { + "query": "logged-in", + "operator": "OR" + } + } + } + ] + } + } ] } } diff --git a/x-pack/plugins/monitoring/server/plugin.ts b/x-pack/plugins/monitoring/server/plugin.ts index 56c654963d340e..10724594ce576d 100644 --- a/x-pack/plugins/monitoring/server/plugin.ts +++ b/x-pack/plugins/monitoring/server/plugin.ts @@ -262,7 +262,12 @@ export class MonitoringPlugin read: [], }, alerting: { - all: ALERTS, + rule: { + all: ALERTS, + }, + alert: { + all: ALERTS, + }, }, ui: [], }, diff --git a/x-pack/plugins/observability/README.md b/x-pack/plugins/observability/README.md index 8d87bacc431e02..f0f66f01def53f 100644 --- a/x-pack/plugins/observability/README.md +++ b/x-pack/plugins/observability/README.md @@ -34,7 +34,7 @@ When both of the these are set to `true`, your alerts should show on the alerts ## Shared navigation -The Observability plugin maintains a navigation registry for Observability solutions, and exposes a shared page template component. Please refer to the docs in [the component directory](./components/shared/page_template/README.md) for more information on registering your solution's navigation structure, and rendering the navigation via the shared component. +The Observability plugin maintains a navigation registry for Observability solutions, and exposes a shared page template component. Please refer to the docs in [the component directory](public/components/shared/page_template) for more information on registering your solution's navigation structure, and rendering the navigation via the shared component. ## Unit testing diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts index e06e40b86e01be..861f6900fda589 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts @@ -20,8 +20,14 @@ describe(`feature_privilege_builder`, () => { const privilege: FeatureKibanaPrivileges = { alerting: { - all: [], - read: [], + rule: { + all: [], + read: [], + }, + alert: { + all: [], + read: [], + }, }, savedObject: { @@ -46,14 +52,16 @@ describe(`feature_privilege_builder`, () => { }); describe(`within feature`, () => { - test('grants `read` privileges under feature consumer', () => { + test('grants `read` privileges to rules under feature consumer', () => { const actions = new Actions(version); const alertingFeaturePrivileges = new FeaturePrivilegeAlertingBuilder(actions); const privilege: FeatureKibanaPrivileges = { alerting: { - all: [], - read: ['alert-type'], + rule: { + all: [], + read: ['alert-type'], + }, }, savedObject: { @@ -80,20 +88,20 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", - "alerting:1.0.0-zeta1:alert-type/my-feature/alert/get", - "alerting:1.0.0-zeta1:alert-type/my-feature/alert/find", ] `); }); - test('grants `all` privileges under feature consumer', () => { + test('grants `read` privileges to alerts under feature consumer', () => { const actions = new Actions(version); const alertingFeaturePrivileges = new FeaturePrivilegeAlertingBuilder(actions); const privilege: FeatureKibanaPrivileges = { alerting: { - all: ['alert-type'], - read: [], + alert: { + all: [], + read: ['alert-type'], + }, }, savedObject: { @@ -116,35 +124,26 @@ describe(`feature_privilege_builder`, () => { expect(alertingFeaturePrivileges.getActions(privilege, feature)).toMatchInlineSnapshot(` Array [ - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/update", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/updateApiKey", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/enable", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/disable", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAll", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAll", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/alert/get", "alerting:1.0.0-zeta1:alert-type/my-feature/alert/find", - "alerting:1.0.0-zeta1:alert-type/my-feature/alert/update", ] `); }); - test('grants both `all` and `read` privileges under feature consumer', () => { + test('grants `read` privileges to rules and alerts under feature consumer', () => { const actions = new Actions(version); const alertingFeaturePrivileges = new FeaturePrivilegeAlertingBuilder(actions); const privilege: FeatureKibanaPrivileges = { alerting: { - all: ['alert-type'], - read: ['readonly-alert-type'], + rule: { + all: [], + read: ['alert-type'], + }, + alert: { + all: [], + read: ['alert-type'], + }, }, savedObject: { @@ -171,28 +170,315 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/update", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/updateApiKey", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/enable", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/disable", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAll", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAll", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", - "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/alert/get", "alerting:1.0.0-zeta1:alert-type/my-feature/alert/find", - "alerting:1.0.0-zeta1:alert-type/my-feature/alert/update", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/find", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/alert/get", - "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/alert/find", ] `); }); + + test('grants `all` privileges to rules under feature consumer', () => { + const actions = new Actions(version); + const alertingFeaturePrivileges = new FeaturePrivilegeAlertingBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + alerting: { + rule: { + all: ['alert-type'], + read: [], + }, + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + const feature = new KibanaFeature({ + id: 'my-feature', + name: 'my-feature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: privilege, + read: privilege, + }, + }); + + expect(alertingFeaturePrivileges.getActions(privilege, feature)).toMatchInlineSnapshot(` + Array [ + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/update", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/updateApiKey", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/enable", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/disable", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", + ] + `); + }); + + test('grants `all` privileges to alerts under feature consumer', () => { + const actions = new Actions(version); + const alertingFeaturePrivileges = new FeaturePrivilegeAlertingBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + alerting: { + alert: { + all: ['alert-type'], + read: [], + }, + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + const feature = new KibanaFeature({ + id: 'my-feature', + name: 'my-feature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: privilege, + read: privilege, + }, + }); + + expect(alertingFeaturePrivileges.getActions(privilege, feature)).toMatchInlineSnapshot(` + Array [ + "alerting:1.0.0-zeta1:alert-type/my-feature/alert/get", + "alerting:1.0.0-zeta1:alert-type/my-feature/alert/find", + "alerting:1.0.0-zeta1:alert-type/my-feature/alert/update", + ] + `); + }); + + test('grants `all` privileges to rules and alerts under feature consumer', () => { + const actions = new Actions(version); + const alertingFeaturePrivileges = new FeaturePrivilegeAlertingBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + alerting: { + rule: { + all: ['alert-type'], + read: [], + }, + alert: { + all: ['alert-type'], + read: [], + }, + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + const feature = new KibanaFeature({ + id: 'my-feature', + name: 'my-feature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: privilege, + read: privilege, + }, + }); + + expect(alertingFeaturePrivileges.getActions(privilege, feature)).toMatchInlineSnapshot(` + Array [ + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/update", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/updateApiKey", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/enable", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/disable", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", + "alerting:1.0.0-zeta1:alert-type/my-feature/alert/get", + "alerting:1.0.0-zeta1:alert-type/my-feature/alert/find", + "alerting:1.0.0-zeta1:alert-type/my-feature/alert/update", + ] + `); + }); + + test('grants both `all` and `read` to rules privileges under feature consumer', () => { + const actions = new Actions(version); + const alertingFeaturePrivileges = new FeaturePrivilegeAlertingBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + alerting: { + rule: { + all: ['alert-type'], + read: ['readonly-alert-type'], + }, + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + const feature = new KibanaFeature({ + id: 'my-feature', + name: 'my-feature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: privilege, + read: privilege, + }, + }); + + expect(alertingFeaturePrivileges.getActions(privilege, feature)).toMatchInlineSnapshot(` + Array [ + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/update", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/updateApiKey", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/enable", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/disable", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/find", + ] + `); + }); + + test('grants both `all` and `read` to alerts privileges under feature consumer', () => { + const actions = new Actions(version); + const alertingFeaturePrivileges = new FeaturePrivilegeAlertingBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + alerting: { + alert: { + all: ['alert-type'], + read: ['readonly-alert-type'], + }, + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + const feature = new KibanaFeature({ + id: 'my-feature', + name: 'my-feature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: privilege, + read: privilege, + }, + }); + + expect(alertingFeaturePrivileges.getActions(privilege, feature)).toMatchInlineSnapshot(` + Array [ + "alerting:1.0.0-zeta1:alert-type/my-feature/alert/get", + "alerting:1.0.0-zeta1:alert-type/my-feature/alert/find", + "alerting:1.0.0-zeta1:alert-type/my-feature/alert/update", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/alert/get", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/alert/find", + ] + `); + }); + + test('grants both `all` and `read` to rules and alerts privileges under feature consumer', () => { + const actions = new Actions(version); + const alertingFeaturePrivileges = new FeaturePrivilegeAlertingBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + alerting: { + rule: { + all: ['alert-type'], + read: ['readonly-alert-type'], + }, + alert: { + all: ['another-alert-type'], + read: ['readonly-alert-type'], + }, + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + const feature = new KibanaFeature({ + id: 'my-feature', + name: 'my-feature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: privilege, + read: privilege, + }, + }); + + expect(alertingFeaturePrivileges.getActions(privilege, feature)).toMatchInlineSnapshot(` + Array [ + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/update", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/updateApiKey", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/enable", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/disable", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/find", + "alerting:1.0.0-zeta1:another-alert-type/my-feature/alert/get", + "alerting:1.0.0-zeta1:another-alert-type/my-feature/alert/find", + "alerting:1.0.0-zeta1:another-alert-type/my-feature/alert/update", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/alert/get", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/alert/find", + ] + `); + }); }); }); }); diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts index 1d0a2b0e129434..f536959a910cd1 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts @@ -5,22 +5,22 @@ * 2.0. */ -import { uniq } from 'lodash'; +import { get, uniq } from 'lodash'; import type { FeatureKibanaPrivileges, KibanaFeature } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; -enum AlertingType { +enum AlertingEntity { RULE = 'rule', ALERT = 'alert', } -const readOperations: Record = { +const readOperations: Record = { rule: ['get', 'getRuleState', 'getAlertSummary', 'find'], alert: ['get', 'find'], }; -const writeOperations: Record = { +const writeOperations: Record = { rule: [ 'create', 'delete', @@ -35,7 +35,7 @@ const writeOperations: Record = { ], alert: ['update'], }; -const allOperations: Record = { +const allOperations: Record = { rule: [...readOperations.rule, ...writeOperations.rule], alert: [...readOperations.alert, ...writeOperations.alert], }; @@ -46,21 +46,30 @@ export class FeaturePrivilegeAlertingBuilder extends BaseFeaturePrivilegeBuilder feature: KibanaFeature ): string[] { const getAlertingPrivilege = ( - operations: Record, + operations: string[], privilegedTypes: readonly string[], + alertingEntity: string, consumer: string ) => - privilegedTypes.flatMap((privilegedType) => - Object.values(AlertingType).flatMap((alertingType) => - operations[alertingType].map((operation) => - this.actions.alerting.get(privilegedType, consumer, alertingType, operation) - ) + privilegedTypes.flatMap((type) => + operations.map((operation) => + this.actions.alerting.get(type, consumer, alertingEntity, operation) ) ); + const getPrivilegesForEntity = (entity: AlertingEntity) => { + const all = get(privilegeDefinition.alerting, `${entity}.all`) ?? []; + const read = get(privilegeDefinition.alerting, `${entity}.read`) ?? []; + + return uniq([ + ...getAlertingPrivilege(allOperations[entity], all, entity, feature.id), + ...getAlertingPrivilege(readOperations[entity], read, entity, feature.id), + ]); + }; + return uniq([ - ...getAlertingPrivilege(allOperations, privilegeDefinition.alerting?.all ?? [], feature.id), - ...getAlertingPrivilege(readOperations, privilegeDefinition.alerting?.read ?? [], feature.id), + ...getPrivilegesForEntity(AlertingEntity.RULE), + ...getPrivilegesForEntity(AlertingEntity.ALERT), ]); } } diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts index d1b107b5396dd5..c0888a6c2a4bd4 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/base_data_generator.ts @@ -9,8 +9,6 @@ import seedrandom from 'seedrandom'; import uuid from 'uuid'; const OS_FAMILY = ['windows', 'macos', 'linux']; -/** Array of 14 day offsets */ -const DAY_OFFSETS = Array.from({ length: 14 }, (_, i) => 8.64e7 * (i + 1)); /** * A generic base class to assist in creating domain specific data generators. It includes @@ -35,23 +33,6 @@ export class BaseDataGenerator { throw new Error('method not implemented!'); } - /** Returns a future ISO date string */ - protected randomFutureDate(from?: Date): string { - const now = from ? from.getTime() : Date.now(); - return new Date(now + this.randomChoice(DAY_OFFSETS)).toISOString(); - } - - /** Returns a past ISO date string */ - protected randomPastDate(from?: Date): string { - const now = from ? from.getTime() : Date.now(); - return new Date(now - this.randomChoice(DAY_OFFSETS)).toISOString(); - } - - /** Generate either `true` or `false` */ - protected randomBoolean(): boolean { - return Math.random() < 0.5; - } - /** generate random OS family value */ protected randomOSFamily(): string { return this.randomChoice(OS_FAMILY); diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_action_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_action_generator.ts deleted file mode 100644 index af799de782f48c..00000000000000 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/fleet_action_generator.ts +++ /dev/null @@ -1,62 +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 { DeepPartial } from 'utility-types'; -import { merge } from 'lodash'; -import { BaseDataGenerator } from './base_data_generator'; -import { EndpointAction, EndpointActionResponse, ISOLATION_ACTIONS } from '../types'; - -const ISOLATION_COMMANDS: ISOLATION_ACTIONS[] = ['isolate', 'unisolate']; - -export class FleetActionGenerator extends BaseDataGenerator { - /** Generate an Action */ - generate(overrides: DeepPartial = {}): EndpointAction { - const timeStamp = new Date(this.randomPastDate()); - - return merge( - { - action_id: this.randomUUID(), - '@timestamp': timeStamp.toISOString(), - expiration: this.randomFutureDate(timeStamp), - type: 'INPUT_ACTION', - input_type: 'endpoint', - agents: [this.randomUUID()], - user_id: 'elastic', - data: { - command: this.randomIsolateCommand(), - comment: this.randomString(15), - }, - }, - overrides - ); - } - - /** Generates an action response */ - generateResponse(overrides: DeepPartial = {}): EndpointActionResponse { - const timeStamp = new Date(); - - return merge( - { - action_data: { - command: this.randomIsolateCommand(), - comment: '', - }, - action_id: this.randomUUID(), - agent_id: this.randomUUID(), - started_at: this.randomPastDate(), - completed_at: timeStamp.toISOString(), - error: 'some error happen', - '@timestamp': timeStamp.toISOString(), - }, - overrides - ); - } - - protected randomIsolateCommand() { - return this.randomChoice(ISOLATION_COMMANDS); - } -} diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 436f1573639c83..fa7ee84441a9bc 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -439,8 +439,6 @@ export class EndpointDocGenerator extends BaseDataGenerator { private createHostData(): HostInfo { const hostName = this.randomHostname(); - const isIsolated = this.randomBoolean(); - return { agent: { version: this.randomVersion(), @@ -467,10 +465,10 @@ export class EndpointDocGenerator extends BaseDataGenerator { applied: this.randomChoice(APPLIED_POLICIES), }, configuration: { - isolation: isIsolated, + isolation: false, }, state: { - isolation: isIsolated, + isolation: false, }, }, }; diff --git a/x-pack/plugins/security_solution/common/endpoint/index_data.ts b/x-pack/plugins/security_solution/common/endpoint/index_data.ts index 021b9bcb1ecccf..0dc7891560c2d8 100644 --- a/x-pack/plugins/security_solution/common/endpoint/index_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/index_data.ts @@ -28,10 +28,8 @@ import { policyFactory as policyConfigFactory } from './models/policy_config'; import { HostMetadata } from './types'; import { KbnClientWithApiKeySupport } from '../../scripts/endpoint/kbn_client_with_api_key_support'; import { FleetAgentGenerator } from './data_generators/fleet_agent_generator'; -import { FleetActionGenerator } from './data_generators/fleet_action_generator'; const fleetAgentGenerator = new FleetAgentGenerator(); -const fleetActionGenerator = new FleetActionGenerator(); export async function indexHostsAndAlerts( client: Client, @@ -177,9 +175,6 @@ async function indexHostDocs({ }, }, }; - - // Create some actions for this Host - await indexFleetActionsForHost(client, hostMetadata); } await client.index({ @@ -402,43 +397,3 @@ const indexFleetAgentForHost = async ( return agentDoc; }; - -const indexFleetActionsForHost = async ( - esClient: Client, - endpointHost: HostMetadata -): Promise => { - const ES_INDEX_OPTIONS = { headers: { 'X-elastic-product-origin': 'fleet' } }; - const agentId = endpointHost.elastic.agent.id; - - for (let i = 0; i < 5; i++) { - // create an action - const isolateAction = fleetActionGenerator.generate({ - data: { comment: 'data generator: this host is bad' }, - }); - - isolateAction.agents = [agentId]; - - await esClient.index( - { - index: '.fleet-actions', - body: isolateAction, - }, - ES_INDEX_OPTIONS - ); - - // Create an action response for the above - const unIsolateAction = fleetActionGenerator.generateResponse({ - action_id: isolateAction.action_id, - agent_id: agentId, - action_data: isolateAction.data, - }); - - await esClient.index( - { - index: '.fleet-actions-results', - body: unIsolateAction, - }, - ES_INDEX_OPTIONS - ); - } -}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index fcfda9c9a30d94..99dac5ea5cda67 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -24,21 +24,6 @@ export interface EndpointAction { }; } -export interface EndpointActionResponse { - '@timestamp': string; - /** The id of the action for which this response is associated with */ - action_id: string; - /** The agent id that sent this action response */ - agent_id: string; - started_at: string; - completed_at: string; - error: string; - action_data: { - command: ISOLATION_ACTIONS; - comment?: string; - }; -} - export type HostIsolationRequestBody = TypeOf; export interface HostIsolationResponse { diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 72db0be6ce278f..aa37a0dc1f6270 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -239,7 +239,12 @@ export class Plugin implements IPlugin void; export interface UsageData { - detections: DetectionsUsage; endpoints: EndpointUsage | {}; detectionMetrics: {}; } @@ -40,55 +34,10 @@ export const registerCollector: RegisterCollector = ({ if (!usageCollection) { return; } + const collector = usageCollection.makeUsageCollector({ type: 'security_solution', schema: { - detections: { - detection_rules: { - custom: { - enabled: { - type: 'long', - _meta: { description: 'The number of custom detection rules enabled' }, - }, - disabled: { - type: 'long', - _meta: { description: 'The number of custom detection rules disabled' }, - }, - }, - elastic: { - enabled: { - type: 'long', - _meta: { description: 'The number of elastic prebuilt detection rules enabled' }, - }, - disabled: { - type: 'long', - _meta: { description: 'The number of elastic prebuilt detection rules disabled' }, - }, - }, - }, - ml_jobs: { - custom: { - enabled: { - type: 'long', - _meta: { description: 'The number of custom ML jobs rules enabled' }, - }, - disabled: { - type: 'long', - _meta: { description: 'The number of custom ML jobs rules disabled' }, - }, - }, - elastic: { - enabled: { - type: 'long', - _meta: { description: 'The number of elastic provided ML jobs rules enabled' }, - }, - disabled: { - type: 'long', - _meta: { description: 'The number of elastic provided ML jobs rules disabled' }, - }, - }, - }, - }, detectionMetrics: { detection_rules: { detection_rule_usage: { @@ -248,172 +197,199 @@ export const registerCollector: RegisterCollector = ({ }, }, ml_jobs: { - type: 'array', - items: { - job_id: { - type: 'keyword', - _meta: { description: 'Identifier for the anomaly detection job' }, - }, - open_time: { - type: 'keyword', - _meta: { - description: 'For open jobs only, the elapsed time for which the job has been open', - }, - }, - create_time: { - type: 'keyword', - _meta: { description: 'The time the job was created' }, - }, - finished_time: { - type: 'keyword', - _meta: { - description: 'If the job closed or failed, this is the time the job finished', - }, - }, - state: { - type: 'keyword', - _meta: { description: 'The status of the anomaly detection job' }, - }, - data_counts: { - bucket_count: { + ml_job_usage: { + custom: { + enabled: { type: 'long', - _meta: { description: 'The number of buckets processed' }, + _meta: { description: 'The number of custom ML jobs rules enabled' }, }, - empty_bucket_count: { + disabled: { type: 'long', - _meta: { description: 'The number of buckets which did not contain any data' }, + _meta: { description: 'The number of custom ML jobs rules disabled' }, }, - input_bytes: { + }, + elastic: { + enabled: { type: 'long', - _meta: { - description: - 'The number of bytes of input data posted to the anomaly detection job', - }, + _meta: { description: 'The number of elastic provided ML jobs rules enabled' }, }, - input_record_count: { + disabled: { type: 'long', - _meta: { - description: 'The number of input documents posted to the anomaly detection job', - }, + _meta: { description: 'The number of elastic provided ML jobs rules disabled' }, }, - last_data_time: { - type: 'long', - _meta: { - description: - 'The timestamp at which data was last analyzed, according to server time', - }, + }, + }, + ml_job_metrics: { + type: 'array', + items: { + job_id: { + type: 'keyword', + _meta: { description: 'Identifier for the anomaly detection job' }, }, - processed_record_count: { - type: 'long', + open_time: { + type: 'keyword', _meta: { description: - 'The number of input documents that have been processed by the anomaly detection job', + 'For open jobs only, the elapsed time for which the job has been open', }, }, - }, - model_size_stats: { - bucket_allocation_failures_count: { - type: 'long', + create_time: { + type: 'keyword', + _meta: { description: 'The time the job was created' }, + }, + finished_time: { + type: 'keyword', _meta: { - description: - 'The number of buckets for which new entities in incoming data were not processed due to insufficient model memory', + description: 'If the job closed or failed, this is the time the job finished', }, }, - model_bytes: { - type: 'long', - _meta: { description: 'The number of bytes of memory used by the models' }, + state: { + type: 'keyword', + _meta: { description: 'The status of the anomaly detection job' }, }, - model_bytes_exceeded: { - type: 'long', - _meta: { - description: - 'The number of bytes over the high limit for memory usage at the last allocation failure', + data_counts: { + bucket_count: { + type: 'long', + _meta: { description: 'The number of buckets processed' }, }, - }, - model_bytes_memory_limit: { - type: 'long', - _meta: { - description: - 'The upper limit for model memory usage, checked on increasing values', + empty_bucket_count: { + type: 'long', + _meta: { description: 'The number of buckets which did not contain any data' }, }, - }, - peak_model_bytes: { - type: 'long', - _meta: { - description: 'The peak number of bytes of memory ever used by the models', + input_bytes: { + type: 'long', + _meta: { + description: + 'The number of bytes of input data posted to the anomaly detection job', + }, }, - }, - }, - timing_stats: { - bucket_count: { - type: 'long', - _meta: { description: 'The number of buckets processed' }, - }, - exponential_average_bucket_processing_time_ms: { - type: 'long', - _meta: { - description: - 'Exponential moving average of all bucket processing times, in milliseconds', + input_record_count: { + type: 'long', + _meta: { + description: + 'The number of input documents posted to the anomaly detection job', + }, }, - }, - exponential_average_bucket_processing_time_per_hour_ms: { - type: 'long', - _meta: { - description: - 'Exponentially-weighted moving average of bucket processing times calculated in a 1 hour time window, in milliseconds', + last_data_time: { + type: 'long', + _meta: { + description: + 'The timestamp at which data was last analyzed, according to server time', + }, }, - }, - maximum_bucket_processing_time_ms: { - type: 'long', - _meta: { - description: 'Maximum among all bucket processing times, in milliseconds', + processed_record_count: { + type: 'long', + _meta: { + description: + 'The number of input documents that have been processed by the anomaly detection job', + }, }, }, - minimum_bucket_processing_time_ms: { - type: 'long', - _meta: { - description: 'Minimum among all bucket processing times, in milliseconds', + model_size_stats: { + bucket_allocation_failures_count: { + type: 'long', + _meta: { + description: + 'The number of buckets for which new entities in incoming data were not processed due to insufficient model memory', + }, }, - }, - total_bucket_processing_time_ms: { - type: 'long', - _meta: { description: 'Sum of all bucket processing times, in milliseconds' }, - }, - }, - datafeed: { - datafeed_id: { - type: 'keyword', - _meta: { - description: 'A numerical character string that uniquely identifies the datafeed', + model_bytes: { + type: 'long', + _meta: { description: 'The number of bytes of memory used by the models' }, }, - }, - state: { - type: 'keyword', - _meta: { description: 'The status of the datafeed' }, - }, - timing_stats: { - average_search_time_per_bucket_ms: { + model_bytes_exceeded: { + type: 'long', + _meta: { + description: + 'The number of bytes over the high limit for memory usage at the last allocation failure', + }, + }, + model_bytes_memory_limit: { + type: 'long', + _meta: { + description: + 'The upper limit for model memory usage, checked on increasing values', + }, + }, + peak_model_bytes: { type: 'long', - _meta: { description: 'The average search time per bucket, in milliseconds' }, + _meta: { + description: 'The peak number of bytes of memory ever used by the models', + }, }, + }, + timing_stats: { bucket_count: { type: 'long', _meta: { description: 'The number of buckets processed' }, }, - exponential_average_search_time_per_hour_ms: { + exponential_average_bucket_processing_time_ms: { + type: 'long', + _meta: { + description: + 'Exponential moving average of all bucket processing times, in milliseconds', + }, + }, + exponential_average_bucket_processing_time_per_hour_ms: { type: 'long', _meta: { - description: 'The exponential average search time per hour, in milliseconds', + description: + 'Exponentially-weighted moving average of bucket processing times calculated in a 1 hour time window, in milliseconds', }, }, - search_count: { + maximum_bucket_processing_time_ms: { type: 'long', - _meta: { description: 'The number of searches run by the datafeed' }, + _meta: { + description: 'Maximum among all bucket processing times, in milliseconds', + }, + }, + minimum_bucket_processing_time_ms: { + type: 'long', + _meta: { + description: 'Minimum among all bucket processing times, in milliseconds', + }, }, - total_search_time_ms: { + total_bucket_processing_time_ms: { type: 'long', + _meta: { description: 'Sum of all bucket processing times, in milliseconds' }, + }, + }, + datafeed: { + datafeed_id: { + type: 'keyword', _meta: { - description: 'The total time the datafeed spent searching, in milliseconds', + description: + 'A numerical character string that uniquely identifies the datafeed', + }, + }, + state: { + type: 'keyword', + _meta: { description: 'The status of the datafeed' }, + }, + timing_stats: { + average_search_time_per_bucket_ms: { + type: 'long', + _meta: { description: 'The average search time per bucket, in milliseconds' }, + }, + bucket_count: { + type: 'long', + _meta: { description: 'The number of buckets processed' }, + }, + exponential_average_search_time_per_hour_ms: { + type: 'long', + _meta: { + description: 'The exponential average search time per hour, in milliseconds', + }, + }, + search_count: { + type: 'long', + _meta: { description: 'The number of searches run by the datafeed' }, + }, + total_search_time_ms: { + type: 'long', + _meta: { + description: 'The total time the datafeed spent searching, in milliseconds', + }, }, }, }, @@ -476,14 +452,12 @@ export const registerCollector: RegisterCollector = ({ fetch: async ({ esClient }: CollectorFetchContext): Promise => { const internalSavedObjectsClient = await getInternalSavedObjectsClient(core); const savedObjectsClient = (internalSavedObjectsClient as unknown) as SavedObjectsClientContract; - const [detections, detectionMetrics, endpoints] = await Promise.allSettled([ - fetchDetectionsUsage(kibanaIndex, esClient, ml, savedObjectsClient), + const [detectionMetrics, endpoints] = await Promise.allSettled([ fetchDetectionsMetrics(kibanaIndex, signalsIndex, esClient, ml, savedObjectsClient), getEndpointTelemetryFromFleet(savedObjectsClient, endpointAppContext, esClient), ]); return { - detections: detections.status === 'fulfilled' ? detections.value : defaultDetectionsUsage, detectionMetrics: detectionMetrics.status === 'fulfilled' ? detectionMetrics.value : {}, endpoints: endpoints.status === 'fulfilled' ? endpoints.value : {}, }; diff --git a/x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.test.ts b/x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.test.ts new file mode 100644 index 00000000000000..3ca0faeca7d361 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.test.ts @@ -0,0 +1,74 @@ +/* + * 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 { initialMlJobsUsage, updateMlJobsUsage } from './detection_ml_helpers'; + +describe('Security Machine Learning usage metrics', () => { + describe('Updates metrics with job information', () => { + it('Should update ML total for elastic rules', async () => { + const initialUsage = initialMlJobsUsage; + const isElastic = true; + const isEnabled = true; + + const updatedUsage = updateMlJobsUsage({ isElastic, isEnabled }, initialUsage); + + expect(updatedUsage).toEqual( + expect.objectContaining({ + custom: { + disabled: 0, + enabled: 0, + }, + elastic: { + disabled: 0, + enabled: 1, + }, + }) + ); + }); + + it('Should update ML total for custom rules', async () => { + const initialUsage = initialMlJobsUsage; + const isElastic = false; + const isEnabled = true; + + const updatedUsage = updateMlJobsUsage({ isElastic, isEnabled }, initialUsage); + + expect(updatedUsage).toEqual( + expect.objectContaining({ + custom: { + disabled: 0, + enabled: 1, + }, + elastic: { + disabled: 0, + enabled: 0, + }, + }) + ); + }); + + it('Should update ML total for both elastic and custom rules', async () => { + const initialUsage = initialMlJobsUsage; + + let updatedUsage = updateMlJobsUsage({ isElastic: true, isEnabled: true }, initialUsage); + updatedUsage = updateMlJobsUsage({ isElastic: false, isEnabled: true }, updatedUsage); + + expect(updatedUsage).toEqual( + expect.objectContaining({ + custom: { + disabled: 0, + enabled: 1, + }, + elastic: { + disabled: 0, + enabled: 1, + }, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.ts new file mode 100644 index 00000000000000..f9571b98c9d594 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.ts @@ -0,0 +1,175 @@ +/* + * 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 { KibanaRequest, SavedObjectsClientContract } from '../../../../../../src/core/server'; +import { DatafeedStats, Job, MlPluginSetup } from '../../../../ml/server'; +import { isJobStarted } from '../../../common/machine_learning/helpers'; +import { isSecurityJob } from '../../../common/machine_learning/is_security_job'; +import { DetectionsMetric, MlJobMetric, MlJobsUsage, MlJobUsage } from './types'; + +/** + * Default ml job usage count + */ +export const initialMlJobsUsage: MlJobsUsage = { + custom: { + enabled: 0, + disabled: 0, + }, + elastic: { + enabled: 0, + disabled: 0, + }, +}; + +export const updateMlJobsUsage = (jobMetric: DetectionsMetric, usage: MlJobsUsage): MlJobsUsage => { + const { isEnabled, isElastic } = jobMetric; + if (isEnabled && isElastic) { + return { + ...usage, + elastic: { + ...usage.elastic, + enabled: usage.elastic.enabled + 1, + }, + }; + } else if (!isEnabled && isElastic) { + return { + ...usage, + elastic: { + ...usage.elastic, + disabled: usage.elastic.disabled + 1, + }, + }; + } else if (isEnabled && !isElastic) { + return { + ...usage, + custom: { + ...usage.custom, + enabled: usage.custom.enabled + 1, + }, + }; + } else if (!isEnabled && !isElastic) { + return { + ...usage, + custom: { + ...usage.custom, + disabled: usage.custom.disabled + 1, + }, + }; + } else { + return usage; + } +}; + +export const getMlJobMetrics = async ( + ml: MlPluginSetup | undefined, + savedObjectClient: SavedObjectsClientContract +): Promise => { + let jobsUsage: MlJobsUsage = initialMlJobsUsage; + + if (ml) { + try { + const fakeRequest = { headers: {} } as KibanaRequest; + + const modules = await ml.modulesProvider(fakeRequest, savedObjectClient).listModules(); + const moduleJobs = modules.flatMap((module) => module.jobs); + const jobs = await ml.jobServiceProvider(fakeRequest, savedObjectClient).jobsSummary(); + + jobsUsage = jobs.filter(isSecurityJob).reduce((usage, job) => { + const isElastic = moduleJobs.some((moduleJob) => moduleJob.id === job.id); + const isEnabled = isJobStarted(job.jobState, job.datafeedState); + + return updateMlJobsUsage({ isElastic, isEnabled }, usage); + }, initialMlJobsUsage); + + const jobsType = 'security'; + const securityJobStats = await ml + .anomalyDetectorsProvider(fakeRequest, savedObjectClient) + .jobStats(jobsType); + + const jobDetails = await ml + .anomalyDetectorsProvider(fakeRequest, savedObjectClient) + .jobs(jobsType); + + const jobDetailsCache = new Map(); + jobDetails.jobs.forEach((detail) => jobDetailsCache.set(detail.job_id, detail)); + + const datafeedStats = await ml + .anomalyDetectorsProvider(fakeRequest, savedObjectClient) + .datafeedStats(); + + const datafeedStatsCache = new Map(); + datafeedStats.datafeeds.forEach((datafeedStat) => + datafeedStatsCache.set(`${datafeedStat.datafeed_id}`, datafeedStat) + ); + + const jobMetrics: MlJobMetric[] = securityJobStats.jobs.map((stat) => { + const jobId = stat.job_id; + const jobDetail = jobDetailsCache.get(stat.job_id); + const datafeed = datafeedStatsCache.get(`datafeed-${jobId}`); + + return { + job_id: jobId, + open_time: stat.open_time, + create_time: jobDetail?.create_time, + finished_time: jobDetail?.finished_time, + state: stat.state, + data_counts: { + bucket_count: stat.data_counts.bucket_count, + empty_bucket_count: stat.data_counts.empty_bucket_count, + input_bytes: stat.data_counts.input_bytes, + input_record_count: stat.data_counts.input_record_count, + last_data_time: stat.data_counts.last_data_time, + processed_record_count: stat.data_counts.processed_record_count, + }, + model_size_stats: { + bucket_allocation_failures_count: + stat.model_size_stats.bucket_allocation_failures_count, + memory_status: stat.model_size_stats.memory_status, + model_bytes: stat.model_size_stats.model_bytes, + model_bytes_exceeded: stat.model_size_stats.model_bytes_exceeded, + model_bytes_memory_limit: stat.model_size_stats.model_bytes_memory_limit, + peak_model_bytes: stat.model_size_stats.peak_model_bytes, + }, + timing_stats: { + average_bucket_processing_time_ms: stat.timing_stats.average_bucket_processing_time_ms, + bucket_count: stat.timing_stats.bucket_count, + exponential_average_bucket_processing_time_ms: + stat.timing_stats.exponential_average_bucket_processing_time_ms, + exponential_average_bucket_processing_time_per_hour_ms: + stat.timing_stats.exponential_average_bucket_processing_time_per_hour_ms, + maximum_bucket_processing_time_ms: stat.timing_stats.maximum_bucket_processing_time_ms, + minimum_bucket_processing_time_ms: stat.timing_stats.minimum_bucket_processing_time_ms, + total_bucket_processing_time_ms: stat.timing_stats.total_bucket_processing_time_ms, + }, + datafeed: { + datafeed_id: datafeed?.datafeed_id, + state: datafeed?.state, + timing_stats: { + bucket_count: datafeed?.timing_stats.bucket_count, + exponential_average_search_time_per_hour_ms: + datafeed?.timing_stats.exponential_average_search_time_per_hour_ms, + search_count: datafeed?.timing_stats.search_count, + total_search_time_ms: datafeed?.timing_stats.total_search_time_ms, + }, + }, + } as MlJobMetric; + }); + + return { + ml_job_usage: jobsUsage, + ml_job_metrics: jobMetrics, + }; + } catch (e) { + // ignore failure, usage will be zeroed + } + } + + return { + ml_job_usage: initialMlJobsUsage, + ml_job_metrics: [], + }; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/dectections_metrics_helpers.test.ts b/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.test.ts similarity index 98% rename from x-pack/plugins/security_solution/server/usage/detections/dectections_metrics_helpers.test.ts rename to x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.test.ts index 55bd372e9dd799..d1846caf4ec223 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/dectections_metrics_helpers.test.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.test.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { initialDetectionRulesUsage, updateDetectionRuleUsage } from './detections_metrics_helpers'; -import { DetectionRuleMetric, DetectionRulesTypeUsage } from './index'; +import { initialDetectionRulesUsage, updateDetectionRuleUsage } from './detection_rule_helpers'; +import { DetectionRuleMetric, DetectionRulesTypeUsage } from './types'; import { v4 as uuid } from 'uuid'; const createStubRule = ( diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections_metrics_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts similarity index 68% rename from x-pack/plugins/security_solution/server/usage/detections/detections_metrics_helpers.ts rename to x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts index a84ea108c5f121..ebcda694411355 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections_metrics_helpers.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts @@ -5,22 +5,18 @@ * 2.0. */ -import { - ElasticsearchClient, - KibanaRequest, - SavedObjectsClientContract, -} from '../../../../../../src/core/server'; +import { ElasticsearchClient, SavedObjectsClientContract } from '../../../../../../src/core/server'; +import { SIGNALS_ID } from '../../../common/constants'; +import { isElasticRule } from './index'; import { AlertsAggregationResponse, CasesSavedObject, DetectionRulesTypeUsage, DetectionRuleMetric, DetectionRuleAdoption, - MlJobMetric, -} from './index'; -import { SIGNALS_ID } from '../../../common/constants'; -import { DatafeedStats, Job, MlPluginSetup } from '../../../../ml/server'; -import { isElasticRule, RuleSearchParams, RuleSearchResult } from './detection_telemetry_helpers'; + RuleSearchParams, + RuleSearchResult, +} from './types'; /** * Default detection rule usage count, split by type + elastic/custom @@ -288,91 +284,3 @@ export const getDetectionRuleMetrics = async ( detection_rule_usage: rulesUsage, }; }; - -export const getMlJobMetrics = async ( - ml: MlPluginSetup | undefined, - savedObjectClient: SavedObjectsClientContract -): Promise => { - if (ml) { - try { - const fakeRequest = { headers: {} } as KibanaRequest; - const jobsType = 'security'; - const securityJobStats = await ml - .anomalyDetectorsProvider(fakeRequest, savedObjectClient) - .jobStats(jobsType); - - const jobDetails = await ml - .anomalyDetectorsProvider(fakeRequest, savedObjectClient) - .jobs(jobsType); - - const jobDetailsCache = new Map(); - jobDetails.jobs.forEach((detail) => jobDetailsCache.set(detail.job_id, detail)); - - const datafeedStats = await ml - .anomalyDetectorsProvider(fakeRequest, savedObjectClient) - .datafeedStats(); - - const datafeedStatsCache = new Map(); - datafeedStats.datafeeds.forEach((datafeedStat) => - datafeedStatsCache.set(`${datafeedStat.datafeed_id}`, datafeedStat) - ); - - return securityJobStats.jobs.map((stat) => { - const jobId = stat.job_id; - const jobDetail = jobDetailsCache.get(stat.job_id); - const datafeed = datafeedStatsCache.get(`datafeed-${jobId}`); - - return { - job_id: jobId, - open_time: stat.open_time, - create_time: jobDetail?.create_time, - finished_time: jobDetail?.finished_time, - state: stat.state, - data_counts: { - bucket_count: stat.data_counts.bucket_count, - empty_bucket_count: stat.data_counts.empty_bucket_count, - input_bytes: stat.data_counts.input_bytes, - input_record_count: stat.data_counts.input_record_count, - last_data_time: stat.data_counts.last_data_time, - processed_record_count: stat.data_counts.processed_record_count, - }, - model_size_stats: { - bucket_allocation_failures_count: - stat.model_size_stats.bucket_allocation_failures_count, - memory_status: stat.model_size_stats.memory_status, - model_bytes: stat.model_size_stats.model_bytes, - model_bytes_exceeded: stat.model_size_stats.model_bytes_exceeded, - model_bytes_memory_limit: stat.model_size_stats.model_bytes_memory_limit, - peak_model_bytes: stat.model_size_stats.peak_model_bytes, - }, - timing_stats: { - average_bucket_processing_time_ms: stat.timing_stats.average_bucket_processing_time_ms, - bucket_count: stat.timing_stats.bucket_count, - exponential_average_bucket_processing_time_ms: - stat.timing_stats.exponential_average_bucket_processing_time_ms, - exponential_average_bucket_processing_time_per_hour_ms: - stat.timing_stats.exponential_average_bucket_processing_time_per_hour_ms, - maximum_bucket_processing_time_ms: stat.timing_stats.maximum_bucket_processing_time_ms, - minimum_bucket_processing_time_ms: stat.timing_stats.minimum_bucket_processing_time_ms, - total_bucket_processing_time_ms: stat.timing_stats.total_bucket_processing_time_ms, - }, - datafeed: { - datafeed_id: datafeed?.datafeed_id, - state: datafeed?.state, - timing_stats: { - bucket_count: datafeed?.timing_stats.bucket_count, - exponential_average_search_time_per_hour_ms: - datafeed?.timing_stats.exponential_average_search_time_per_hour_ms, - search_count: datafeed?.timing_stats.search_count, - total_search_time_ms: datafeed?.timing_stats.total_search_time_ms, - }, - }, - } as MlJobMetric; - }); - } catch (e) { - // ignore failure, usage will be zeroed - } - } - - return []; -}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/detection_telemetry_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections/detection_telemetry_helpers.ts deleted file mode 100644 index bc1e734e4cc3af..00000000000000 --- a/x-pack/plugins/security_solution/server/usage/detections/detection_telemetry_helpers.ts +++ /dev/null @@ -1,46 +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 { INTERNAL_IMMUTABLE_KEY } from '../../../common/constants'; - -export const isElasticRule = (tags: string[] = []) => - tags.includes(`${INTERNAL_IMMUTABLE_KEY}:true`); - -interface RuleSearchBody { - query: { - bool: { - filter: { - term: { [key: string]: string }; - }; - }; - }; -} - -export interface RuleSearchParams { - body: RuleSearchBody; - filterPath: string[]; - ignoreUnavailable: boolean; - index: string; - size: number; -} - -export interface RuleSearchResult { - alert: { - name: string; - enabled: boolean; - tags: string[]; - createdAt: string; - updatedAt: string; - params: DetectionRuleParms; - }; -} - -interface DetectionRuleParms { - ruleId: string; - version: string; - type: string; -} diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts b/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts index f90841ff4e596c..c4ab55127f5d3e 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { INTERNAL_IMMUTABLE_KEY } from '../../../common/constants'; - export const getMockJobSummaryResponse = () => [ { id: 'linux_anomalous_network_activity_ecs', @@ -162,20 +160,6 @@ export const getMockListModulesResponse = () => [ }, ]; -export const getMockRulesResponse = () => ({ - hits: { - hits: [ - { _source: { alert: { enabled: true, tags: [`${INTERNAL_IMMUTABLE_KEY}:true`] } } }, - { _source: { alert: { enabled: true, tags: [`${INTERNAL_IMMUTABLE_KEY}:false`] } } }, - { _source: { alert: { enabled: false, tags: [`${INTERNAL_IMMUTABLE_KEY}:true`] } } }, - { _source: { alert: { enabled: true, tags: [`${INTERNAL_IMMUTABLE_KEY}:true`] } } }, - { _source: { alert: { enabled: false, tags: [`${INTERNAL_IMMUTABLE_KEY}:false`] } } }, - { _source: { alert: { enabled: false, tags: [`${INTERNAL_IMMUTABLE_KEY}:true`] } } }, - { _source: { alert: { enabled: false, tags: [`${INTERNAL_IMMUTABLE_KEY}:true`] } } }, - ], - }, -}); - export const getMockMlJobDetailsResponse = () => ({ count: 20, jobs: [ diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts b/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts index a020f49464007f..7365c210172fe4 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts @@ -11,10 +11,11 @@ import { savedObjectsClientMock, } from '../../../../../../src/core/server/mocks'; import { mlServicesMock } from '../../lib/machine_learning/mocks'; +import { fetchDetectionsMetrics } from './index'; +import { initialMlJobsUsage } from './detection_ml_helpers'; import { getMockJobSummaryResponse, getMockListModulesResponse, - getMockRulesResponse, getMockMlJobDetailsResponse, getMockMlJobStatsResponse, getMockMlDatafeedStatsResponse, @@ -22,7 +23,6 @@ import { getMockRuleAlertsResponse, getMockAlertCasesResponse, } from './detections.mocks'; -import { fetchDetectionsUsage, fetchDetectionsMetrics } from './index'; const savedObjectsClient = savedObjectsClientMock.create(); @@ -30,89 +30,6 @@ describe('Detections Usage and Metrics', () => { let esClientMock: jest.Mocked; let mlMock: ReturnType; - describe('fetchDetectionsUsage()', () => { - beforeEach(() => { - esClientMock = elasticsearchServiceMock.createClusterClient().asInternalUser; - mlMock = mlServicesMock.createSetupContract(); - }); - - it('returns zeroed counts if both calls are empty', async () => { - const result = await fetchDetectionsUsage('', esClientMock, mlMock, savedObjectsClient); - - expect(result).toEqual({ - detection_rules: { - custom: { - enabled: 0, - disabled: 0, - }, - elastic: { - enabled: 0, - disabled: 0, - }, - }, - ml_jobs: { - custom: { - enabled: 0, - disabled: 0, - }, - elastic: { - enabled: 0, - disabled: 0, - }, - }, - }); - }); - - it('tallies rules data given rules results', async () => { - (esClientMock.search as jest.Mock).mockResolvedValue({ body: getMockRulesResponse() }); - - const result = await fetchDetectionsUsage('', esClientMock, mlMock, savedObjectsClient); - - expect(result).toEqual( - expect.objectContaining({ - detection_rules: { - custom: { - enabled: 1, - disabled: 1, - }, - elastic: { - enabled: 2, - disabled: 3, - }, - }, - }) - ); - }); - - it('tallies jobs data given jobs results', async () => { - const mockJobSummary = jest.fn().mockResolvedValue(getMockJobSummaryResponse()); - const mockListModules = jest.fn().mockResolvedValue(getMockListModulesResponse()); - mlMock.modulesProvider.mockReturnValue(({ - listModules: mockListModules, - } as unknown) as ReturnType); - mlMock.jobServiceProvider.mockReturnValue({ - jobsSummary: mockJobSummary, - }); - - const result = await fetchDetectionsUsage('', esClientMock, mlMock, savedObjectsClient); - - expect(result).toEqual( - expect.objectContaining({ - ml_jobs: { - custom: { - enabled: 1, - disabled: 1, - }, - elastic: { - enabled: 1, - disabled: 1, - }, - }, - }) - ); - }); - }); - describe('getDetectionRuleMetrics()', () => { beforeEach(() => { esClientMock = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -171,7 +88,7 @@ describe('Detections Usage and Metrics', () => { }, }, }, - ml_jobs: [], + ml_jobs: { ml_job_metrics: [], ml_job_usage: initialMlJobsUsage }, }) ); }); @@ -246,7 +163,7 @@ describe('Detections Usage and Metrics', () => { }, }, }, - ml_jobs: [], + ml_jobs: { ml_job_metrics: [], ml_job_usage: initialMlJobsUsage }, }) ); }); @@ -308,7 +225,7 @@ describe('Detections Usage and Metrics', () => { }, }, }, - ml_jobs: [], + ml_jobs: { ml_job_metrics: [], ml_job_usage: initialMlJobsUsage }, }) ); }); @@ -383,7 +300,7 @@ describe('Detections Usage and Metrics', () => { }, }, }, - ml_jobs: [], + ml_jobs: { ml_job_metrics: [], ml_job_usage: initialMlJobsUsage }, }) ); }); @@ -404,12 +321,20 @@ describe('Detections Usage and Metrics', () => { expect(result).toEqual( expect.objectContaining({ - ml_jobs: [], + ml_jobs: { ml_job_metrics: [], ml_job_usage: initialMlJobsUsage }, }) ); }); it('returns an ml job telemetry object from anomaly detectors provider', async () => { + const mockJobSummary = jest.fn().mockResolvedValue(getMockJobSummaryResponse()); + const mockListModules = jest.fn().mockResolvedValue(getMockListModulesResponse()); + mlMock.modulesProvider.mockReturnValue(({ + listModules: mockListModules, + } as unknown) as ReturnType); + mlMock.jobServiceProvider.mockReturnValue({ + jobsSummary: mockJobSummary, + }); const mockJobsResponse = jest.fn().mockResolvedValue(getMockMlJobDetailsResponse()); const mockJobStatsResponse = jest.fn().mockResolvedValue(getMockMlJobStatsResponse()); const mockDatafeedStatsResponse = jest @@ -426,49 +351,61 @@ describe('Detections Usage and Metrics', () => { expect(result).toEqual( expect.objectContaining({ - ml_jobs: [ - { - job_id: 'high_distinct_count_error_message', - create_time: 1603838214983, - finished_time: 1611739871669, - state: 'closed', - data_counts: { - bucket_count: 8612, - empty_bucket_count: 8590, - input_bytes: 45957, - input_record_count: 162, - last_data_time: 1610470367123, - processed_record_count: 162, - }, - model_size_stats: { - bucket_allocation_failures_count: 0, - memory_status: 'ok', - model_bytes: 72574, - model_bytes_exceeded: 0, - model_bytes_memory_limit: 16777216, - peak_model_bytes: 78682, - }, - timing_stats: { - average_bucket_processing_time_ms: 0.4900837644740133, - bucket_count: 16236, - exponential_average_bucket_processing_time_ms: 0.23614068552903306, - exponential_average_bucket_processing_time_per_hour_ms: 1.5551298175461634, - maximum_bucket_processing_time_ms: 392, - minimum_bucket_processing_time_ms: 0, - total_bucket_processing_time_ms: 7957.00000000008, - }, - datafeed: { - datafeed_id: 'datafeed-high_distinct_count_error_message', - state: 'stopped', - timing_stats: { + ml_jobs: { + ml_job_usage: { + custom: { + disabled: 1, + enabled: 1, + }, + elastic: { + disabled: 1, + enabled: 1, + }, + }, + ml_job_metrics: [ + { + job_id: 'high_distinct_count_error_message', + create_time: 1603838214983, + finished_time: 1611739871669, + state: 'closed', + data_counts: { bucket_count: 8612, - exponential_average_search_time_per_hour_ms: 86145.39799630083, - search_count: 7202, - total_search_time_ms: 3107147, + empty_bucket_count: 8590, + input_bytes: 45957, + input_record_count: 162, + last_data_time: 1610470367123, + processed_record_count: 162, + }, + model_size_stats: { + bucket_allocation_failures_count: 0, + memory_status: 'ok', + model_bytes: 72574, + model_bytes_exceeded: 0, + model_bytes_memory_limit: 16777216, + peak_model_bytes: 78682, + }, + timing_stats: { + average_bucket_processing_time_ms: 0.4900837644740133, + bucket_count: 16236, + exponential_average_bucket_processing_time_ms: 0.23614068552903306, + exponential_average_bucket_processing_time_per_hour_ms: 1.5551298175461634, + maximum_bucket_processing_time_ms: 392, + minimum_bucket_processing_time_ms: 0, + total_bucket_processing_time_ms: 7957.00000000008, + }, + datafeed: { + datafeed_id: 'datafeed-high_distinct_count_error_message', + state: 'stopped', + timing_stats: { + bucket_count: 8612, + exponential_average_search_time_per_hour_ms: 86145.39799630083, + search_count: 7202, + total_search_time_ms: 3107147, + }, }, }, - }, - ], + ], + }, }) ); }); diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections_usage_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections/detections_usage_helpers.ts deleted file mode 100644 index 3c666d4d217809..00000000000000 --- a/x-pack/plugins/security_solution/server/usage/detections/detections_usage_helpers.ts +++ /dev/null @@ -1,191 +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 { - ElasticsearchClient, - KibanaRequest, - SavedObjectsClientContract, -} from '../../../../../../src/core/server'; -import { SIGNALS_ID } from '../../../common/constants'; -import { isJobStarted } from '../../../common/machine_learning/helpers'; -import { isSecurityJob } from '../../../common/machine_learning/is_security_job'; -import { MlPluginSetup } from '../../../../ml/server'; -import { DetectionRulesUsage, MlJobsUsage } from './index'; -import { isElasticRule, RuleSearchParams, RuleSearchResult } from './detection_telemetry_helpers'; - -interface DetectionsMetric { - isElastic: boolean; - isEnabled: boolean; -} - -/** - * Default detection rule usage count - */ -export const initialRulesUsage: DetectionRulesUsage = { - custom: { - enabled: 0, - disabled: 0, - }, - elastic: { - enabled: 0, - disabled: 0, - }, -}; - -/** - * Default ml job usage count - */ -export const initialMlJobsUsage: MlJobsUsage = { - custom: { - enabled: 0, - disabled: 0, - }, - elastic: { - enabled: 0, - disabled: 0, - }, -}; - -const updateRulesUsage = ( - ruleMetric: DetectionsMetric, - usage: DetectionRulesUsage -): DetectionRulesUsage => { - const { isEnabled, isElastic } = ruleMetric; - if (isEnabled && isElastic) { - return { - ...usage, - elastic: { - ...usage.elastic, - enabled: usage.elastic.enabled + 1, - }, - }; - } else if (!isEnabled && isElastic) { - return { - ...usage, - elastic: { - ...usage.elastic, - disabled: usage.elastic.disabled + 1, - }, - }; - } else if (isEnabled && !isElastic) { - return { - ...usage, - custom: { - ...usage.custom, - enabled: usage.custom.enabled + 1, - }, - }; - } else if (!isEnabled && !isElastic) { - return { - ...usage, - custom: { - ...usage.custom, - disabled: usage.custom.disabled + 1, - }, - }; - } else { - return usage; - } -}; - -const updateMlJobsUsage = (jobMetric: DetectionsMetric, usage: MlJobsUsage): MlJobsUsage => { - const { isEnabled, isElastic } = jobMetric; - if (isEnabled && isElastic) { - return { - ...usage, - elastic: { - ...usage.elastic, - enabled: usage.elastic.enabled + 1, - }, - }; - } else if (!isEnabled && isElastic) { - return { - ...usage, - elastic: { - ...usage.elastic, - disabled: usage.elastic.disabled + 1, - }, - }; - } else if (isEnabled && !isElastic) { - return { - ...usage, - custom: { - ...usage.custom, - enabled: usage.custom.enabled + 1, - }, - }; - } else if (!isEnabled && !isElastic) { - return { - ...usage, - custom: { - ...usage.custom, - disabled: usage.custom.disabled + 1, - }, - }; - } else { - return usage; - } -}; - -export const getRulesUsage = async ( - index: string, - esClient: ElasticsearchClient -): Promise => { - let rulesUsage: DetectionRulesUsage = initialRulesUsage; - const ruleSearchOptions: RuleSearchParams = { - body: { query: { bool: { filter: { term: { 'alert.alertTypeId': SIGNALS_ID } } } } }, - filterPath: ['hits.hits._source.alert.enabled', 'hits.hits._source.alert.tags'], - ignoreUnavailable: true, - index, - size: 10000, // elasticsearch index.max_result_window default value - }; - - try { - const { body: ruleResults } = await esClient.search(ruleSearchOptions); - - if (ruleResults.hits?.hits?.length > 0) { - rulesUsage = ruleResults.hits.hits.reduce((usage, hit) => { - const isElastic = isElasticRule(hit._source?.alert.tags); - const isEnabled = Boolean(hit._source?.alert.enabled); - - return updateRulesUsage({ isElastic, isEnabled }, usage); - }, initialRulesUsage); - } - } catch (e) { - // ignore failure, usage will be zeroed - } - - return rulesUsage; -}; - -export const getMlJobsUsage = async ( - ml: MlPluginSetup | undefined, - savedObjectClient: SavedObjectsClientContract -): Promise => { - let jobsUsage: MlJobsUsage = initialMlJobsUsage; - - if (ml) { - try { - const fakeRequest = { headers: {} } as KibanaRequest; - - const modules = await ml.modulesProvider(fakeRequest, savedObjectClient).listModules(); - const moduleJobs = modules.flatMap((module) => module.jobs); - const jobs = await ml.jobServiceProvider(fakeRequest, savedObjectClient).jobsSummary(); - - jobsUsage = jobs.filter(isSecurityJob).reduce((usage, job) => { - const isElastic = moduleJobs.some((moduleJob) => moduleJob.id === job.id); - const isEnabled = isJobStarted(job.jobState, job.datafeedState); - - return updateMlJobsUsage({ isElastic, isEnabled }, usage); - }, initialMlJobsUsage); - } catch (e) { - // ignore failure, usage will be zeroed - } - } - - return jobsUsage; -}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/index.ts b/x-pack/plugins/security_solution/server/usage/detections/index.ts index ea3df7b1f2230b..823e29fd0dd30d 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/index.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/index.ts @@ -6,157 +6,15 @@ */ import { ElasticsearchClient, SavedObjectsClientContract } from '../../../../../../src/core/server'; -import { - getMlJobsUsage, - getRulesUsage, - initialRulesUsage, - initialMlJobsUsage, -} from './detections_usage_helpers'; -import { - getMlJobMetrics, - getDetectionRuleMetrics, - initialDetectionRulesUsage, -} from './detections_metrics_helpers'; import { MlPluginSetup } from '../../../../ml/server'; +import { getDetectionRuleMetrics, initialDetectionRulesUsage } from './detection_rule_helpers'; +import { getMlJobMetrics, initialMlJobsUsage } from './detection_ml_helpers'; +import { DetectionMetrics } from './types'; -interface FeatureUsage { - enabled: number; - disabled: number; -} +import { INTERNAL_IMMUTABLE_KEY } from '../../../common/constants'; -interface FeatureTypeUsage { - enabled: number; - disabled: number; - alerts: number; - cases: number; -} - -export interface DetectionRulesTypeUsage { - query: FeatureTypeUsage; - threshold: FeatureTypeUsage; - eql: FeatureTypeUsage; - machine_learning: FeatureTypeUsage; - threat_match: FeatureTypeUsage; - elastic_total: FeatureTypeUsage; - custom_total: FeatureTypeUsage; -} - -export interface DetectionRulesUsage { - custom: FeatureUsage; - elastic: FeatureUsage; -} - -export interface MlJobsUsage { - custom: FeatureUsage; - elastic: FeatureUsage; -} - -export interface DetectionsUsage { - detection_rules: DetectionRulesUsage; - ml_jobs: MlJobsUsage; -} - -export interface DetectionMetrics { - ml_jobs: MlJobMetric[]; - detection_rules: DetectionRuleAdoption; -} - -export interface MlJobDataCount { - bucket_count: number; - empty_bucket_count: number; - input_bytes: number; - input_record_count: number; - last_data_time: number; - processed_record_count: number; -} - -export interface MlJobModelSize { - bucket_allocation_failures_count: number; - memory_status: string; - model_bytes: number; - model_bytes_exceeded: number; - model_bytes_memory_limit: number; - peak_model_bytes: number; -} - -export interface MlTimingStats { - bucket_count: number; - exponential_average_bucket_processing_time_ms: number; - exponential_average_bucket_processing_time_per_hour_ms: number; - maximum_bucket_processing_time_ms: number; - minimum_bucket_processing_time_ms: number; - total_bucket_processing_time_ms: number; -} - -export interface MlJobMetric { - job_id: string; - open_time: string; - state: string; - data_counts: MlJobDataCount; - model_size_stats: MlJobModelSize; - timing_stats: MlTimingStats; -} - -export interface DetectionRuleMetric { - rule_name: string; - rule_id: string; - rule_type: string; - enabled: boolean; - elastic_rule: boolean; - created_on: string; - updated_on: string; - alert_count_daily: number; - cases_count_total: number; -} - -export interface DetectionRuleAdoption { - detection_rule_detail: DetectionRuleMetric[]; - detection_rule_usage: DetectionRulesTypeUsage; -} - -export interface AlertsAggregationResponse { - hits: { - total: { value: number }; - }; - aggregations: { - [aggName: string]: { - buckets: Array<{ key: string; doc_count: number }>; - }; - }; -} - -export interface CasesSavedObject { - associationType: string; - type: string; - alertId: string; - index: string; - rule: { - id: string; - name: string; - }; -} - -export const defaultDetectionsUsage = { - detection_rules: initialRulesUsage, - ml_jobs: initialMlJobsUsage, -}; - -export const fetchDetectionsUsage = async ( - kibanaIndex: string, - esClient: ElasticsearchClient, - ml: MlPluginSetup | undefined, - savedObjectClient: SavedObjectsClientContract -): Promise => { - const [rulesUsage, mlJobsUsage] = await Promise.allSettled([ - getRulesUsage(kibanaIndex, esClient), - getMlJobsUsage(ml, savedObjectClient), - ]); - - return { - detection_rules: rulesUsage.status === 'fulfilled' ? rulesUsage.value : initialRulesUsage, - ml_jobs: mlJobsUsage.status === 'fulfilled' ? mlJobsUsage.value : initialMlJobsUsage, - }; -}; +export const isElasticRule = (tags: string[] = []) => + tags.includes(`${INTERNAL_IMMUTABLE_KEY}:true`); export const fetchDetectionsMetrics = async ( kibanaIndex: string, @@ -171,7 +29,10 @@ export const fetchDetectionsMetrics = async ( ]); return { - ml_jobs: mlJobMetrics.status === 'fulfilled' ? mlJobMetrics.value : [], + ml_jobs: + mlJobMetrics.status === 'fulfilled' + ? mlJobMetrics.value + : { ml_job_metrics: [], ml_job_usage: initialMlJobsUsage }, detection_rules: detectionRuleMetrics.status === 'fulfilled' ? detectionRuleMetrics.value diff --git a/x-pack/plugins/security_solution/server/usage/detections/types.ts b/x-pack/plugins/security_solution/server/usage/detections/types.ts new file mode 100644 index 00000000000000..0e3ba97ca0f7c2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/types.ts @@ -0,0 +1,162 @@ +/* + * 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. + */ + +interface RuleSearchBody { + query: { + bool: { + filter: { + term: { [key: string]: string }; + }; + }; + }; +} + +export interface RuleSearchParams { + body: RuleSearchBody; + filterPath: string[]; + ignoreUnavailable: boolean; + index: string; + size: number; +} + +export interface RuleSearchResult { + alert: { + name: string; + enabled: boolean; + tags: string[]; + createdAt: string; + updatedAt: string; + params: DetectionRuleParms; + }; +} + +export interface DetectionsMetric { + isElastic: boolean; + isEnabled: boolean; +} + +interface DetectionRuleParms { + ruleId: string; + version: string; + type: string; +} + +interface FeatureUsage { + enabled: number; + disabled: number; +} + +interface FeatureTypeUsage { + enabled: number; + disabled: number; + alerts: number; + cases: number; +} + +export interface DetectionRulesTypeUsage { + query: FeatureTypeUsage; + threshold: FeatureTypeUsage; + eql: FeatureTypeUsage; + machine_learning: FeatureTypeUsage; + threat_match: FeatureTypeUsage; + elastic_total: FeatureTypeUsage; + custom_total: FeatureTypeUsage; +} + +export interface MlJobsUsage { + custom: FeatureUsage; + elastic: FeatureUsage; +} + +export interface DetectionsUsage { + ml_jobs: MlJobsUsage; +} + +export interface DetectionMetrics { + ml_jobs: MlJobUsage; + detection_rules: DetectionRuleAdoption; +} + +export interface MlJobDataCount { + bucket_count: number; + empty_bucket_count: number; + input_bytes: number; + input_record_count: number; + last_data_time: number; + processed_record_count: number; +} + +export interface MlJobModelSize { + bucket_allocation_failures_count: number; + memory_status: string; + model_bytes: number; + model_bytes_exceeded: number; + model_bytes_memory_limit: number; + peak_model_bytes: number; +} + +export interface MlTimingStats { + bucket_count: number; + exponential_average_bucket_processing_time_ms: number; + exponential_average_bucket_processing_time_per_hour_ms: number; + maximum_bucket_processing_time_ms: number; + minimum_bucket_processing_time_ms: number; + total_bucket_processing_time_ms: number; +} + +export interface MlJobMetric { + job_id: string; + open_time: string; + state: string; + data_counts: MlJobDataCount; + model_size_stats: MlJobModelSize; + timing_stats: MlTimingStats; +} + +export interface DetectionRuleMetric { + rule_name: string; + rule_id: string; + rule_type: string; + enabled: boolean; + elastic_rule: boolean; + created_on: string; + updated_on: string; + alert_count_daily: number; + cases_count_total: number; +} + +export interface AlertsAggregationResponse { + hits: { + total: { value: number }; + }; + aggregations: { + [aggName: string]: { + buckets: Array<{ key: string; doc_count: number }>; + }; + }; +} + +export interface CasesSavedObject { + associationType: string; + type: string; + alertId: string; + index: string; + rule: { + id: string; + name: string; + }; +} + +export interface MlJobUsage { + ml_job_usage: MlJobsUsage; + ml_job_metrics: MlJobMetric[]; +} + +export interface DetectionRuleAdoption { + detection_rule_detail: DetectionRuleMetric[]; + detection_rule_usage: DetectionRulesTypeUsage; +} diff --git a/x-pack/plugins/stack_alerts/server/feature.test.ts b/x-pack/plugins/stack_alerts/server/feature.test.ts index 62807f1c10a1ca..3dde7fd09f3476 100644 --- a/x-pack/plugins/stack_alerts/server/feature.test.ts +++ b/x-pack/plugins/stack_alerts/server/feature.test.ts @@ -20,9 +20,11 @@ describe('Stack Alerts Feature Privileges', () => { const featuresSetup = featuresPluginMock.createSetup(); await plugin.setup(coreSetup, { alerting: alertingSetup, features: featuresSetup }); - const typesInFeaturePrivilege = BUILT_IN_ALERTS_FEATURE.alerting; - const typesInFeaturePrivilegeAll = BUILT_IN_ALERTS_FEATURE.privileges.all.alerting.all; - const typesInFeaturePrivilegeRead = BUILT_IN_ALERTS_FEATURE.privileges.read.alerting.read; + const typesInFeaturePrivilege = BUILT_IN_ALERTS_FEATURE.alerting ?? []; + const typesInFeaturePrivilegeAll = + BUILT_IN_ALERTS_FEATURE.privileges?.all?.alerting?.rule?.all ?? []; + const typesInFeaturePrivilegeRead = + BUILT_IN_ALERTS_FEATURE.privileges?.read?.alerting?.rule?.read ?? []; expect(alertingSetup.registerType.mock.calls.length).toEqual(typesInFeaturePrivilege.length); expect(alertingSetup.registerType.mock.calls.length).toEqual(typesInFeaturePrivilegeAll.length); expect(alertingSetup.registerType.mock.calls.length).toEqual( diff --git a/x-pack/plugins/stack_alerts/server/feature.ts b/x-pack/plugins/stack_alerts/server/feature.ts index e168ec21438c0c..70e68c2b7ced30 100644 --- a/x-pack/plugins/stack_alerts/server/feature.ts +++ b/x-pack/plugins/stack_alerts/server/feature.ts @@ -6,13 +6,14 @@ */ import { i18n } from '@kbn/i18n'; +import { KibanaFeatureConfig } from '../../../plugins/features/common'; import { ID as IndexThreshold } from './alert_types/index_threshold/alert_type'; import { GEO_CONTAINMENT_ID as GeoContainment } from './alert_types/geo_containment/alert_type'; import { ES_QUERY_ID as ElasticsearchQuery } from './alert_types/es_query/alert_type'; import { STACK_ALERTS_FEATURE_ID } from '../common'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; -export const BUILT_IN_ALERTS_FEATURE = { +export const BUILT_IN_ALERTS_FEATURE: KibanaFeatureConfig = { id: STACK_ALERTS_FEATURE_ID, name: i18n.translate('xpack.stackAlerts.featureRegistry.actionsFeatureName', { defaultMessage: 'Stack Rules', @@ -31,8 +32,12 @@ export const BUILT_IN_ALERTS_FEATURE = { insightsAndAlerting: ['triggersActions'], }, alerting: { - all: [IndexThreshold, GeoContainment, ElasticsearchQuery], - read: [], + rule: { + all: [IndexThreshold, GeoContainment, ElasticsearchQuery], + }, + alert: { + all: [IndexThreshold, GeoContainment, ElasticsearchQuery], + }, }, savedObject: { all: [], @@ -48,8 +53,12 @@ export const BUILT_IN_ALERTS_FEATURE = { insightsAndAlerting: ['triggersActions'], }, alerting: { - all: [], - read: [IndexThreshold, GeoContainment, ElasticsearchQuery], + rule: { + read: [IndexThreshold, GeoContainment, ElasticsearchQuery], + }, + alert: { + read: [IndexThreshold, GeoContainment, ElasticsearchQuery], + }, }, savedObject: { all: [], diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index b85fd8bf8989e3..12e83008b2e5a5 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -4664,82 +4664,6 @@ }, "security_solution": { "properties": { - "detections": { - "properties": { - "detection_rules": { - "properties": { - "custom": { - "properties": { - "enabled": { - "type": "long", - "_meta": { - "description": "The number of custom detection rules enabled" - } - }, - "disabled": { - "type": "long", - "_meta": { - "description": "The number of custom detection rules disabled" - } - } - } - }, - "elastic": { - "properties": { - "enabled": { - "type": "long", - "_meta": { - "description": "The number of elastic prebuilt detection rules enabled" - } - }, - "disabled": { - "type": "long", - "_meta": { - "description": "The number of elastic prebuilt detection rules disabled" - } - } - } - } - } - }, - "ml_jobs": { - "properties": { - "custom": { - "properties": { - "enabled": { - "type": "long", - "_meta": { - "description": "The number of custom ML jobs rules enabled" - } - }, - "disabled": { - "type": "long", - "_meta": { - "description": "The number of custom ML jobs rules disabled" - } - } - } - }, - "elastic": { - "properties": { - "enabled": { - "type": "long", - "_meta": { - "description": "The number of elastic provided ML jobs rules enabled" - } - }, - "disabled": { - "type": "long", - "_meta": { - "description": "The number of elastic provided ML jobs rules disabled" - } - } - } - } - } - } - } - }, "detectionMetrics": { "properties": { "detection_rules": { @@ -5014,197 +4938,237 @@ } }, "ml_jobs": { - "type": "array", - "items": { - "properties": { - "job_id": { - "type": "keyword", - "_meta": { - "description": "Identifier for the anomaly detection job" - } - }, - "open_time": { - "type": "keyword", - "_meta": { - "description": "For open jobs only, the elapsed time for which the job has been open" - } - }, - "create_time": { - "type": "keyword", - "_meta": { - "description": "The time the job was created" - } - }, - "finished_time": { - "type": "keyword", - "_meta": { - "description": "If the job closed or failed, this is the time the job finished" - } - }, - "state": { - "type": "keyword", - "_meta": { - "description": "The status of the anomaly detection job" - } - }, - "data_counts": { - "properties": { - "bucket_count": { - "type": "long", - "_meta": { - "description": "The number of buckets processed" - } - }, - "empty_bucket_count": { - "type": "long", - "_meta": { - "description": "The number of buckets which did not contain any data" - } - }, - "input_bytes": { - "type": "long", - "_meta": { - "description": "The number of bytes of input data posted to the anomaly detection job" - } - }, - "input_record_count": { - "type": "long", - "_meta": { - "description": "The number of input documents posted to the anomaly detection job" - } - }, - "last_data_time": { - "type": "long", - "_meta": { - "description": "The timestamp at which data was last analyzed, according to server time" - } - }, - "processed_record_count": { - "type": "long", - "_meta": { - "description": "The number of input documents that have been processed by the anomaly detection job" + "properties": { + "ml_job_usage": { + "properties": { + "custom": { + "properties": { + "enabled": { + "type": "long", + "_meta": { + "description": "The number of custom ML jobs rules enabled" + } + }, + "disabled": { + "type": "long", + "_meta": { + "description": "The number of custom ML jobs rules disabled" + } } } - } - }, - "model_size_stats": { - "properties": { - "bucket_allocation_failures_count": { - "type": "long", - "_meta": { - "description": "The number of buckets for which new entities in incoming data were not processed due to insufficient model memory" - } - }, - "model_bytes": { - "type": "long", - "_meta": { - "description": "The number of bytes of memory used by the models" - } - }, - "model_bytes_exceeded": { - "type": "long", - "_meta": { - "description": "The number of bytes over the high limit for memory usage at the last allocation failure" - } - }, - "model_bytes_memory_limit": { - "type": "long", - "_meta": { - "description": "The upper limit for model memory usage, checked on increasing values" - } - }, - "peak_model_bytes": { - "type": "long", - "_meta": { - "description": "The peak number of bytes of memory ever used by the models" + }, + "elastic": { + "properties": { + "enabled": { + "type": "long", + "_meta": { + "description": "The number of elastic provided ML jobs rules enabled" + } + }, + "disabled": { + "type": "long", + "_meta": { + "description": "The number of elastic provided ML jobs rules disabled" + } } } } - }, - "timing_stats": { + } + }, + "ml_job_metrics": { + "type": "array", + "items": { "properties": { - "bucket_count": { - "type": "long", - "_meta": { - "description": "The number of buckets processed" - } - }, - "exponential_average_bucket_processing_time_ms": { - "type": "long", - "_meta": { - "description": "Exponential moving average of all bucket processing times, in milliseconds" - } - }, - "exponential_average_bucket_processing_time_per_hour_ms": { - "type": "long", + "job_id": { + "type": "keyword", "_meta": { - "description": "Exponentially-weighted moving average of bucket processing times calculated in a 1 hour time window, in milliseconds" + "description": "Identifier for the anomaly detection job" } }, - "maximum_bucket_processing_time_ms": { - "type": "long", + "open_time": { + "type": "keyword", "_meta": { - "description": "Maximum among all bucket processing times, in milliseconds" + "description": "For open jobs only, the elapsed time for which the job has been open" } }, - "minimum_bucket_processing_time_ms": { - "type": "long", + "create_time": { + "type": "keyword", "_meta": { - "description": "Minimum among all bucket processing times, in milliseconds" + "description": "The time the job was created" } }, - "total_bucket_processing_time_ms": { - "type": "long", - "_meta": { - "description": "Sum of all bucket processing times, in milliseconds" - } - } - } - }, - "datafeed": { - "properties": { - "datafeed_id": { + "finished_time": { "type": "keyword", "_meta": { - "description": "A numerical character string that uniquely identifies the datafeed" + "description": "If the job closed or failed, this is the time the job finished" } }, "state": { "type": "keyword", "_meta": { - "description": "The status of the datafeed" + "description": "The status of the anomaly detection job" } }, - "timing_stats": { + "data_counts": { + "properties": { + "bucket_count": { + "type": "long", + "_meta": { + "description": "The number of buckets processed" + } + }, + "empty_bucket_count": { + "type": "long", + "_meta": { + "description": "The number of buckets which did not contain any data" + } + }, + "input_bytes": { + "type": "long", + "_meta": { + "description": "The number of bytes of input data posted to the anomaly detection job" + } + }, + "input_record_count": { + "type": "long", + "_meta": { + "description": "The number of input documents posted to the anomaly detection job" + } + }, + "last_data_time": { + "type": "long", + "_meta": { + "description": "The timestamp at which data was last analyzed, according to server time" + } + }, + "processed_record_count": { + "type": "long", + "_meta": { + "description": "The number of input documents that have been processed by the anomaly detection job" + } + } + } + }, + "model_size_stats": { "properties": { - "average_search_time_per_bucket_ms": { + "bucket_allocation_failures_count": { + "type": "long", + "_meta": { + "description": "The number of buckets for which new entities in incoming data were not processed due to insufficient model memory" + } + }, + "model_bytes": { + "type": "long", + "_meta": { + "description": "The number of bytes of memory used by the models" + } + }, + "model_bytes_exceeded": { + "type": "long", + "_meta": { + "description": "The number of bytes over the high limit for memory usage at the last allocation failure" + } + }, + "model_bytes_memory_limit": { "type": "long", "_meta": { - "description": "The average search time per bucket, in milliseconds" + "description": "The upper limit for model memory usage, checked on increasing values" } }, + "peak_model_bytes": { + "type": "long", + "_meta": { + "description": "The peak number of bytes of memory ever used by the models" + } + } + } + }, + "timing_stats": { + "properties": { "bucket_count": { "type": "long", "_meta": { "description": "The number of buckets processed" } }, - "exponential_average_search_time_per_hour_ms": { + "exponential_average_bucket_processing_time_ms": { + "type": "long", + "_meta": { + "description": "Exponential moving average of all bucket processing times, in milliseconds" + } + }, + "exponential_average_bucket_processing_time_per_hour_ms": { "type": "long", "_meta": { - "description": "The exponential average search time per hour, in milliseconds" + "description": "Exponentially-weighted moving average of bucket processing times calculated in a 1 hour time window, in milliseconds" } }, - "search_count": { + "maximum_bucket_processing_time_ms": { "type": "long", "_meta": { - "description": "The number of searches run by the datafeed" + "description": "Maximum among all bucket processing times, in milliseconds" } }, - "total_search_time_ms": { + "minimum_bucket_processing_time_ms": { "type": "long", "_meta": { - "description": "The total time the datafeed spent searching, in milliseconds" + "description": "Minimum among all bucket processing times, in milliseconds" + } + }, + "total_bucket_processing_time_ms": { + "type": "long", + "_meta": { + "description": "Sum of all bucket processing times, in milliseconds" + } + } + } + }, + "datafeed": { + "properties": { + "datafeed_id": { + "type": "keyword", + "_meta": { + "description": "A numerical character string that uniquely identifies the datafeed" + } + }, + "state": { + "type": "keyword", + "_meta": { + "description": "The status of the datafeed" + } + }, + "timing_stats": { + "properties": { + "average_search_time_per_bucket_ms": { + "type": "long", + "_meta": { + "description": "The average search time per bucket, in milliseconds" + } + }, + "bucket_count": { + "type": "long", + "_meta": { + "description": "The number of buckets processed" + } + }, + "exponential_average_search_time_per_hour_ms": { + "type": "long", + "_meta": { + "description": "The exponential average search time per hour, in milliseconds" + } + }, + "search_count": { + "type": "long", + "_meta": { + "description": "The number of searches run by the datafeed" + } + }, + "total_search_time_ms": { + "type": "long", + "_meta": { + "description": "The total time the datafeed spent searching, in milliseconds" + } + } } } } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f694b2b39c605f..d829c8eb22a98b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9490,7 +9490,7 @@ "xpack.fleet.settings.cancelButtonLabel": "キャンセル", "xpack.fleet.settings.elasticHostError": "無効なURL", "xpack.fleet.settings.elasticsearchUrlLabel": "Elasticsearchホスト", - "xpack.fleet.settings.elasticsearchUrlsHelpTect": "エージェントがデータを送信するElasticsearch URLを指定します。", + "xpack.fleet.settings.elasticsearchUrlsHelpTect": "エージェントがデータを送信するElasticsearch URLを指定します。{link}を参照してください。", "xpack.fleet.settings.fleetServerHostsDifferentPathOrProtocolError": "各URLのプロトコルとパスは同じでなければなりません", "xpack.fleet.settings.fleetServerHostsEmptyError": "1つ以上のURLが必要です。", "xpack.fleet.settings.fleetServerHostsError": "無効なURL", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 36985a729ec2f1..a7cd8b5fe8d515 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9575,7 +9575,7 @@ "xpack.fleet.settings.cancelButtonLabel": "取消", "xpack.fleet.settings.elasticHostError": "URL 无效", "xpack.fleet.settings.elasticsearchUrlLabel": "Elasticsearch 主机", - "xpack.fleet.settings.elasticsearchUrlsHelpTect": "指定代理用于发送数据的 Elasticsearch URL。", + "xpack.fleet.settings.elasticsearchUrlsHelpTect": "指定代理用于发送数据的 Elasticsearch URL。请参阅 {link}。", "xpack.fleet.settings.fleetServerHostsDifferentPathOrProtocolError": "对于每个 URL,协议和路径必须相同", "xpack.fleet.settings.fleetServerHostsEmptyError": "至少需要一个 URL", "xpack.fleet.settings.fleetServerHostsError": "URL 无效", diff --git a/x-pack/plugins/uptime/server/kibana.index.ts b/x-pack/plugins/uptime/server/kibana.index.ts index 0afe804de9717b..82ba70155608c8 100644 --- a/x-pack/plugins/uptime/server/kibana.index.ts +++ b/x-pack/plugins/uptime/server/kibana.index.ts @@ -50,7 +50,12 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor read: [], }, alerting: { - all: ['xpack.uptime.alerts.tls', 'xpack.uptime.alerts.monitorStatus'], + rule: { + all: ['xpack.uptime.alerts.tls', 'xpack.uptime.alerts.monitorStatus'], + }, + alert: { + all: ['xpack.uptime.alerts.tls', 'xpack.uptime.alerts.monitorStatus'], + }, }, management: { insightsAndAlerting: ['triggersActions'], @@ -66,7 +71,12 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor read: [umDynamicSettings.name], }, alerting: { - read: ['xpack.uptime.alerts.tls', 'xpack.uptime.alerts.monitorStatus'], + rule: { + read: ['xpack.uptime.alerts.tls', 'xpack.uptime.alerts.monitorStatus'], + }, + alert: { + read: ['xpack.uptime.alerts.tls', 'xpack.uptime.alerts.monitorStatus'], + }, }, management: { insightsAndAlerting: ['triggersActions'], diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 8647c5951b7f35..c56e8adfbe34fb 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -19,10 +19,11 @@ interface CreateTestConfigOptions { disabledPlugins?: string[]; ssl?: boolean; enableActionsProxy: boolean; - rejectUnauthorized?: boolean; + verificationMode?: 'full' | 'none' | 'certificate'; publicBaseUrl?: boolean; preconfiguredAlertHistoryEsIndex?: boolean; customizeLocalHostTls?: boolean; + rejectUnauthorized?: boolean; // legacy } // test.not-enabled is specifically not enabled @@ -49,9 +50,10 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) license = 'trial', disabledPlugins = [], ssl = false, - rejectUnauthorized = true, + verificationMode = 'full', preconfiguredAlertHistoryEsIndex = false, customizeLocalHostTls = false, + rejectUnauthorized = true, // legacy } = options; return async ({ readConfigFile }: FtrConfigProviderContext) => { @@ -101,19 +103,19 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) { url: tlsWebhookServers.rejectUnauthorizedFalse, tls: { - rejectUnauthorized: false, + verificationMode: 'none', }, }, { url: tlsWebhookServers.rejectUnauthorizedTrue, tls: { - rejectUnauthorized: true, + verificationMode: 'full', }, }, { url: tlsWebhookServers.caFile, tls: { - rejectUnauthorized: true, + verificationMode: 'certificate', certificateAuthoritiesFiles: [CA_CERT_PATH], }, }, @@ -151,6 +153,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) '--xpack.alerting.invalidateApiKeysTask.interval="15s"', `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, `--xpack.actions.rejectUnauthorized=${rejectUnauthorized}`, + `--xpack.actions.tls.verificationMode=${verificationMode}`, ...actionsProxyUrl, ...customHostSettings, '--xpack.eventLog.logEntries=true', diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/plugin.ts index 9a7cd8d333b44a..e98b7af075d643 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/plugin.ts @@ -69,21 +69,23 @@ export class FixturePlugin implements Plugin> = {} + ): Promise { + const url = formatUrl(new URL(webhookSimulatorURL), { auth: false }); + const composedConfig = { + headers: { + 'Content-Type': 'text/plain', + }, + ...config, + url, + }; + + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'test') + .send({ + name: 'A generic Webhook action', + actionTypeId: '.webhook', + secrets: {}, + config: composedConfig, + }) + .expect(200); + + return createdAction.id; + } + + async function getPortOfConnector(connectorId: string): Promise { + const response = await supertest.get(`/api/actions/connectors`).expect(200); + const connector = response.body.find((conn: { id: string }) => conn.id === connectorId); + if (connector === undefined) { + throw new Error(`unable to find connector with id ${connectorId}`); + } + + // server URL is the connector name + const url = connector.name; + const parsedUrl = new URL(url); + return parsedUrl.port; + } + + describe('webhook action', () => { + describe('with http endpoint', () => { + let webhookSimulatorURL: string = ''; + let webhookServer: http.Server; + before(async () => { + webhookServer = await getWebhookServer(); + const availablePort = await getPort({ port: 9000 }); + webhookServer.listen(availablePort); + webhookSimulatorURL = `http://localhost:${availablePort}`; + }); + + it('webhook can be executed without username and password', async () => { + const webhookActionId = await createWebhookAction(webhookSimulatorURL); + const { body: result } = await supertest + .post(`/api/actions/action/${webhookActionId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'success', + }, + }) + .expect(200); + + expect(result.status).to.eql('ok'); + }); + + after(() => { + webhookServer.close(); + }); + }); + + describe('with https endpoint and rejectUnauthorized=false', () => { + let webhookSimulatorURL: string = ''; + let webhookServer: https.Server; + + before(async () => { + webhookServer = await getHttpsWebhookServer(); + const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) }); + webhookServer.listen(availablePort); + webhookSimulatorURL = `https://localhost:${availablePort}`; + }); + + it('should support the POST method against webhook target', async () => { + const webhookActionId = await createWebhookAction(webhookSimulatorURL, { method: 'post' }); + const { body: result } = await supertest + .post(`/api/actions/action/${webhookActionId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'success_post_method', + }, + }) + .expect(200); + + expect(result.status).to.eql('ok'); + }); + + after(() => { + webhookServer.close(); + }); + }); + + describe('tls customization', () => { + it('should handle the xpack.actions.rejectUnauthorized: false', async () => { + const connectorId = 'custom.tls.noCustom'; + const port = await getPortOfConnector(connectorId); + const server = await createTlsWebhookServer(port); + const { status, body } = await supertest + .post(`/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'foo', + }, + }); + expect(status).to.eql(200); + server.close(); + + expect(body.status).to.eql('ok'); + }); + + it('should handle the customized rejectUnauthorized: false', async () => { + const connectorId = 'custom.tls.rejectUnauthorizedFalse'; + const port = await getPortOfConnector(connectorId); + const server = await createTlsWebhookServer(port); + const { status, body } = await supertest + .post(`/api/actions/connector/custom.tls.rejectUnauthorizedFalse/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'foo', + }, + }); + expect(status).to.eql(200); + server.close(); + + expect(body.status).to.eql('ok'); + }); + + it('should handle the customized rejectUnauthorized: true', async () => { + const connectorId = 'custom.tls.rejectUnauthorizedTrue'; + const port = await getPortOfConnector(connectorId); + const server = await createTlsWebhookServer(port); + const { status, body } = await supertest + .post(`/api/actions/connector/custom.tls.rejectUnauthorizedTrue/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'foo', + }, + }); + expect(status).to.eql(200); + server.close(); + + expect(body.status).to.eql('error'); + expect(body.service_message.indexOf('certificate')).to.be.greaterThan(0); + }); + + it('should handle the customized ca file', async () => { + const connectorId = 'custom.tls.caFile'; + const port = await getPortOfConnector(connectorId); + const server = await createTlsWebhookServer(port); + const { status, body } = await supertest + .post(`/api/actions/connector/custom.tls.caFile/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'foo', + }, + }); + expect(status).to.eql(200); + server.close(); + + expect(body.status).to.eql('ok'); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only_legacy/tests/index.ts b/x-pack/test/alerting_api_integration/spaces_only_legacy/tests/index.ts new file mode 100644 index 00000000000000..a5a046dcbbe86f --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only_legacy/tests/index.ts @@ -0,0 +1,33 @@ +/* + * 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 { Spaces } from '../scenarios'; + +// eslint-disable-next-line import/no-default-export +export default function alertingApiIntegrationTests({ loadTestFile }: FtrProviderContext) { + describe('alerting api integration spaces only legacy configuration', function () { + this.tags('ciGroup12'); + + loadTestFile(require.resolve('./actions/builtin_action_types/webhook')); + }); +} + +export async function buildUp(getService: FtrProviderContext['getService']) { + const spacesService = getService('spaces'); + for (const space of Object.values(Spaces)) { + if (space.id === 'default') continue; + + const { id, name, disabledFeatures } = space; + await spacesService.create({ id, name, disabledFeatures }); + } +} + +export async function tearDown(getService: FtrProviderContext['getService']) { + const esArchiver = getService('esArchiver'); + await esArchiver.unload('empty_kibana'); +} diff --git a/x-pack/test/functional/apps/reporting/index.ts b/x-pack/test/functional/apps/reporting/index.ts new file mode 100644 index 00000000000000..286693f01ac52d --- /dev/null +++ b/x-pack/test/functional/apps/reporting/index.ts @@ -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. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Reporting', function () { + loadTestFile(require.resolve('./reporting')); + }); +} diff --git a/x-pack/test/functional/apps/reporting/reporting.ts b/x-pack/test/functional/apps/reporting/reporting.ts new file mode 100644 index 00000000000000..a9d089e9fd3978 --- /dev/null +++ b/x-pack/test/functional/apps/reporting/reporting.ts @@ -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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const pageObjects = getPageObjects(['dashboard', 'common', 'reporting']); + const es = getService('es'); + const esArchiver = getService('esArchiver'); + + describe('Reporting', function () { + this.tags(['smoke', 'ciGroup2']); + before(async () => { + await esArchiver.loadIfNeeded('packaging'); + }); + + after(async () => { + await esArchiver.unload('packaging'); + await es.deleteByQuery({ + index: '.reporting-*', + refresh: true, + body: { query: { match_all: {} } }, + }); + }); + + it('downloaded PDF has OK status', async function () { + this.timeout(180000); + + await pageObjects.common.navigateToApp('dashboards'); + await pageObjects.dashboard.loadSavedDashboard('dashboard'); + await pageObjects.reporting.openPdfReportingPanel(); + await pageObjects.reporting.clickGenerateReportButton(); + + const url = await pageObjects.reporting.getReportURL(60000); + const res = await pageObjects.reporting.getResponse(url); + + expect(res.status).to.equal(200); + expect(res.get('content-type')).to.equal('application/pdf'); + }); + }); +} diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 573350dad24d0f..ee5be48a07663c 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -59,6 +59,7 @@ export default async function ({ readConfigFile }) { resolve(__dirname, './apps/transform'), resolve(__dirname, './apps/reporting_management'), resolve(__dirname, './apps/management'), + resolve(__dirname, './apps/reporting'), // This license_management file must be last because it is destructive. resolve(__dirname, './apps/license_management'), diff --git a/x-pack/test/functional/es_archives/packaging/data.json.gz b/x-pack/test/functional/es_archives/packaging/data.json.gz new file mode 100644 index 00000000000000..69c9e4cb4d8a22 Binary files /dev/null and b/x-pack/test/functional/es_archives/packaging/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/packaging/mappings.json b/x-pack/test/functional/es_archives/packaging/mappings.json new file mode 100644 index 00000000000000..182d281c9a3ded --- /dev/null +++ b/x-pack/test/functional/es_archives/packaging/mappings.json @@ -0,0 +1,2561 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": { + } + }, + "index": ".kibana_1", + "mappings": { + "_meta": { + "migrationMappingPropertyHashes": { + "action": "6e96ac5e648f57523879661ea72525b7", + "action_task_params": "a9d49f184ee89641044be0ca2950fa3a", + "alert": "0359d7fcc04da9878ee9aadbda38ba55", + "api_key_pending_invalidation": "16f515278a295f6245149ad7c5ddedb7", + "apm-indices": "9bb9b2bf1fa636ed8619cbab5ce6a1dd", + "apm-telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "app_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "application_usage_daily": "43b8830d5d0df85a6823d290885fc9fd", + "application_usage_totals": "3d1b76c39bfb2cc8296b024d73854724", + "application_usage_transactional": "3d1b76c39bfb2cc8296b024d73854724", + "background-session": "721df406dbb7e35ac22e4df6c3ad2b2a", + "canvas-element": "7390014e1091044523666d97247392fc", + "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", + "canvas-workpad-template": "ae2673f678281e2c055d764b153e9715", + "cases": "477f214ff61acc3af26a7b7818e380c1", + "cases-comments": "8a50736330e953bca91747723a319593", + "cases-configure": "387c5f3a3bda7e0ae0dd4e106f914a69", + "cases-user-actions": "32277330ec6b721abe3b846cfd939a71", + "config": "c63748b75f39d0c54de12d12c1ccbc20", + "dashboard": "40554caf09725935e2c02e02563a2d07", + "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", + "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "enterprise_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "epm-packages": "2b83397e3eaaaa8ef15e38813f3721c3", + "exception-list": "67f055ab8c10abd7b2ebfd969b836788", + "exception-list-agnostic": "67f055ab8c10abd7b2ebfd969b836788", + "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e", + "fleet-agent-actions": "9511b565b1cc6441a42033db3d5de8e9", + "fleet-agent-events": "e20a508b6e805189356be381dbfac8db", + "fleet-agents": "cb661e8ede2b640c42c8e5ef99db0683", + "fleet-enrollment-api-keys": "a69ef7ae661dab31561d6c6f052ef2a7", + "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", + "index-pattern": "45915a1ad866812242df474eb0479052", + "infrastructure-ui-source": "3d1b76c39bfb2cc8296b024d73854724", + "ingest-agent-policies": "8b0733cce189659593659dad8db426f0", + "ingest-outputs": "8854f34453a47e26f86a29f8f3b80b4e", + "ingest-package-policies": "f74dfe498e1849267cda41580b2be110", + "ingest_manager_settings": "02a03095f0e05b7a538fa801b88a217f", + "inventory-view": "3d1b76c39bfb2cc8296b024d73854724", + "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", + "lens": "52346cfec69ff7b47d5f0c12361a2797", + "lens-ui-telemetry": "509bfa5978586998e05f9e303c07a327", + "map": "4a05b35c3a3a58fbc72dd0202dc3487f", + "maps-telemetry": "5ef305b18111b77789afefbd36b66171", + "metrics-explorer-view": "3d1b76c39bfb2cc8296b024d73854724", + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "ml-job": "3bb64c31915acf93fc724af137a0891b", + "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", + "monitoring-telemetry": "2669d5ec15e82391cf58df4294ee9c68", + "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", + "originId": "2f4316de49999235636386fe51dc06c1", + "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", + "search": "43012c7ebc4cb57054e0a490e4b43023", + "search-telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "siem-detection-engine-rule-actions": "6569b288c169539db10cb262bf79de18", + "siem-detection-engine-rule-status": "ae783f41c6937db6b7a2ef5c93a9e9b0", + "siem-ui-timeline": "d12c5474364d737d17252acf1dc4585c", + "siem-ui-timeline-note": "8874706eedc49059d4cf0f5094559084", + "siem-ui-timeline-pinned-event": "20638091112f0e14f0e443d512301c29", + "space": "c5ca8acafa0beaa4d08d014a97b6bc6b", + "tag": "83d55da58f6530f7055415717ec06474", + "telemetry": "36a616f7026dfa617d6655df850fe16d", + "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", + "tsvb-validation-telemetry": "3a37ef6c8700ae6fc97d5c7da00e9215", + "type": "2f4316de49999235636386fe51dc06c1", + "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "upgrade-assistant-reindex-operation": "215107c281839ea9b3ad5f6419819763", + "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", + "uptime-dynamic-settings": "3d1b76c39bfb2cc8296b024d73854724", + "url": "c7f66a0df8b1b52f17c28c4adb111105", + "visualization": "f819cf6636b75c9e76ba733a0c6ef355", + "workplace_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724" + } + }, + "dynamic": "strict", + "properties": { + "action": { + "properties": { + "actionTypeId": { + "type": "keyword" + }, + "config": { + "enabled": false, + "type": "object" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "secrets": { + "type": "binary" + } + } + }, + "action_task_params": { + "properties": { + "actionId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alert": { + "properties": { + "actions": { + "properties": { + "actionRef": { + "type": "keyword" + }, + "actionTypeId": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + }, + "type": "nested" + }, + "alertTypeId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "apiKeyOwner": { + "type": "keyword" + }, + "consumer": { + "type": "keyword" + }, + "createdAt": { + "type": "date" + }, + "createdBy": { + "type": "keyword" + }, + "enabled": { + "type": "boolean" + }, + "executionStatus": { + "properties": { + "error": { + "properties": { + "message": { + "type": "keyword" + }, + "reason": { + "type": "keyword" + } + } + }, + "lastExecutionDate": { + "type": "date" + }, + "status": { + "type": "keyword" + } + } + }, + "meta": { + "properties": { + "versionApiKeyLastmodified": { + "type": "keyword" + } + } + }, + "muteAll": { + "type": "boolean" + }, + "mutedInstanceIds": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "params": { + "enabled": false, + "type": "object" + }, + "schedule": { + "properties": { + "interval": { + "type": "keyword" + } + } + }, + "scheduledTaskId": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "throttle": { + "type": "keyword" + }, + "updatedAt": { + "type": "date" + }, + "updatedBy": { + "type": "keyword" + } + } + }, + "api_key_pending_invalidation": { + "properties": { + "apiKeyId": { + "type": "keyword" + }, + "createdAt": { + "type": "date" + } + } + }, + "apm-indices": { + "properties": { + "apm_oss": { + "properties": { + "errorIndices": { + "type": "keyword" + }, + "metricsIndices": { + "type": "keyword" + }, + "onboardingIndices": { + "type": "keyword" + }, + "sourcemapIndices": { + "type": "keyword" + }, + "spanIndices": { + "type": "keyword" + }, + "transactionIndices": { + "type": "keyword" + } + } + } + } + }, + "apm-telemetry": { + "dynamic": "false", + "type": "object" + }, + "app_search_telemetry": { + "dynamic": "false", + "type": "object" + }, + "application_usage_daily": { + "dynamic": "false", + "properties": { + "timestamp": { + "type": "date" + } + } + }, + "application_usage_totals": { + "dynamic": "false", + "type": "object" + }, + "application_usage_transactional": { + "dynamic": "false", + "type": "object" + }, + "background-session": { + "properties": { + "created": { + "type": "date" + }, + "expires": { + "type": "date" + }, + "idMapping": { + "enabled": false, + "type": "object" + }, + "initialState": { + "enabled": false, + "type": "object" + }, + "name": { + "type": "keyword" + }, + "restoreState": { + "enabled": false, + "type": "object" + }, + "status": { + "type": "keyword" + } + } + }, + "canvas-element": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "content": { + "type": "text" + }, + "help": { + "type": "text" + }, + "image": { + "type": "text" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "canvas-workpad-template": { + "dynamic": "false", + "properties": { + "help": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "tags": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "template_key": { + "type": "keyword" + } + } + }, + "cases": { + "properties": { + "closed_at": { + "type": "date" + }, + "closed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "connector": { + "properties": { + "fields": { + "properties": { + "key": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "id": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "description": { + "type": "text" + }, + "external_service": { + "properties": { + "connector_id": { + "type": "keyword" + }, + "connector_name": { + "type": "keyword" + }, + "external_id": { + "type": "keyword" + }, + "external_title": { + "type": "text" + }, + "external_url": { + "type": "text" + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "status": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-comments": { + "properties": { + "alertId": { + "type": "keyword" + }, + "comment": { + "type": "text" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "index": { + "type": "keyword" + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-configure": { + "properties": { + "closure_type": { + "type": "keyword" + }, + "connector": { + "properties": { + "fields": { + "properties": { + "key": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "id": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-user-actions": { + "properties": { + "action": { + "type": "keyword" + }, + "action_at": { + "type": "date" + }, + "action_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "action_field": { + "type": "keyword" + }, + "new_value": { + "type": "text" + }, + "old_value": { + "type": "text" + } + } + }, + "config": { + "dynamic": "false", + "properties": { + "buildNum": { + "type": "keyword" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "doc_values": false, + "index": false, + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "index": false, + "type": "text" + } + } + }, + "optionsJSON": { + "index": false, + "type": "text" + }, + "panelsJSON": { + "index": false, + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "pause": { + "doc_values": false, + "index": false, + "type": "boolean" + }, + "section": { + "doc_values": false, + "index": false, + "type": "integer" + }, + "value": { + "doc_values": false, + "index": false, + "type": "integer" + } + } + }, + "timeFrom": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "timeRestore": { + "doc_values": false, + "index": false, + "type": "boolean" + }, + "timeTo": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "endpoint:user-artifact": { + "properties": { + "body": { + "type": "binary" + }, + "compressionAlgorithm": { + "index": false, + "type": "keyword" + }, + "created": { + "index": false, + "type": "date" + }, + "decodedSha256": { + "index": false, + "type": "keyword" + }, + "decodedSize": { + "index": false, + "type": "long" + }, + "encodedSha256": { + "type": "keyword" + }, + "encodedSize": { + "index": false, + "type": "long" + }, + "encryptionAlgorithm": { + "index": false, + "type": "keyword" + }, + "identifier": { + "type": "keyword" + } + } + }, + "endpoint:user-artifact-manifest": { + "properties": { + "created": { + "index": false, + "type": "date" + }, + "ids": { + "index": false, + "type": "keyword" + }, + "schemaVersion": { + "type": "keyword" + }, + "semanticVersion": { + "index": false, + "type": "keyword" + } + } + }, + "enterprise_search_telemetry": { + "dynamic": "false", + "type": "object" + }, + "epm-packages": { + "properties": { + "es_index_patterns": { + "enabled": false, + "type": "object" + }, + "install_source": { + "type": "keyword" + }, + "install_started_at": { + "type": "date" + }, + "install_status": { + "type": "keyword" + }, + "install_version": { + "type": "keyword" + }, + "installed_es": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "installed_kibana": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "internal": { + "type": "boolean" + }, + "name": { + "type": "keyword" + }, + "removable": { + "type": "boolean" + }, + "version": { + "type": "keyword" + } + } + }, + "exception-list": { + "properties": { + "_tags": { + "type": "keyword" + }, + "comments": { + "properties": { + "comment": { + "type": "keyword" + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "updated_at": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "entries": { + "properties": { + "entries": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "field": { + "type": "keyword" + }, + "list": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "immutable": { + "type": "boolean" + }, + "item_id": { + "type": "keyword" + }, + "list_id": { + "type": "keyword" + }, + "list_type": { + "type": "keyword" + }, + "meta": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "os_types": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "tie_breaker_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "exception-list-agnostic": { + "properties": { + "_tags": { + "type": "keyword" + }, + "comments": { + "properties": { + "comment": { + "type": "keyword" + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "updated_at": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "entries": { + "properties": { + "entries": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "field": { + "type": "keyword" + }, + "list": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "fields": { + "text": { + "type": "text" + } + }, + "type": "keyword" + } + } + }, + "immutable": { + "type": "boolean" + }, + "item_id": { + "type": "keyword" + }, + "list_id": { + "type": "keyword" + }, + "list_type": { + "type": "keyword" + }, + "meta": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "os_types": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "tie_breaker_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "file-upload-telemetry": { + "properties": { + "filesUploadedTotalCount": { + "type": "long" + } + } + }, + "fleet-agent-actions": { + "properties": { + "ack_data": { + "type": "text" + }, + "agent_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "data": { + "type": "binary" + }, + "policy_id": { + "type": "keyword" + }, + "policy_revision": { + "type": "integer" + }, + "sent_at": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + }, + "fleet-agent-events": { + "properties": { + "action_id": { + "type": "keyword" + }, + "agent_id": { + "type": "keyword" + }, + "data": { + "type": "text" + }, + "message": { + "type": "text" + }, + "payload": { + "type": "text" + }, + "policy_id": { + "type": "keyword" + }, + "stream_id": { + "type": "keyword" + }, + "subtype": { + "type": "keyword" + }, + "timestamp": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + }, + "fleet-agents": { + "properties": { + "access_api_key_id": { + "type": "keyword" + }, + "active": { + "type": "boolean" + }, + "current_error_events": { + "index": false, + "type": "text" + }, + "default_api_key": { + "type": "binary" + }, + "default_api_key_id": { + "type": "keyword" + }, + "enrolled_at": { + "type": "date" + }, + "last_checkin": { + "type": "date" + }, + "last_checkin_status": { + "type": "keyword" + }, + "last_updated": { + "type": "date" + }, + "local_metadata": { + "type": "flattened" + }, + "packages": { + "type": "keyword" + }, + "policy_id": { + "type": "keyword" + }, + "policy_revision": { + "type": "integer" + }, + "shared_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "unenrolled_at": { + "type": "date" + }, + "unenrollment_started_at": { + "type": "date" + }, + "updated_at": { + "type": "date" + }, + "upgrade_started_at": { + "type": "date" + }, + "upgraded_at": { + "type": "date" + }, + "user_provided_metadata": { + "type": "flattened" + }, + "version": { + "type": "keyword" + } + } + }, + "fleet-enrollment-api-keys": { + "properties": { + "active": { + "type": "boolean" + }, + "api_key": { + "type": "binary" + }, + "api_key_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "expire_at": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "policy_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "dynamic": "false", + "properties": { + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "infrastructure-ui-source": { + "dynamic": "false", + "type": "object" + }, + "ingest-agent-policies": { + "properties": { + "description": { + "type": "text" + }, + "is_default": { + "type": "boolean" + }, + "monitoring_enabled": { + "index": false, + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "package_policies": { + "type": "keyword" + }, + "revision": { + "type": "integer" + }, + "status": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "ingest-outputs": { + "properties": { + "ca_sha256": { + "index": false, + "type": "keyword" + }, + "config": { + "type": "flattened" + }, + "config_yaml": { + "type": "text" + }, + "fleet_enroll_password": { + "type": "binary" + }, + "fleet_enroll_username": { + "type": "binary" + }, + "hosts": { + "type": "keyword" + }, + "is_default": { + "type": "boolean" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "ingest-package-policies": { + "properties": { + "created_at": { + "type": "date" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "enabled": { + "type": "boolean" + }, + "inputs": { + "enabled": false, + "properties": { + "config": { + "type": "flattened" + }, + "enabled": { + "type": "boolean" + }, + "streams": { + "properties": { + "compiled_stream": { + "type": "flattened" + }, + "config": { + "type": "flattened" + }, + "data_stream": { + "properties": { + "dataset": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "vars": { + "type": "flattened" + } + }, + "type": "nested" + }, + "type": { + "type": "keyword" + }, + "vars": { + "type": "flattened" + } + }, + "type": "nested" + }, + "name": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "output_id": { + "type": "keyword" + }, + "package": { + "properties": { + "name": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "policy_id": { + "type": "keyword" + }, + "revision": { + "type": "integer" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "ingest_manager_settings": { + "properties": { + "agent_auto_upgrade": { + "type": "keyword" + }, + "has_seen_add_data_notice": { + "index": false, + "type": "boolean" + }, + "kibana_ca_sha256": { + "type": "keyword" + }, + "kibana_urls": { + "type": "keyword" + }, + "package_auto_upgrade": { + "type": "keyword" + } + } + }, + "inventory-view": { + "dynamic": "false", + "type": "object" + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "lens": { + "properties": { + "description": { + "type": "text" + }, + "expression": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "state": { + "type": "flattened" + }, + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + } + } + }, + "lens-ui-telemetry": { + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "map": { + "properties": { + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "maps-telemetry": { + "enabled": false, + "type": "object" + }, + "metrics-explorer-view": { + "dynamic": "false", + "type": "object" + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "config": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "dashboard": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "index-pattern": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "lens": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "search": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "space": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "ml-job": { + "properties": { + "datafeed_id": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "job_id": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "ml-telemetry": { + "properties": { + "file_data_visualizer": { + "properties": { + "index_creation_count": { + "type": "long" + } + } + } + } + }, + "monitoring-telemetry": { + "properties": { + "reportedClusterUuids": { + "type": "keyword" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "originId": { + "type": "keyword" + }, + "query": { + "properties": { + "description": { + "type": "text" + }, + "filters": { + "enabled": false, + "type": "object" + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "index": false, + "type": "keyword" + } + } + }, + "timefilter": { + "enabled": false, + "type": "object" + }, + "title": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "sample-data-telemetry": { + "properties": { + "installCount": { + "type": "long" + }, + "unInstallCount": { + "type": "long" + } + } + }, + "search": { + "properties": { + "columns": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "doc_values": false, + "index": false, + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "index": false, + "type": "text" + } + } + }, + "sort": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "search-telemetry": { + "dynamic": "false", + "type": "object" + }, + "siem-detection-engine-rule-actions": { + "properties": { + "actions": { + "properties": { + "action_type_id": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alertThrottle": { + "type": "keyword" + }, + "ruleAlertId": { + "type": "keyword" + }, + "ruleThrottle": { + "type": "keyword" + } + } + }, + "siem-detection-engine-rule-status": { + "properties": { + "alertId": { + "type": "keyword" + }, + "bulkCreateTimeDurations": { + "type": "float" + }, + "gap": { + "type": "text" + }, + "lastFailureAt": { + "type": "date" + }, + "lastFailureMessage": { + "type": "text" + }, + "lastLookBackDate": { + "type": "date" + }, + "lastSuccessAt": { + "type": "date" + }, + "lastSuccessMessage": { + "type": "text" + }, + "searchAfterTimeDurations": { + "type": "float" + }, + "status": { + "type": "keyword" + }, + "statusDate": { + "type": "date" + } + } + }, + "siem-ui-timeline": { + "properties": { + "columns": { + "properties": { + "aggregatable": { + "type": "boolean" + }, + "category": { + "type": "keyword" + }, + "columnHeaderType": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "example": { + "type": "text" + }, + "id": { + "type": "keyword" + }, + "indexes": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "placeholder": { + "type": "text" + }, + "searchable": { + "type": "boolean" + }, + "type": { + "type": "keyword" + } + } + }, + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "dataProviders": { + "properties": { + "and": { + "properties": { + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "type": { + "type": "text" + } + } + }, + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "type": { + "type": "text" + } + } + }, + "dateRange": { + "properties": { + "end": { + "type": "date" + }, + "start": { + "type": "date" + } + } + }, + "description": { + "type": "text" + }, + "eventType": { + "type": "keyword" + }, + "excludedRowRendererIds": { + "type": "text" + }, + "favorite": { + "properties": { + "favoriteDate": { + "type": "date" + }, + "fullName": { + "type": "text" + }, + "keySearch": { + "type": "text" + }, + "userName": { + "type": "text" + } + } + }, + "filters": { + "properties": { + "exists": { + "type": "text" + }, + "match_all": { + "type": "text" + }, + "meta": { + "properties": { + "alias": { + "type": "text" + }, + "controlledBy": { + "type": "text" + }, + "disabled": { + "type": "boolean" + }, + "field": { + "type": "text" + }, + "formattedValue": { + "type": "text" + }, + "index": { + "type": "keyword" + }, + "key": { + "type": "keyword" + }, + "negate": { + "type": "boolean" + }, + "params": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "value": { + "type": "text" + } + } + }, + "missing": { + "type": "text" + }, + "query": { + "type": "text" + }, + "range": { + "type": "text" + }, + "script": { + "type": "text" + } + } + }, + "indexNames": { + "type": "text" + }, + "kqlMode": { + "type": "keyword" + }, + "kqlQuery": { + "properties": { + "filterQuery": { + "properties": { + "kuery": { + "properties": { + "expression": { + "type": "text" + }, + "kind": { + "type": "keyword" + } + } + }, + "serializedQuery": { + "type": "text" + } + } + } + } + }, + "savedQueryId": { + "type": "keyword" + }, + "sort": { + "properties": { + "columnId": { + "type": "keyword" + }, + "sortDirection": { + "type": "keyword" + } + } + }, + "status": { + "type": "keyword" + }, + "templateTimelineId": { + "type": "text" + }, + "templateTimelineVersion": { + "type": "integer" + }, + "timelineType": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-note": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "note": { + "type": "text" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-pinned-event": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "imageUrl": { + "index": false, + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "tag": { + "properties": { + "color": { + "type": "text" + }, + "description": { + "type": "text" + }, + "name": { + "type": "text" + } + } + }, + "telemetry": { + "properties": { + "allowChangingOptInStatus": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "lastReported": { + "type": "date" + }, + "lastVersionChecked": { + "type": "keyword" + }, + "reportFailureCount": { + "type": "integer" + }, + "reportFailureVersion": { + "type": "keyword" + }, + "sendUsageFrom": { + "type": "keyword" + }, + "userHasSeenNotice": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "tsvb-validation-telemetry": { + "properties": { + "failedRequests": { + "type": "long" + } + } + }, + "type": { + "type": "keyword" + }, + "ui-metric": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "updated_at": { + "type": "date" + }, + "upgrade-assistant-reindex-operation": { + "properties": { + "errorMessage": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "indexName": { + "type": "keyword" + }, + "lastCompletedStep": { + "type": "long" + }, + "locked": { + "type": "date" + }, + "newIndexName": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "reindexOptions": { + "properties": { + "openAndClose": { + "type": "boolean" + }, + "queueSettings": { + "properties": { + "queuedAt": { + "type": "long" + }, + "startedAt": { + "type": "long" + } + } + } + } + }, + "reindexTaskId": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "reindexTaskPercComplete": { + "type": "float" + }, + "runningReindexCount": { + "type": "integer" + }, + "status": { + "type": "integer" + } + } + }, + "upgrade-assistant-telemetry": { + "properties": { + "features": { + "properties": { + "deprecation_logging": { + "properties": { + "enabled": { + "null_value": true, + "type": "boolean" + } + } + } + } + }, + "ui_open": { + "properties": { + "cluster": { + "null_value": 0, + "type": "long" + }, + "indices": { + "null_value": 0, + "type": "long" + }, + "overview": { + "null_value": 0, + "type": "long" + } + } + }, + "ui_reindex": { + "properties": { + "close": { + "null_value": 0, + "type": "long" + }, + "open": { + "null_value": 0, + "type": "long" + }, + "start": { + "null_value": 0, + "type": "long" + }, + "stop": { + "null_value": 0, + "type": "long" + } + } + } + } + }, + "uptime-dynamic-settings": { + "dynamic": "false", + "type": "object" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "index": false, + "type": "text" + } + } + }, + "savedSearchRefName": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "index": false, + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "index": false, + "type": "text" + } + } + }, + "workplace_search_telemetry": { + "dynamic": "false", + "type": "object" + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} + +{ + "type": "index", + "value": { + "aliases": { + }, + "index": "foo", + "mappings": { + "properties": { + "field": { + "type": "long" + } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} \ No newline at end of file diff --git a/x-pack/test/functional/page_objects/gis_page.ts b/x-pack/test/functional/page_objects/gis_page.ts index 4a898967419b6f..99d7172651a4d1 100644 --- a/x-pack/test/functional/page_objects/gis_page.ts +++ b/x-pack/test/functional/page_objects/gis_page.ts @@ -21,12 +21,12 @@ export function GisPageProvider({ getService, getPageObjects }: FtrProviderConte const comboBox = getService('comboBox'); const renderable = getService('renderable'); const browser = getService('browser'); - const MenuToggle = getService('MenuToggle'); + const menuToggle = getService('menuToggle'); const listingTable = getService('listingTable'); const monacoEditor = getService('monacoEditor'); const dashboardPanelActions = getService('dashboardPanelActions'); - const setViewPopoverToggle = new MenuToggle({ + const setViewPopoverToggle = menuToggle.create({ name: 'SetView Popover', menuTestSubject: 'mapSetViewForm', toggleButtonTestSubject: 'toggleSetViewVisibilityButton', diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts index d51cc3f9f9938f..10a81e43090886 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts @@ -95,7 +95,9 @@ export class AlertingFixturePlugin implements Plugin { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/100697 + describe.skip('Resolver tests for the entity route', () => { describe('winlogbeat tests', () => { before(async () => { await esArchiver.load('endpoint/resolver/winlogbeat'); diff --git a/yarn.lock b/yarn.lock index 24f80c7dcb7b52..3add4843d0966b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2682,7 +2682,7 @@ version "0.0.0" uid "" -"@kbn/monaco@link:packages/kbn-monaco": +"@kbn/monaco@link:bazel-bin/packages/kbn-monaco/npm_module": version "0.0.0" uid ""