diff --git a/.buildkite/pipelines/bazel_cache.yml b/.buildkite/pipelines/bazel_cache.yml index daf56eb712a8d1..9aa961bcddbd29 100644 --- a/.buildkite/pipelines/bazel_cache.yml +++ b/.buildkite/pipelines/bazel_cache.yml @@ -1,5 +1,7 @@ steps: - label: ':pipeline: Create pipeline with priority' + agents: + queue: kibana-default concurrency_group: bazel_macos concurrency: 1 concurrency_method: eager diff --git a/.buildkite/pipelines/es_snapshots/promote.yml b/.buildkite/pipelines/es_snapshots/promote.yml index 5a003321246a18..f2f7b423c94c2e 100644 --- a/.buildkite/pipelines/es_snapshots/promote.yml +++ b/.buildkite/pipelines/es_snapshots/promote.yml @@ -10,3 +10,5 @@ steps: required: true - label: Promote Snapshot command: .buildkite/scripts/steps/es_snapshots/promote.sh + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/es_snapshots/verify.yml b/.buildkite/pipelines/es_snapshots/verify.yml index f98626ef25c012..58908d1578bb5f 100755 --- a/.buildkite/pipelines/es_snapshots/verify.yml +++ b/.buildkite/pipelines/es_snapshots/verify.yml @@ -14,6 +14,8 @@ steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build timeout_in_minutes: 10 + agents: + queue: kibana-default - wait @@ -85,6 +87,8 @@ steps: - command: .buildkite/scripts/steps/es_snapshots/trigger_promote.sh label: Trigger promotion timeout_in_minutes: 10 + agents: + queue: kibana-default depends_on: - default-cigroup - default-cigroup-docker @@ -98,3 +102,5 @@ steps: - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build timeout_in_minutes: 10 + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/flaky_tests/pipeline.js b/.buildkite/pipelines/flaky_tests/pipeline.js index cb5c37bf58348a..b7f93412edb37c 100644 --- a/.buildkite/pipelines/flaky_tests/pipeline.js +++ b/.buildkite/pipelines/flaky_tests/pipeline.js @@ -51,6 +51,9 @@ const pipeline = { { command: '.buildkite/pipelines/flaky_tests/runner.sh', label: 'Create pipeline', + agents: { + queue: 'kibana-default', + }, }, ], }; diff --git a/.buildkite/pipelines/hourly.yml b/.buildkite/pipelines/hourly.yml index 6953c146050ebc..78c57ff3bd1285 100644 --- a/.buildkite/pipelines/hourly.yml +++ b/.buildkite/pipelines/hourly.yml @@ -2,6 +2,8 @@ steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build timeout_in_minutes: 10 + agents: + queue: kibana-default - wait @@ -174,3 +176,5 @@ steps: - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build timeout_in_minutes: 10 + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/on_merge.yml b/.buildkite/pipelines/on_merge.yml index e5f6dcc2d1d5fd..c6acb48b3e212e 100644 --- a/.buildkite/pipelines/on_merge.yml +++ b/.buildkite/pipelines/on_merge.yml @@ -5,6 +5,8 @@ steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build timeout_in_minutes: 10 + agents: + queue: kibana-default - wait @@ -34,3 +36,5 @@ steps: - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build timeout_in_minutes: 10 + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/performance/daily.yml b/.buildkite/pipelines/performance/daily.yml index 208456f9c67a52..564bfb5e501b3e 100644 --- a/.buildkite/pipelines/performance/daily.yml +++ b/.buildkite/pipelines/performance/daily.yml @@ -1,25 +1,27 @@ steps: - - block: ":gear: Performance Tests Configuration" - prompt: "Fill out the details for performance test" + - block: ':gear: Performance Tests Configuration' + prompt: 'Fill out the details for performance test' fields: - - text: ":arrows_counterclockwise: Iterations" - key: "performance-test-iteration-count" - hint: "How many times you want to run tests? " + - text: ':arrows_counterclockwise: Iterations' + key: 'performance-test-iteration-count' + hint: 'How many times you want to run tests? ' required: true if: build.env('PERF_TEST_COUNT') == null - - label: ":male-mechanic::skin-tone-2: Pre-Build" + - label: ':male-mechanic::skin-tone-2: Pre-Build' command: .buildkite/scripts/lifecycle/pre_build.sh + agents: + queue: kibana-default - wait - - label: ":factory_worker: Build Kibana Distribution and Plugins" + - label: ':factory_worker: Build Kibana Distribution and Plugins' command: .buildkite/scripts/steps/build_kibana.sh agents: queue: c2-16 key: build - - label: ":muscle: Performance Tests with Playwright config" + - label: ':muscle: Performance Tests with Playwright config' command: .buildkite/scripts/steps/functional/performance_playwright.sh agents: queue: c2-16 @@ -28,6 +30,7 @@ steps: - wait: ~ continue_on_failure: true - - label: ":male_superhero::skin-tone-2: Post-Build" + - label: ':male_superhero::skin-tone-2: Post-Build' command: .buildkite/scripts/lifecycle/post_build.sh - + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/pull_request.yml b/.buildkite/pipelines/pull_request.yml deleted file mode 100644 index 41c13bb403e1a9..00000000000000 --- a/.buildkite/pipelines/pull_request.yml +++ /dev/null @@ -1,17 +0,0 @@ -env: - GITHUB_COMMIT_STATUS_ENABLED: 'true' - GITHUB_COMMIT_STATUS_CONTEXT: 'buildkite/kibana-pull-request' -steps: - - command: .buildkite/scripts/lifecycle/pre_build.sh - label: Pre-Build - - - wait - - - command: echo 'Hello World' - label: Test - - - wait: ~ - continue_on_failure: true - - - command: .buildkite/scripts/lifecycle/post_build.sh - label: Post-Build diff --git a/.buildkite/pipelines/pull_request/base.yml b/.buildkite/pipelines/pull_request/base.yml index d832717906bb1b..3117ba98078d92 100644 --- a/.buildkite/pipelines/pull_request/base.yml +++ b/.buildkite/pipelines/pull_request/base.yml @@ -2,6 +2,8 @@ steps: - command: .buildkite/scripts/lifecycle/pre_build.sh label: Pre-Build timeout_in_minutes: 10 + agents: + queue: kibana-default - wait diff --git a/.buildkite/pipelines/pull_request/post_build.yml b/.buildkite/pipelines/pull_request/post_build.yml index 4f252bf8abc111..63f7169334584b 100644 --- a/.buildkite/pipelines/pull_request/post_build.yml +++ b/.buildkite/pipelines/pull_request/post_build.yml @@ -4,3 +4,5 @@ steps: - command: .buildkite/scripts/lifecycle/post_build.sh label: Post-Build + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/purge_cloud_deployments.yml b/.buildkite/pipelines/purge_cloud_deployments.yml index 8287abf2ca5a25..9567f67a047f8e 100644 --- a/.buildkite/pipelines/purge_cloud_deployments.yml +++ b/.buildkite/pipelines/purge_cloud_deployments.yml @@ -2,3 +2,5 @@ steps: - command: .buildkite/scripts/steps/cloud/purge.sh label: Purge old cloud deployments timeout_in_minutes: 10 + agents: + queue: kibana-default diff --git a/.buildkite/pipelines/update_demo_env.yml b/.buildkite/pipelines/update_demo_env.yml index e2dfdd782fd413..12c4f296f5dfd0 100644 --- a/.buildkite/pipelines/update_demo_env.yml +++ b/.buildkite/pipelines/update_demo_env.yml @@ -2,6 +2,8 @@ steps: - command: .buildkite/scripts/steps/demo_env/es_and_init.sh label: Initialize Environment and Deploy ES timeout_in_minutes: 10 + agents: + queue: kibana-default - command: .buildkite/scripts/steps/demo_env/kibana.sh label: Build and Deploy Kibana diff --git a/.gitignore b/.gitignore index 818d3a472d52c3..7e451584582380 100644 --- a/.gitignore +++ b/.gitignore @@ -95,4 +95,3 @@ fleet-server-* elastic-agent.yml fleet-server.yml -/x-pack/plugins/fleet/server/bundled_packages diff --git a/.i18nrc.json b/.i18nrc.json index 5c362908a18760..7ec704aab3a7ae 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -66,6 +66,7 @@ "uiActions": "src/plugins/ui_actions", "uiActionsExamples": "examples/ui_action_examples", "usageCollection": "src/plugins/usage_collection", + "utils": "packages/kbn-securitysolution-utils/src", "visDefaultEditor": "src/plugins/vis_default_editor", "visTypeHeatmap": "src/plugins/vis_types/heatmap", "visTypeMarkdown": "src/plugins/vis_type_markdown", diff --git a/docs/api/saved-objects/resolve_import_errors.asciidoc b/docs/api/saved-objects/resolve_import_errors.asciidoc index 7a57e03875e359..162e9589e4f9e0 100644 --- a/docs/api/saved-objects/resolve_import_errors.asciidoc +++ b/docs/api/saved-objects/resolve_import_errors.asciidoc @@ -25,7 +25,7 @@ To resolve errors, you can: ==== Path parameters `space_id`:: - (Optional, string) An identifier for the <>. When `space_id` is unspecfied in the URL, the default space is used. + (Optional, string) An identifier for the <>. When `space_id` is unspecified in the URL, the default space is used. [[saved-objects-api-resolve-import-errors-query-params]] ==== Query parameters diff --git a/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc index d79df2c085b193..9d26f9656d3f60 100644 --- a/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc +++ b/docs/api/spaces-management/resolve_copy_saved_objects_conflicts.asciidoc @@ -68,7 +68,7 @@ Execute the <>, w `id`:::: (Required, string) The saved object ID. `overwrite`:::: - (Required, boolean) When set to `true`, the saved object from the source space (desigated by the <>) overwrites the conflicting object in the destination space. When set to `false`, this does nothing. + (Required, boolean) When set to `true`, the saved object from the source space (designated by the <>) overwrites the conflicting object in the destination space. When set to `false`, this does nothing. `destinationId`:::: (Optional, string) Specifies the destination ID that the copied object should have, if different from the current ID. `ignoreMissingReferences`::: diff --git a/docs/api/upgrade-assistant/default-field.asciidoc b/docs/api/upgrade-assistant/default-field.asciidoc index 8bdcd359d56681..bbe44d894963b9 100644 --- a/docs/api/upgrade-assistant/default-field.asciidoc +++ b/docs/api/upgrade-assistant/default-field.asciidoc @@ -26,7 +26,7 @@ GET /api/upgrade_assistant/add_query_default_field/myIndex // KIBANA <1> A required array of {es} field types that generate the list of fields. -<2> An optional array of additional field names, dot-deliminated. +<2> An optional array of additional field names, dot-delimited. To add the `index.query.default_field` index setting to the specified index, {kib} generates an array of all fields from the index mapping. The fields contain the types specified in `fieldTypes`. {kib} appends any other fields specified in `otherFields` to the array of default fields. diff --git a/docs/apm/service-maps.asciidoc b/docs/apm/service-maps.asciidoc index f76b9976dd1d2e..8a2beef22b6bd6 100644 --- a/docs/apm/service-maps.asciidoc +++ b/docs/apm/service-maps.asciidoc @@ -84,7 +84,7 @@ image:apm/images/red-service.png[APM red service]:: Max anomaly score **≥75**. [role="screenshot"] image::apm/images/apm-service-map-anomaly.png[Example view of anomaly scores on service maps in the APM app] -If an anomaly has been detected, click *view anomalies* to view the anomaly detection metric viewier in the Machine learning app. +If an anomaly has been detected, click *view anomalies* to view the anomaly detection metric viewer in the Machine learning app. This time series analysis will display additional details on the severity and time of the detected anomalies. To learn how to create a machine learning job, see <>. diff --git a/docs/developer/architecture/kibana-platform-plugin-api.asciidoc b/docs/developer/architecture/kibana-platform-plugin-api.asciidoc index 2005a90bb87bb8..9cf60cda76f759 100644 --- a/docs/developer/architecture/kibana-platform-plugin-api.asciidoc +++ b/docs/developer/architecture/kibana-platform-plugin-api.asciidoc @@ -221,7 +221,7 @@ These are the contracts exposed by the core services for each lifecycle: [cols=",,",options="header",] |=== |lifecycle |server contract|browser contract -|_contructor_ +|_constructor_ |{kib-repo}blob/{branch}/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md[PluginInitializerContext] |{kib-repo}blob/{branch}/docs/development/core/public/kibana-plugin-core-public.plugininitializercontext.md[PluginInitializerContext] diff --git a/docs/developer/best-practices/typescript.asciidoc b/docs/developer/best-practices/typescript.asciidoc index 2631ee717c3d5f..92b6818a09865b 100644 --- a/docs/developer/best-practices/typescript.asciidoc +++ b/docs/developer/best-practices/typescript.asciidoc @@ -51,7 +51,7 @@ Additionally, in order to migrate into project refs, you also need to make sure ], "references": [ { "path": "../../core/tsconfig.json" }, - // add references to other TypeScript projects your plugin dependes on + // add references to other TypeScript projects your plugin depends on ] } ---- diff --git a/docs/developer/contributing/development-ci-metrics.asciidoc b/docs/developer/contributing/development-ci-metrics.asciidoc index 3a133e64ea5286..2905bd72a501fe 100644 --- a/docs/developer/contributing/development-ci-metrics.asciidoc +++ b/docs/developer/contributing/development-ci-metrics.asciidoc @@ -137,4 +137,4 @@ If you only want to run the build once you can run: node scripts/build_kibana_platform_plugins --validate-limits --focus {pluginId} ----------- -This command needs to apply production optimizations to get the right sizes, which means that the optimizer will take significantly longer to run and on most developmer machines will consume all of your machines resources for 20 minutes or more. If you'd like to multi-task while this is running you might need to limit the number of workers using the `--max-workers` flag. \ No newline at end of file +This command needs to apply production optimizations to get the right sizes, which means that the optimizer will take significantly longer to run and on most developer machines will consume all of your machines resources for 20 minutes or more. If you'd like to multi-task while this is running you might need to limit the number of workers using the `--max-workers` flag. \ No newline at end of file diff --git a/docs/developer/contributing/development-documentation.asciidoc b/docs/developer/contributing/development-documentation.asciidoc index 7137d5bad051cb..801d0527cc2b7d 100644 --- a/docs/developer/contributing/development-documentation.asciidoc +++ b/docs/developer/contributing/development-documentation.asciidoc @@ -31,7 +31,7 @@ node scripts/docs.js --open REST APIs should be documented using the following recommended formats: -* https://raw.githubusercontent.com/elastic/docs/master/shared/api-ref-ex.asciidoc[API doc templaate] +* https://raw.githubusercontent.com/elastic/docs/master/shared/api-ref-ex.asciidoc[API doc template] * https://raw.githubusercontent.com/elastic/docs/master/shared/api-definitions-ex.asciidoc[API object definition template] [discrete] diff --git a/docs/developer/contributing/interpreting-ci-failures.asciidoc b/docs/developer/contributing/interpreting-ci-failures.asciidoc index ffbe448d79a444..eead720f03c609 100644 --- a/docs/developer/contributing/interpreting-ci-failures.asciidoc +++ b/docs/developer/contributing/interpreting-ci-failures.asciidoc @@ -22,7 +22,7 @@ image::images/job_view.png[Jenkins job view showing a test failure] 1. *Git Changes:* the list of commits that were in this build which weren't in the previous build. For Pull Requests this list is calculated by comparing against the most recent Pull Request which was tested, it is not limited to build for this specific Pull Request, so it's not very useful. 2. *Test Results:* A link to the test results screen, and shortcuts to the failed tests. Functional tests capture and store the log output from each specific test, and make it visible at these links. For other test runners only the error message is visible and log output must be tracked down in the *Pipeline Steps*. 3. *Google Cloud Storage (GCS) Upload Report:* Link to the screen which lists out the artifacts uploaded to GCS during this job execution. -4. *Pipeline Steps:*: A breakdown of the pipline that was executed, along with individual log output for each step in the pipeline. +4. *Pipeline Steps:*: A breakdown of the pipeline that was executed, along with individual log output for each step in the pipeline. [discrete] === Viewing ciGroup/test Logs diff --git a/docs/settings/enterprise-search-settings.asciidoc b/docs/settings/enterprise-search-settings.asciidoc new file mode 100644 index 00000000000000..736a7614b31edb --- /dev/null +++ b/docs/settings/enterprise-search-settings.asciidoc @@ -0,0 +1,26 @@ +[role="xpack"] +[[enterprise-search-settings-kb]] +=== Enterprise Search settings in {kib} +++++ +Enterprise Search settings +++++ + +On Elastic Cloud, you do not need to configure any settings to use Enterprise Search in {kib}. It is enabled by default. On self-managed installations, you must configure `enterpriseSearch.host`. + +`enterpriseSearch.host`:: +The http(s) URL of your Enterprise Search instance. For example, in a local self-managed setup, +set this to `http://localhost:3002`. Authentication between {kib} and the Enterprise Search host URL, +such as via OAuth, is not supported. You can also +{enterprise-search-ref}/configure-ssl-tls.html#configure-ssl-tls-in-kibana[configure {kib} to trust +your Enterprise Search TLS certificate authority]. + + +`enterpriseSearch.accessCheckTimeout`:: +When launching the Enterprise Search UI, the maximum number of milliseconds for {kib} to wait +for a response from Enterprise Search +before considering the attempt failed and logging a warning. +Default: 5000. + +`enterpriseSearch.accessCheckTimeoutWarning`:: +When launching the Enterprise Search UI, the maximum number of milliseconds for {kib} to wait for a response from +Enterprise Search before logging a warning. Default: 300. diff --git a/docs/setup/configuring-reporting.asciidoc b/docs/setup/configuring-reporting.asciidoc index 0b2fe486707773..bd800d60323090 100644 --- a/docs/setup/configuring-reporting.asciidoc +++ b/docs/setup/configuring-reporting.asciidoc @@ -30,6 +30,7 @@ If you are using Ubuntu/Debian systems, install the following packages: * `fonts-liberation` * `libfontconfig1` +* `libnss3` If the system is missing dependencies, *Reporting* fails in a non-deterministic way. {kib} runs a self-test at server startup, and if it encounters errors, logs them in the Console. The error message does not include diff --git a/docs/setup/connect-to-elasticsearch.asciidoc b/docs/setup/connect-to-elasticsearch.asciidoc index 1d698e90879373..9e1ee62f093fe3 100644 --- a/docs/setup/connect-to-elasticsearch.asciidoc +++ b/docs/setup/connect-to-elasticsearch.asciidoc @@ -55,7 +55,7 @@ https://www.elastic.co/guide/en/elasticsearch/client/index.html[{es} Client docu If you are running {kib} on our hosted {es} Service, click *View deployment details* on the *Integrations* view -to verify your {es} endpoint and Cloud ID, and create API keys for integestion. +to verify your {es} endpoint and Cloud ID, and create API keys for integration. [float] === Add sample data diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index e55a94a516d68d..3a1e0f1a7f4ffa 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -282,9 +282,6 @@ on the {kib} index at startup. {kib} users still need to authenticate with that the {kib} server uses to perform maintenance on the {kib} index at startup. This setting is an alternative to `elasticsearch.username` and `elasticsearch.password`. -| `enterpriseSearch.host` - | The http(s) URL of your Enterprise Search instance. For example, in a local self-managed setup, set this to `http://localhost:3002`. Authentication between Kibana and the Enterprise Search host URL, such as via OAuth, is not supported. You can also {enterprise-search-ref}/configure-ssl-tls.html#configure-ssl-tls-in-kibana[configure Kibana to trust your Enterprise Search TLS certificate authority]. - | `interpreter.enableInVisualize` | Enables use of interpreter in Visualize. *Default: `true`* @@ -719,6 +716,7 @@ Valid locales are: `en`, `zh-CN`, `ja-JP`. *Default: `en`* include::{kib-repo-dir}/settings/alert-action-settings.asciidoc[] include::{kib-repo-dir}/settings/apm-settings.asciidoc[] include::{kib-repo-dir}/settings/banners-settings.asciidoc[] +include::{kib-repo-dir}/settings/enterprise-search-settings.asciidoc[] include::{kib-repo-dir}/settings/fleet-settings.asciidoc[] include::{kib-repo-dir}/settings/i18n-settings.asciidoc[] include::{kib-repo-dir}/settings/logging-settings.asciidoc[] @@ -726,8 +724,8 @@ include::{kib-repo-dir}/settings/logs-ui-settings.asciidoc[] include::{kib-repo-dir}/settings/infrastructure-ui-settings.asciidoc[] include::{kib-repo-dir}/settings/monitoring-settings.asciidoc[] include::{kib-repo-dir}/settings/reporting-settings.asciidoc[] -include::secure-settings.asciidoc[] include::{kib-repo-dir}/settings/search-sessions-settings.asciidoc[] +include::secure-settings.asciidoc[] include::{kib-repo-dir}/settings/security-settings.asciidoc[] include::{kib-repo-dir}/settings/spaces-settings.asciidoc[] include::{kib-repo-dir}/settings/task-manager-settings.asciidoc[] diff --git a/docs/user/production-considerations/task-manager-production-considerations.asciidoc b/docs/user/production-considerations/task-manager-production-considerations.asciidoc index 672c310f138e98..28c5f6e4f14c86 100644 --- a/docs/user/production-considerations/task-manager-production-considerations.asciidoc +++ b/docs/user/production-considerations/task-manager-production-considerations.asciidoc @@ -101,7 +101,7 @@ Scaling {kib} instances horizontally requires a higher degree of coordination, w A recommended strategy is to follow these steps: 1. Produce a <> as a guide to provisioning as many {kib} instances as needed. Include any growth in tasks that you predict experiencing in the near future, and a buffer to better address ad-hoc tasks. -2. After provisioning a deployment, assess whether the provisioned {kib} instances achieve the required throughput by evaluating the <> as described in <>. +2. After provisioning a deployment, assess whether the provisioned {kib} instances achieve the required throughput by evaluating the <> as described in <>. 3. If the throughput is insufficient, and {kib} instances exhibit low resource usage, incrementally scale vertically while <> the impact of these changes. 4. If the throughput is insufficient, and {kib} instances are exhibiting high resource usage, incrementally scale horizontally by provisioning new {kib} instances and reassess. diff --git a/docs/user/production-considerations/task-manager-troubleshooting.asciidoc b/docs/user/production-considerations/task-manager-troubleshooting.asciidoc index a22d46902f54c3..606dd3c8a24eec 100644 --- a/docs/user/production-considerations/task-manager-troubleshooting.asciidoc +++ b/docs/user/production-considerations/task-manager-troubleshooting.asciidoc @@ -412,7 +412,7 @@ This assessment is based on the following: * Comparing the `last_successful_poll` to the `timestamp` (value of `2021-02-16T11:38:10.077Z`) at the root, where you can see the last polling cycle took place 1 second before the monitoring stats were exposed by the health monitoring API. * Comparing the `last_polling_delay` to the `timestamp` (value of `2021-02-16T11:38:10.077Z`) at the root, where you can see the last polling cycle delay took place 2 days ago, suggesting {kib} instances are not conflicting often. -* The `p50` of the `duration` shows that at least 50% of polling cycles take, at most, 13 millisconds to complete. +* The `p50` of the `duration` shows that at least 50% of polling cycles take, at most, 13 milliseconds to complete. * Evaluating the `result_frequency_percent_as_number`: ** 80% of the polling cycles completed without claiming any tasks (suggesting that there aren't any overdue tasks). ** 20% completed with Task Manager claiming tasks that were then executed. @@ -508,7 +508,7 @@ For details on achieving higher throughput by adjusting your scaling strategy, s Tasks run for too long, overrunning their schedule *Diagnosis*: -The <> theory analyzed a hypothetical scenario where both drift and load were unusually high. +The <> theory analyzed a hypothetical scenario where both drift and load were unusually high. Suppose an alternate scenario, where `drift` is high, but `load` is not, such as the following: @@ -688,7 +688,7 @@ Keep in mind that these stats give you a glimpse at a moment in time, and even t [[task-manager-health-evaluate-the-workload]] ===== Evaluate the Workload -Predicting the required throughput a deplyment might need to support Task Manager is difficult, as features can schedule an unpredictable number of tasks at a variety of scheduled cadences. +Predicting the required throughput a deployment might need to support Task Manager is difficult, as features can schedule an unpredictable number of tasks at a variety of scheduled cadences. <> provides statistics that make it easier to monitor the adequacy of the existing throughput. By evaluating the workload, the required throughput can be estimated, which is used when following the Task Manager <>. diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index a86772d3ef27f3..933e257ca235e8 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -6,7 +6,7 @@ "description": "Developer documentation for building custom Kibana plugins and extending Kibana functionality.", "items": [ { - "category": "Getting started", + "label": "Getting started", "items": [ { "id": "kibDevDocsWelcome" }, { "id": "kibDevTutorialSetupDevEnv" }, @@ -16,7 +16,7 @@ ] }, { - "category": "Key concepts", + "label": "Key concepts", "items": [ { "id": "kibPlatformIntro" }, { "id": "kibDevAnatomyOfAPlugin" }, @@ -32,7 +32,7 @@ ] }, { - "category": "Tutorials", + "label": "Tutorials", "items": [ { "id": "kibDevTutorialTestingPlugins" }, { "id": "kibDevTutorialSavedObject" }, @@ -53,7 +53,7 @@ ] }, { - "category": "Contributing", + "label": "Contributing", "items": [ { "id": "kibRepoStructure" }, { "id": "kibDevPrinciples" }, @@ -65,7 +65,7 @@ ] }, { - "category": "Contributors Newsletters", + "label": "Contributors Newsletters", "items": [ { "id": "kibFebruary2022ContributorNewsletter" }, { "id": "kibJanuary2022ContributorNewsletter" }, @@ -82,7 +82,7 @@ ] }, { - "category": "API documentation", + "label": "API documentation", "items": [ { "id": "kibDevDocsApiWelcome" }, { "id": "kibDevDocsPluginDirectory" }, diff --git a/package.json b/package.json index fdb358c25fb83c..bfd4c07a8fd23d 100644 --- a/package.json +++ b/package.json @@ -737,7 +737,7 @@ "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "chromedriver": "^98.0.0", + "chromedriver": "^98.0.1", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", "compression-webpack-plugin": "^4.0.0", diff --git a/packages/kbn-securitysolution-autocomplete/README.md b/packages/kbn-securitysolution-autocomplete/README.md index 41bfd9baf628d8..83b2d6a1882cea 100644 --- a/packages/kbn-securitysolution-autocomplete/README.md +++ b/packages/kbn-securitysolution-autocomplete/README.md @@ -1,6 +1,6 @@ # Autocomplete Fields -Need an input that shows available index fields? Or an input that autocompletes based on a selected indexPattern field? Bingo! That's what these components are for. They are generalized enough so that they can be reused throughout and repurposed based on your needs. +Need an input that shows available index fields? Or an input that auto-completes based on a selected indexPattern field? Bingo! That's what these components are for. They are generalized enough so that they can be reused throughout and repurposed based on your needs. All three of the available components rely on Eui's combo box. @@ -119,4 +119,24 @@ The `onChange` handler is passed selected `string[]`. indexPattern={indexPattern} onChange={handleFieldMatchAnyValueChange} /> +``` + +## AutocompleteFieldWildcardComponent + +This component can be used to allow users to select a single value. It uses the autocomplete hook to display any autocomplete options based on the passed in `indexPattern`, but also allows a user to add their own value. + +The `onChange` handler is passed selected `string[]`. + +```js + ``` \ No newline at end of file diff --git a/packages/kbn-securitysolution-autocomplete/src/field_value_wildcard/index.test.tsx b/packages/kbn-securitysolution-autocomplete/src/field_value_wildcard/index.test.tsx new file mode 100644 index 00000000000000..34769a76563c16 --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/field_value_wildcard/index.test.tsx @@ -0,0 +1,279 @@ +/* + * 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 React from 'react'; +import { ReactWrapper, mount } from 'enzyme'; +import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { act } from '@testing-library/react'; +import { AutocompleteFieldWildcardComponent } from '.'; +import { useFieldValueAutocomplete } from '../hooks/use_field_value_autocomplete'; +import { fields, getField } from '../fields/index.mock'; +import { autocompleteStartMock } from '../autocomplete/index.mock'; + +jest.mock('../hooks/use_field_value_autocomplete'); + +describe('AutocompleteFieldWildcardComponent', () => { + let wrapper: ReactWrapper; + + const getValueSuggestionsMock = jest + .fn() + .mockResolvedValue([false, true, ['value 3', 'value 4'], jest.fn()]); + + beforeEach(() => { + (useFieldValueAutocomplete as jest.Mock).mockReturnValue([ + false, + true, + ['value 1', 'value 2'], + getValueSuggestionsMock, + ]); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('it renders row label if one passed in', () => { + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="valuesAutocompleteWildcardLabel"] label').at(0).text() + ).toEqual('Row Label'); + }); + + test('it renders disabled if "isDisabled" is true', () => { + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="valuesAutocompleteWildcard"] input').prop('disabled') + ).toBeTruthy(); + }); + + test('it renders loading if "isLoading" is true', () => { + wrapper = mount( + + ); + wrapper.find('[data-test-subj="valuesAutocompleteWildcard"] button').at(0).simulate('click'); + expect( + wrapper + .find('EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteWildcard-optionsList"]') + .prop('isLoading') + ).toBeTruthy(); + }); + + test('it allows user to clear values if "isClearable" is true', () => { + wrapper = mount( + + ); + + expect( + wrapper + .find('[data-test-subj="comboBoxInput"]') + .hasClass('euiComboBox__inputWrap-isClearable') + ).toBeTruthy(); + }); + + test('it correctly displays selected value', () => { + wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="valuesAutocompleteWildcard"] EuiComboBoxPill').at(0).text() + ).toEqual('/opt/*/app.dmg'); + }); + + test('it invokes "onChange" when new value created', async () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + ( + wrapper.find(EuiComboBox).props() as unknown as { + onCreateOption: (a: string) => void; + } + ).onCreateOption('/opt/*/app.dmg'); + + expect(mockOnChange).toHaveBeenCalledWith('/opt/*/app.dmg'); + }); + + test('it invokes "onChange" when new value selected', async () => { + const mockOnChange = jest.fn(); + wrapper = mount( + + ); + + ( + wrapper.find(EuiComboBox).props() as unknown as { + onChange: (a: EuiComboBoxOptionOption[]) => void; + } + ).onChange([{ label: 'value 1' }]); + + expect(mockOnChange).toHaveBeenCalledWith('value 1'); + }); + + test('it refreshes autocomplete with search query when new value searched', () => { + wrapper = mount( + + ); + act(() => { + ( + wrapper.find(EuiComboBox).props() as unknown as { + onSearchChange: (a: string) => void; + } + ).onSearchChange('A:\\Some Folder\\inc*.exe'); + }); + + expect(useFieldValueAutocomplete).toHaveBeenCalledWith({ + autocompleteService: autocompleteStartMock, + fieldValue: '', + indexPattern: { + fields, + id: '1234', + title: 'logs-endpoint.events.*', + }, + operatorType: 'wildcard', + query: 'A:\\Some Folder\\inc*.exe', + selectedField: getField('file.path.text'), + }); + }); +}); diff --git a/packages/kbn-securitysolution-autocomplete/src/field_value_wildcard/index.tsx b/packages/kbn-securitysolution-autocomplete/src/field_value_wildcard/index.tsx new file mode 100644 index 00000000000000..159267c3386de7 --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/field_value_wildcard/index.tsx @@ -0,0 +1,255 @@ +/* + * 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 React, { useCallback, useMemo, useState, useEffect, memo } from 'react'; +import { EuiFormRow, EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui'; +import { DataViewBase, DataViewFieldBase } from '@kbn/es-query'; + +import { uniq } from 'lodash'; + +import { ListOperatorTypeEnum as OperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; + +// TODO: I have to use any here for now, but once this is available below, we should use the correct types, https://github.com/elastic/kibana/issues/100715 +// import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; +type AutocompleteStart = any; + +import * as i18n from '../translations'; +import { useFieldValueAutocomplete } from '../hooks/use_field_value_autocomplete'; +import { + getGenericComboBoxProps, + GetGenericComboBoxPropsReturn, +} from '../get_generic_combo_box_props'; +import { paramIsValid } from '../param_is_valid'; + +const SINGLE_SELECTION = { asPlainText: true }; + +interface AutocompleteFieldWildcardProps { + placeholder: string; + selectedField: DataViewFieldBase | undefined; + selectedValue: string | undefined; + indexPattern: DataViewBase | undefined; + isLoading: boolean; + isDisabled?: boolean; + isClearable?: boolean; + isRequired?: boolean; + fieldInputWidth?: number; + rowLabel?: string; + autocompleteService: AutocompleteStart; + onChange: (arg: string) => void; + onError: (arg: boolean) => void; + onWarning: (arg: boolean) => void; + warning?: string; +} + +export const AutocompleteFieldWildcardComponent: React.FC = memo( + ({ + autocompleteService, + placeholder, + rowLabel, + selectedField, + selectedValue, + indexPattern, + isLoading, + isDisabled = false, + isClearable = false, + isRequired = false, + fieldInputWidth, + onChange, + onError, + onWarning, + warning, + }): JSX.Element => { + const [searchQuery, setSearchQuery] = useState(''); + const [touched, setIsTouched] = useState(false); + const [error, setError] = useState(undefined); + const [isLoadingSuggestions, , suggestions] = useFieldValueAutocomplete({ + autocompleteService, + fieldValue: selectedValue, + indexPattern, + operatorType: OperatorTypeEnum.WILDCARD, + query: searchQuery, + selectedField, + }); + const getLabel = useCallback((option: string): string => option, []); + const optionsMemo = useMemo((): string[] => { + const valueAsStr = String(selectedValue); + return selectedValue != null && selectedValue.trim() !== '' + ? uniq([valueAsStr, ...suggestions]) + : suggestions; + }, [suggestions, selectedValue]); + const selectedOptionsMemo = useMemo((): string[] => { + const valueAsStr = String(selectedValue); + return selectedValue ? [valueAsStr] : []; + }, [selectedValue]); + + const handleError = useCallback( + (err: string | undefined): void => { + setError((existingErr): string | undefined => { + const oldErr = existingErr != null; + const newErr = err != null; + if (oldErr !== newErr && onError != null) { + onError(newErr); + } + + return err; + }); + }, + [setError, onError] + ); + + const handleWarning = useCallback( + (warn: string | undefined): void => { + onWarning(warn !== undefined); + }, + [onWarning] + ); + + const { comboOptions, labels, selectedComboOptions } = useMemo( + (): GetGenericComboBoxPropsReturn => + getGenericComboBoxProps({ + getLabel, + options: optionsMemo, + selectedOptions: selectedOptionsMemo, + }), + [optionsMemo, selectedOptionsMemo, getLabel] + ); + + const handleValuesChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]): void => { + const [newValue] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]); + handleError(undefined); + handleWarning(undefined); + onChange(newValue ?? ''); + }, + [handleError, handleWarning, labels, onChange, optionsMemo] + ); + + const handleSearchChange = useCallback( + (searchVal: string): void => { + if (searchVal.trim() !== '' && selectedField != null) { + const err = paramIsValid(searchVal, selectedField, isRequired, touched); + handleError(err); + handleWarning(warning); + setSearchQuery(searchVal); + } + }, + [handleError, isRequired, selectedField, touched, warning, handleWarning] + ); + + const handleCreateOption = useCallback( + (option: string): boolean | undefined => { + const err = paramIsValid(option, selectedField, isRequired, touched); + handleError(err); + handleWarning(warning); + + if (err != null) { + // Explicitly reject the user's input + return false; + } else { + onChange(option); + return undefined; + } + }, + [isRequired, onChange, selectedField, touched, handleError, handleWarning, warning] + ); + + const setIsTouchedValue = useCallback((): void => { + setIsTouched(true); + + const err = paramIsValid(selectedValue, selectedField, isRequired, true); + handleError(err); + handleWarning(warning); + }, [ + setIsTouched, + handleError, + selectedValue, + selectedField, + isRequired, + handleWarning, + warning, + ]); + + const inputPlaceholder = useMemo((): string => { + if (isLoading || isLoadingSuggestions) { + return i18n.LOADING; + } else if (selectedField == null) { + return i18n.SELECT_FIELD_FIRST; + } else { + return placeholder; + } + }, [isLoading, selectedField, isLoadingSuggestions, placeholder]); + + const isLoadingState = useMemo( + (): boolean => isLoading || isLoadingSuggestions, + [isLoading, isLoadingSuggestions] + ); + + useEffect((): void => { + setError(undefined); + if (onError != null) { + onError(false); + } + if (onWarning != null) { + onWarning(false); + } + }, [selectedField, onError, onWarning]); + + const defaultInput = useMemo((): JSX.Element => { + return ( + + + + ); + }, [ + comboOptions, + error, + fieldInputWidth, + handleCreateOption, + handleSearchChange, + handleValuesChange, + inputPlaceholder, + isClearable, + isDisabled, + isLoadingState, + rowLabel, + selectedComboOptions, + selectedField, + setIsTouchedValue, + warning, + ]); + + return defaultInput; + } +); + +AutocompleteFieldWildcardComponent.displayName = 'AutocompleteFieldWildcard'; diff --git a/packages/kbn-securitysolution-autocomplete/src/fields/index.mock.ts b/packages/kbn-securitysolution-autocomplete/src/fields/index.mock.ts index d25dc5d45c9ec2..b3def81c43360a 100644 --- a/packages/kbn-securitysolution-autocomplete/src/fields/index.mock.ts +++ b/packages/kbn-securitysolution-autocomplete/src/fields/index.mock.ts @@ -309,6 +309,14 @@ export const fields: DataViewFieldBase[] = [ readFromDocValues: false, subType: { nested: { path: 'nestedField.nestedChild' } }, }, + { + name: 'file.path.text', + type: 'string', + esTypes: ['text'], + searchable: true, + aggregatable: false, + subType: { multi: { parent: 'file.path' } }, + }, ] as unknown as DataViewFieldBase[]; export const getField = (name: string) => fields.find((field) => field.name === name); diff --git a/packages/kbn-securitysolution-autocomplete/src/get_operators/index.test.ts b/packages/kbn-securitysolution-autocomplete/src/get_operators/index.test.ts index 9ed9c6358c3971..24e4d759989ebc 100644 --- a/packages/kbn-securitysolution-autocomplete/src/get_operators/index.test.ts +++ b/packages/kbn-securitysolution-autocomplete/src/get_operators/index.test.ts @@ -8,6 +8,7 @@ import { doesNotExistOperator, + EVENT_FILTERS_OPERATORS, EXCEPTION_OPERATORS, existsOperator, isNotOperator, @@ -40,6 +41,15 @@ describe('#getOperators', () => { expect(operator).toEqual([isOperator]); }); + test('it includes a "matches" operator when field is "file.path.text"', () => { + const operator = getOperators({ + name: 'file.path.text', + type: 'simple', + }); + + expect(operator).toEqual(EVENT_FILTERS_OPERATORS); + }); + test('it returns all operator types when field type is not null, boolean, or nested', () => { const operator = getOperators(getField('machine.os.raw')); diff --git a/packages/kbn-securitysolution-autocomplete/src/get_operators/index.ts b/packages/kbn-securitysolution-autocomplete/src/get_operators/index.ts index e84dc33e676e63..643c330b15241b 100644 --- a/packages/kbn-securitysolution-autocomplete/src/get_operators/index.ts +++ b/packages/kbn-securitysolution-autocomplete/src/get_operators/index.ts @@ -10,6 +10,7 @@ import { DataViewFieldBase } from '@kbn/es-query'; import { EXCEPTION_OPERATORS, + EVENT_FILTERS_OPERATORS, OperatorOption, doesNotExistOperator, existsOperator, @@ -30,6 +31,8 @@ export const getOperators = (field: DataViewFieldBase | undefined): OperatorOpti return [isOperator, isNotOperator, existsOperator, doesNotExistOperator]; } else if (field.type === 'nested') { return [isOperator]; + } else if (field.name === 'file.path.text') { + return EVENT_FILTERS_OPERATORS; } else { return EXCEPTION_OPERATORS; } diff --git a/packages/kbn-securitysolution-autocomplete/src/index.ts b/packages/kbn-securitysolution-autocomplete/src/index.ts index 5fcb3f954189ad..fcb1ea6b2cde64 100644 --- a/packages/kbn-securitysolution-autocomplete/src/index.ts +++ b/packages/kbn-securitysolution-autocomplete/src/index.ts @@ -11,6 +11,7 @@ export * from './field_value_exists'; export * from './field_value_lists'; export * from './field_value_match'; export * from './field_value_match_any'; +export * from './field_value_wildcard'; export * from './filter_field_to_list'; export * from './get_generic_combo_box_props'; export * from './get_operators'; diff --git a/packages/kbn-securitysolution-autocomplete/src/operator/index.test.tsx b/packages/kbn-securitysolution-autocomplete/src/operator/index.test.tsx index 6d1622f0fa95f7..48f5cbf25b91ce 100644 --- a/packages/kbn-securitysolution-autocomplete/src/operator/index.test.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/operator/index.test.tsx @@ -129,6 +129,9 @@ describe('operator', () => { { label: 'is not in list', }, + { + label: 'matches', + }, ]); }); @@ -196,6 +199,30 @@ describe('operator', () => { ]); }); + test('it only displays subset of operators if field name is "file.path.text"', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options') + ).toEqual([ + { label: 'is' }, + { label: 'is not' }, + { label: 'is one of' }, + { label: 'is not one of' }, + { label: 'matches' }, + ]); + }); + test('it invokes "onChange" when option selected', () => { const mockOnChange = jest.fn(); const wrapper = mount( diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.mock.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.mock.ts index 176a6357b30e72..64f7e1aceeb2af 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.mock.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.mock.ts @@ -10,11 +10,11 @@ import { EndpointEntriesArray } from '.'; import { getEndpointEntryMatchMock } from '../entry_match/index.mock'; import { getEndpointEntryMatchAnyMock } from '../entry_match_any/index.mock'; import { getEndpointEntryNestedMock } from '../entry_nested/index.mock'; -import { getEndpointEntryMatchWildcard } from '../entry_match_wildcard/index.mock'; +import { getEndpointEntryMatchWildcardMock } from '../entry_match_wildcard/index.mock'; export const getEndpointEntriesArrayMock = (): EndpointEntriesArray => [ getEndpointEntryMatchMock(), getEndpointEntryMatchAnyMock(), getEndpointEntryNestedMock(), - getEndpointEntryMatchWildcard(), + getEndpointEntryMatchWildcardMock(), ]; diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.test.ts index ca852e15c5c2a7..08235d35e921f6 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.test.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entries/index.test.ts @@ -20,7 +20,7 @@ import { getEndpointEntryNestedMock } from '../entry_nested/index.mock'; import { getEndpointEntriesArrayMock } from './index.mock'; import { getEntryListMock } from '../../entries_list/index.mock'; import { getEntryExistsMock } from '../../entries_exist/index.mock'; -import { getEndpointEntryMatchWildcard } from '../entry_match_wildcard/index.mock'; +import { getEndpointEntryMatchWildcardMock } from '../entry_match_wildcard/index.mock'; describe('Endpoint', () => { describe('entriesArray', () => { @@ -101,7 +101,7 @@ describe('Endpoint', () => { }); test('it should validate an array with wildcard entry', () => { - const payload = [getEndpointEntryMatchWildcard()]; + const payload = [getEndpointEntryMatchWildcardMock()]; const decoded = endpointEntriesArray.decode(payload); const message = pipe(decoded, foldLeftRight); diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.mock.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.mock.ts index e001552277e0ca..842e046ea67eeb 100644 --- a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.mock.ts +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.mock.ts @@ -9,7 +9,7 @@ import { ENTRY_VALUE, FIELD, OPERATOR, WILDCARD } from '../../../constants/index.mock'; import { EndpointEntryMatchWildcard } from './index'; -export const getEndpointEntryMatchWildcard = (): EndpointEntryMatchWildcard => ({ +export const getEndpointEntryMatchWildcardMock = (): EndpointEntryMatchWildcard => ({ field: FIELD, operator: OPERATOR, type: WILDCARD, diff --git a/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.test.ts b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.test.ts new file mode 100644 index 00000000000000..9671e721f20c61 --- /dev/null +++ b/packages/kbn-securitysolution-io-ts-list-types/src/common/endpoint/entry_match_wildcard/index.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; +import { getEndpointEntryMatchWildcardMock } from './index.mock'; +import { EndpointEntryMatchWildcard, endpointEntryMatchWildcard } from '.'; +import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; +import { getEntryMatchWildcardMock } from '../../entry_match_wildcard/index.mock'; + +describe('endpointEntryMatchWildcard', () => { + test('it should validate an entry', () => { + const payload = getEndpointEntryMatchWildcardMock(); + const decoded = endpointEntryMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate when "operator" is "excluded"', () => { + const payload = getEntryMatchWildcardMock(); + payload.operator = 'excluded'; + const decoded = endpointEntryMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "excluded" supplied to "operator"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "field" is empty string', () => { + const payload: Omit & { field: string } = { + ...getEndpointEntryMatchWildcardMock(), + field: '', + }; + const decoded = endpointEntryMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "field"']); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "value" is not string', () => { + const payload: Omit & { value: string[] } = { + ...getEndpointEntryMatchWildcardMock(), + value: ['some value'], + }; + const decoded = endpointEntryMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "["some value"]" supplied to "value"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "value" is empty string', () => { + const payload: Omit & { value: string } = { + ...getEndpointEntryMatchWildcardMock(), + value: '', + }; + const decoded = endpointEntryMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "value"']); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "type" is not "wildcard"', () => { + const payload: Omit & { type: string } = { + ...getEndpointEntryMatchWildcardMock(), + type: 'match', + }; + const decoded = endpointEntryMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "match" supplied to "type"']); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: EndpointEntryMatchWildcard & { + extraKey?: string; + } = getEndpointEntryMatchWildcardMock(); + payload.extraKey = 'some value'; + const decoded = endpointEntryMatchWildcard.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getEntryMatchWildcardMock()); + }); +}); diff --git a/packages/kbn-securitysolution-list-utils/src/autocomplete_operators/index.ts b/packages/kbn-securitysolution-list-utils/src/autocomplete_operators/index.ts index 101076bdfcfffc..ac3236528b6718 100644 --- a/packages/kbn-securitysolution-list-utils/src/autocomplete_operators/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/autocomplete_operators/index.ts @@ -85,11 +85,21 @@ export const isNotInListOperator: OperatorOption = { value: 'is_not_in_list', }; +export const matchesOperator: OperatorOption = { + message: i18n.translate('lists.exceptions.matchesOperatorLabel', { + defaultMessage: 'matches', + }), + operator: OperatorEnum.INCLUDED, + type: OperatorTypeEnum.WILDCARD, + value: 'matches', +}; + export const EVENT_FILTERS_OPERATORS: OperatorOption[] = [ isOperator, isNotOperator, isOneOfOperator, isNotOneOfOperator, + matchesOperator, ]; export const EXCEPTION_OPERATORS: OperatorOption[] = [ @@ -101,6 +111,7 @@ export const EXCEPTION_OPERATORS: OperatorOption[] = [ doesNotExistOperator, isInListOperator, isNotInListOperator, + matchesOperator, ]; export const EXCEPTION_OPERATORS_SANS_LISTS: OperatorOption[] = [ diff --git a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts index 394d4f02b8772b..eabf8dfa33f98b 100644 --- a/packages/kbn-securitysolution-list-utils/src/helpers/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/helpers/index.ts @@ -172,6 +172,8 @@ export const getOperatorType = (item: BuilderEntry): OperatorTypeEnum => { return OperatorTypeEnum.MATCH; case 'match_any': return OperatorTypeEnum.MATCH_ANY; + case 'wildcard': + return OperatorTypeEnum.WILDCARD; case 'list': return OperatorTypeEnum.LIST; default: @@ -207,6 +209,7 @@ export const getEntryValue = (item: BuilderEntry): string | string[] | undefined switch (item.type) { case OperatorTypeEnum.MATCH: case OperatorTypeEnum.MATCH_ANY: + case OperatorTypeEnum.WILDCARD: return item.value; case OperatorTypeEnum.EXISTS: return undefined; @@ -523,6 +526,54 @@ export const getEntryOnMatchChange = ( } }; +/** + * Determines proper entry update when user updates value + * when operator is of type "wildcard" + * + * @param item - current exception item entry values + * @param newField - newly entered value + * + */ +export const getEntryOnWildcardChange = ( + item: FormattedBuilderEntry, + newField: string +): { index: number; updatedEntry: BuilderEntry } => { + const { nested, parent, entryIndex, field, operator } = item; + + if (nested != null && parent != null) { + const fieldName = field != null ? field.name.split('.').slice(-1)[0] : ''; + + return { + index: parent.parentIndex, + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + field: fieldName, + id: item.id, + operator: operator.operator, + type: OperatorTypeEnum.WILDCARD, + value: newField, + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + }; + } else { + return { + index: entryIndex, + updatedEntry: { + field: field != null ? field.name : '', + id: item.id, + operator: operator.operator, + type: OperatorTypeEnum.WILDCARD, + value: newField, + }, + }; + } +}; + /** * On operator change, determines whether value needs to be cleared or not * @@ -563,6 +614,15 @@ export const getEntryFromOperator = ( operator: selectedOperator.operator, type: OperatorTypeEnum.LIST, }; + case 'wildcard': + return { + field: fieldValue, + id: currentEntry.id, + operator: selectedOperator.operator, + type: OperatorTypeEnum.WILDCARD, + value: + isSameOperatorType && typeof currentEntry.value === 'string' ? currentEntry.value : '', + }; default: return { field: fieldValue, diff --git a/packages/kbn-securitysolution-utils/BUILD.bazel b/packages/kbn-securitysolution-utils/BUILD.bazel index cfb6b722ea2e63..70ecc2712d4af7 100644 --- a/packages/kbn-securitysolution-utils/BUILD.bazel +++ b/packages/kbn-securitysolution-utils/BUILD.bazel @@ -28,11 +28,13 @@ NPM_MODULE_EXTRA_FILES = [ ] RUNTIME_DEPS = [ + "//packages/kbn-i18n", "@npm//tslib", - "@npm//uuid", + "@npm//uuid" ] TYPES_DEPS = [ + "//packages/kbn-i18n:npm_module_types", "@npm//tslib", "@npm//@types/jest", "@npm//@types/node", diff --git a/packages/kbn-securitysolution-utils/src/index.ts b/packages/kbn-securitysolution-utils/src/index.ts index 755bbd2203dffd..e3442a3ec7dc81 100644 --- a/packages/kbn-securitysolution-utils/src/index.ts +++ b/packages/kbn-securitysolution-utils/src/index.ts @@ -8,3 +8,4 @@ export * from './add_remove_id_to_item'; export * from './transform_data_to_ndjson'; +export * from './path_validations'; diff --git a/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.test.ts b/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts similarity index 84% rename from x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.test.ts rename to packages/kbn-securitysolution-utils/src/path_validations/index.test.ts index 952a2fa234ace5..ee2d8764a30afb 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.test.ts +++ b/packages/kbn-securitysolution-utils/src/path_validations/index.test.ts @@ -1,12 +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. + * 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 { isPathValid, hasSimpleExecutableName } from './validations'; -import { OperatingSystem, ConditionEntryField } from '../../types'; +import { + isPathValid, + hasSimpleExecutableName, + OperatingSystem, + ConditionEntryField, + validateFilePathInput, + FILENAME_WILDCARD_WARNING, + FILEPATH_WARNING, +} from '.'; + +describe('validateFilePathInput', () => { + describe('windows', () => { + const os = OperatingSystem.WINDOWS; + + it('warns on wildcard in file name at the end of the path', () => { + expect(validateFilePathInput({ os, value: 'c:\\path*.exe' })).toEqual( + FILENAME_WILDCARD_WARNING + ); + }); + + it('warns on unix paths or non-windows paths', () => { + expect(validateFilePathInput({ os, value: '/opt/bin' })).toEqual(FILEPATH_WARNING); + }); + + it('warns on malformed paths', () => { + expect(validateFilePathInput({ os, value: 'c:\\path/opt' })).toEqual(FILEPATH_WARNING); + expect(validateFilePathInput({ os, value: '1242' })).toEqual(FILEPATH_WARNING); + expect(validateFilePathInput({ os, value: 'w12efdfa' })).toEqual(FILEPATH_WARNING); + }); + }); + describe('unix paths', () => { + const os = + parseInt((Math.random() * 2).toString(), 10) === 1 + ? OperatingSystem.MAC + : OperatingSystem.LINUX; + + it('warns on wildcard in file name at the end of the path', () => { + expect(validateFilePathInput({ os, value: '/opt/bin*' })).toEqual(FILENAME_WILDCARD_WARNING); + }); + + it('warns on windows paths', () => { + expect(validateFilePathInput({ os, value: 'd:\\path\\file.exe' })).toEqual(FILEPATH_WARNING); + }); + + it('warns on malformed paths', () => { + expect(validateFilePathInput({ os, value: 'opt/bin\\file.exe' })).toEqual(FILEPATH_WARNING); + expect(validateFilePathInput({ os, value: '1242' })).toEqual(FILEPATH_WARNING); + expect(validateFilePathInput({ os, value: 'w12efdfa' })).toEqual(FILEPATH_WARNING); + }); + }); +}); + +describe('No Warnings', () => { + it('should not show warnings on non path entries ', () => { + expect( + isPathValid({ + os: OperatingSystem.WINDOWS, + field: ConditionEntryField.HASH, + type: 'match', + value: '5d5b09f6dcb2d53a5fffc60c4ac0d55fabdf556069d6631545f42aa6e3500f2e', + }) + ).toEqual(true); + + expect( + isPathValid({ + os: OperatingSystem.WINDOWS, + field: ConditionEntryField.SIGNER, + type: 'match', + value: '', + }) + ).toEqual(true); + }); +}); describe('Unacceptable Windows wildcard paths', () => { it('should not accept paths that do not have a folder name with a wildcard ', () => { diff --git a/packages/kbn-securitysolution-utils/src/path_validations/index.ts b/packages/kbn-securitysolution-utils/src/path_validations/index.ts new file mode 100644 index 00000000000000..82d2cc3151b90e --- /dev/null +++ b/packages/kbn-securitysolution-utils/src/path_validations/index.ts @@ -0,0 +1,178 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const FILENAME_WILDCARD_WARNING = i18n.translate('utils.filename.wildcardWarning', { + defaultMessage: `A wildcard in the filename will affect the endpoint's performance`, +}); + +export const FILEPATH_WARNING = i18n.translate('utils.filename.pathWarning', { + defaultMessage: `Path may be formed incorrectly; verify value`, +}); + +export const enum ConditionEntryField { + HASH = 'process.hash.*', + PATH = 'process.executable.caseless', + SIGNER = 'process.Ext.code_signature', +} + +export const enum OperatingSystem { + LINUX = 'linux', + MAC = 'macos', + WINDOWS = 'windows', +} + +export type TrustedAppEntryTypes = 'match' | 'wildcard'; +/* + * regex to match executable names + * starts matching from the eol of the path + * file names with a single or multiple spaces (for spaced names) + * and hyphens and combinations of these that produce complex names + * such as: + * c:\home\lib\dmp.dmp + * c:\home\lib\my-binary-app-+/ some/ x/ dmp.dmp + * /home/lib/dmp.dmp + * /home/lib/my-binary-app+-\ some\ x\ dmp.dmp + */ +export const WIN_EXEC_PATH = /(\\[-\w]+|\\[-\w]+[\.]+[\w]+)$/i; +export const UNIX_EXEC_PATH = /(\/[-\w]+|\/[-\w]+[\.]+[\w]+)$/i; + +export const validateFilePathInput = ({ + os, + value = '', +}: { + os: OperatingSystem; + value?: string; +}): string | undefined => { + const textInput = value.trim(); + const isValidFilePath = isPathValid({ + os, + field: 'file.path.text', + type: 'wildcard', + value: textInput, + }); + const hasSimpleFileName = hasSimpleExecutableName({ + os, + type: 'wildcard', + value: textInput, + }); + + if (!textInput.length) { + return FILEPATH_WARNING; + } + + if (isValidFilePath) { + if (!hasSimpleFileName) { + return FILENAME_WILDCARD_WARNING; + } + } else { + return FILEPATH_WARNING; + } +}; + +export const hasSimpleExecutableName = ({ + os, + type, + value, +}: { + os: OperatingSystem; + type: TrustedAppEntryTypes; + value: string; +}): boolean => { + if (type === 'wildcard') { + return os === OperatingSystem.WINDOWS ? WIN_EXEC_PATH.test(value) : UNIX_EXEC_PATH.test(value); + } + return true; +}; + +export const isPathValid = ({ + os, + field, + type, + value, +}: { + os: OperatingSystem; + field: ConditionEntryField | 'file.path.text'; + type: TrustedAppEntryTypes; + value: string; +}): boolean => { + if (field === ConditionEntryField.PATH || field === 'file.path.text') { + if (type === 'wildcard') { + return os === OperatingSystem.WINDOWS + ? isWindowsWildcardPathValid(value) + : isLinuxMacWildcardPathValid(value); + } + return doesPathMatchRegex({ value, os }); + } + return true; +}; + +const doesPathMatchRegex = ({ os, value }: { os: OperatingSystem; value: string }): boolean => { + if (os === OperatingSystem.WINDOWS) { + const filePathRegex = + /^[a-z]:(?:|\\\\[^<>:"'/\\|?*]+\\[^<>:"'/\\|?*]+|%\w+%|)[\\](?:[^<>:"'/\\|?*]+[\\/])*([^<>:"'/\\|?*])+$/i; + return filePathRegex.test(value); + } + return /^(\/|(\/[\w\-]+)+|\/[\w\-]+\.[\w]+|(\/[\w-]+)+\/[\w\-]+\.[\w]+)$/i.test(value); +}; + +const isWindowsWildcardPathValid = (path: string): boolean => { + const firstCharacter = path[0]; + const lastCharacter = path.slice(-1); + const trimmedValue = path.trim(); + const hasSlash = /\//.test(trimmedValue); + if (path.length === 0) { + return false; + } else if ( + hasSlash || + trimmedValue.length !== path.length || + firstCharacter === '^' || + lastCharacter === '\\' || + !hasWildcard({ path, isWindowsPath: true }) + ) { + return false; + } else { + return true; + } +}; + +const isLinuxMacWildcardPathValid = (path: string): boolean => { + const firstCharacter = path[0]; + const lastCharacter = path.slice(-1); + const trimmedValue = path.trim(); + if (path.length === 0) { + return false; + } else if ( + trimmedValue.length !== path.length || + firstCharacter !== '/' || + lastCharacter === '/' || + path.length > 1024 === true || + path.includes('//') === true || + !hasWildcard({ path, isWindowsPath: false }) + ) { + return false; + } else { + return true; + } +}; + +const hasWildcard = ({ + path, + isWindowsPath, +}: { + path: string; + isWindowsPath: boolean; +}): boolean => { + for (const pathComponent of path.split(isWindowsPath ? '\\' : '/')) { + if (/[\*|\?]+/.test(pathComponent) === true) { + return true; + } + } + return false; +}; diff --git a/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js index 6e25e4c073ab0b..417fc8e10aeca2 100644 --- a/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/failure_hooks/config.js @@ -37,5 +37,10 @@ export default function () { captureLogOutput: false, sendToCiStats: false, }, + servers: { + elasticsearch: { + port: 1234, + }, + }, }; } diff --git a/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js index 4c87b53b5753b2..067528c4ae120b 100644 --- a/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/__fixtures__/simple_project/config.js @@ -13,4 +13,9 @@ export default () => ({ mochaReporter: { sendToCiStats: false, }, + servers: { + elasticsearch: { + port: 1234, + }, + }, }); diff --git a/packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js b/packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js index 0d986a1602e124..47ae51ca62f131 100644 --- a/packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js +++ b/packages/kbn-test/src/functional_test_runner/integration_tests/failure_hooks.test.js @@ -61,7 +61,7 @@ describe('failure hooks', function () { expect(tests).toHaveLength(0); } catch (error) { - console.error('full log output', linesCopy.join('\n')); + error.message += `\n\nfull log output:${linesCopy.join('\n')}`; throw error; } }); diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js index 123bc8b9bc201b..afcad01c4ab924 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.1.js @@ -9,5 +9,10 @@ export default function () { return { testFiles: ['config.1'], + servers: { + elasticsearch: { + port: 1234, + }, + }, }; } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js index 2dd4c96186fcda..692a3de786723d 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js +++ b/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.2.js @@ -11,5 +11,10 @@ export default async function ({ readConfigFile }) { return { testFiles: [...config1.get('testFiles'), 'config.2'], + servers: { + elasticsearch: { + port: 1234, + }, + }, }; } diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/config.test.ts b/packages/kbn-test/src/functional_test_runner/lib/config/config.test.ts index 88c1fd99f0014e..d551e7a884b416 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/config.test.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/config.test.ts @@ -15,6 +15,11 @@ describe('Config', () => { services: { foo: () => 42, }, + servers: { + elasticsearch: { + port: 1234, + }, + }, }, primary: true, path: process.cwd(), diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts index f65cb3c41f4218..42a77b85ddc6c3 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts @@ -17,19 +17,33 @@ const ID_PATTERN = /^[a-zA-Z0-9_]+$/; // it will search both --inspect and --inspect-brk const INSPECTING = !!process.execArgv.find((arg) => arg.includes('--inspect')); -const urlPartsSchema = () => +const maybeRequireKeys = (keys: string[], schemas: Record) => { + if (!keys.length) { + return schemas; + } + + const withRequires: Record = {}; + for (const [key, schema] of Object.entries(schemas)) { + withRequires[key] = keys.includes(key) ? schema.required() : schema; + } + return withRequires; +}; + +const urlPartsSchema = ({ requiredKeys }: { requiredKeys?: string[] } = {}) => Joi.object() - .keys({ - protocol: Joi.string().valid('http', 'https').default('http'), - hostname: Joi.string().hostname().default('localhost'), - port: Joi.number(), - auth: Joi.string().regex(/^[^:]+:.+$/, 'username and password separated by a colon'), - username: Joi.string(), - password: Joi.string(), - pathname: Joi.string().regex(/^\//, 'start with a /'), - hash: Joi.string().regex(/^\//, 'start with a /'), - certificateAuthorities: Joi.array().items(Joi.binary()).optional(), - }) + .keys( + maybeRequireKeys(requiredKeys ?? [], { + protocol: Joi.string().valid('http', 'https').default('http'), + hostname: Joi.string().hostname().default('localhost'), + port: Joi.number(), + auth: Joi.string().regex(/^[^:]+:.+$/, 'username and password separated by a colon'), + username: Joi.string(), + password: Joi.string(), + pathname: Joi.string().regex(/^\//, 'start with a /'), + hash: Joi.string().regex(/^\//, 'start with a /'), + certificateAuthorities: Joi.array().items(Joi.binary()).optional(), + }) + ) .default(); const appUrlPartsSchema = () => @@ -170,7 +184,9 @@ export const schema = Joi.object() servers: Joi.object() .keys({ kibana: urlPartsSchema(), - elasticsearch: urlPartsSchema(), + elasticsearch: urlPartsSchema({ + requiredKeys: ['port'], + }), }) .default(), diff --git a/renovate.json b/renovate.json index 9b673a5a9ccf65..0b6ca59edefe2e 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,6 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:base", ":disableDependencyDashboard"], + "extends": ["config:base"], "ignorePaths": ["**/__fixtures__/**", "**/fixtures/**"], "enabledManagers": ["npm"], "baseBranches": ["main", "7.16", "7.15"], diff --git a/src/dev/build/tasks/bundle_fleet_packages.ts b/src/dev/build/tasks/bundle_fleet_packages.ts index 7d0dc6a25a47e3..b2faed818b55b6 100644 --- a/src/dev/build/tasks/bundle_fleet_packages.ts +++ b/src/dev/build/tasks/bundle_fleet_packages.ts @@ -11,7 +11,7 @@ import JSON5 from 'json5'; import { readCliArgs } from '../args'; import { Task, read, downloadToDisk } from '../lib'; -const BUNDLED_PACKAGES_DIR = 'x-pack/plugins/fleet/server/bundled_packages'; +const BUNDLED_PACKAGES_DIR = 'x-pack/plugins/fleet/target/bundled_packages'; interface FleetPackage { name: string; diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 860886811da547..932fdaf6c2e280 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -61,9 +61,6 @@ export const IGNORE_FILE_GLOBS = [ 'x-pack/plugins/maps/server/fonts/**/*', - // Bundled package names typically use a format like ${pkgName}-${pkgVersion}, so don't lint them - 'x-pack/plugins/fleet/server/bundled_packages/**/*', - // Bazel default files '**/WORKSPACE.bazel', '**/BUILD.bazel', diff --git a/src/plugins/data/common/datatable_utilities/datatable_utilities_service.test.ts b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.test.ts new file mode 100644 index 00000000000000..d626bc22265435 --- /dev/null +++ b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.test.ts @@ -0,0 +1,123 @@ +/* + * 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 { createStubDataView } from 'src/plugins/data_views/common/mocks'; +import type { DataViewsContract } from 'src/plugins/data_views/common'; +import type { DatatableColumn } from 'src/plugins/expressions/common'; +import { FieldFormat } from 'src/plugins/field_formats/common'; +import { fieldFormatsMock } from 'src/plugins/field_formats/common/mocks'; +import type { AggsCommonStart } from '../search'; +import { DatatableUtilitiesService } from './datatable_utilities_service'; + +describe('DatatableUtilitiesService', () => { + let aggs: jest.Mocked; + let dataViews: jest.Mocked; + let datatableUtilitiesService: DatatableUtilitiesService; + + beforeEach(() => { + aggs = { + createAggConfigs: jest.fn(), + types: { get: jest.fn() }, + } as unknown as typeof aggs; + dataViews = { + get: jest.fn(), + } as unknown as typeof dataViews; + + datatableUtilitiesService = new DatatableUtilitiesService(aggs, dataViews, fieldFormatsMock); + }); + + describe('clearField', () => { + it('should delete the field reference', () => { + const column = { meta: { field: 'foo' } } as DatatableColumn; + + datatableUtilitiesService.clearField(column); + + expect(column).not.toHaveProperty('meta.field'); + }); + }); + + describe('clearFieldFormat', () => { + it('should remove field format', () => { + const column = { meta: { params: { id: 'number' } } } as DatatableColumn; + datatableUtilitiesService.clearFieldFormat(column); + + expect(column).not.toHaveProperty('meta.params'); + }); + }); + + describe('getDataView', () => { + it('should return a data view instance', async () => { + const column = { meta: { index: 'index' } } as DatatableColumn; + const dataView = {} as ReturnType; + dataViews.get.mockReturnValue(dataView); + + await expect(datatableUtilitiesService.getDataView(column)).resolves.toBe(dataView); + expect(dataViews.get).toHaveBeenCalledWith('index'); + }); + + it('should return undefined when there is no index metadata', async () => { + const column = { meta: {} } as DatatableColumn; + + await expect(datatableUtilitiesService.getDataView(column)).resolves.toBeUndefined(); + expect(dataViews.get).not.toHaveBeenCalled(); + }); + }); + + describe('getField', () => { + it('should return a data view field instance', async () => { + const column = { meta: { field: 'field', index: 'index' } } as DatatableColumn; + const dataView = createStubDataView({ spec: {} }); + const field = {}; + spyOn(datatableUtilitiesService, 'getDataView').and.returnValue(dataView); + spyOn(dataView, 'getFieldByName').and.returnValue(field); + + await expect(datatableUtilitiesService.getField(column)).resolves.toBe(field); + expect(dataView.getFieldByName).toHaveBeenCalledWith('field'); + }); + + it('should return undefined when there is no field metadata', async () => { + const column = { meta: {} } as DatatableColumn; + + await expect(datatableUtilitiesService.getField(column)).resolves.toBeUndefined(); + }); + }); + + describe('getFieldFormat', () => { + it('should deserialize field format', () => { + const column = { meta: { params: { id: 'number' } } } as DatatableColumn; + const fieldFormat = datatableUtilitiesService.getFieldFormat(column); + + expect(fieldFormat).toBeInstanceOf(FieldFormat); + }); + }); + + describe('getInterval', () => { + it('should return a histogram interval', () => { + const column = { + meta: { sourceParams: { params: { interval: '1d' } } }, + } as unknown as DatatableColumn; + + expect(datatableUtilitiesService.getInterval(column)).toBe('1d'); + }); + }); + + describe('setFieldFormat', () => { + it('should set new field format', () => { + const column = { meta: {} } as DatatableColumn; + const fieldFormat = fieldFormatsMock.deserialize({ id: 'number' }); + datatableUtilitiesService.setFieldFormat(column, fieldFormat); + + expect(column.meta.params).toEqual( + expect.objectContaining({ + id: expect.anything(), + params: undefined, + }) + ); + }); + }); +}); diff --git a/src/plugins/data/common/datatable_utilities/datatable_utilities_service.ts b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.ts new file mode 100644 index 00000000000000..cf4e65f31cce34 --- /dev/null +++ b/src/plugins/data/common/datatable_utilities/datatable_utilities_service.ts @@ -0,0 +1,93 @@ +/* + * 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 type { DataView, DataViewsContract, DataViewField } from 'src/plugins/data_views/common'; +import type { DatatableColumn } from 'src/plugins/expressions/common'; +import type { FieldFormatsStartCommon, FieldFormat } from 'src/plugins/field_formats/common'; +import type { AggsCommonStart, AggConfig, CreateAggConfigParams, IAggType } from '../search'; + +export class DatatableUtilitiesService { + constructor( + private aggs: AggsCommonStart, + private dataViews: DataViewsContract, + private fieldFormats: FieldFormatsStartCommon + ) { + this.getAggConfig = this.getAggConfig.bind(this); + this.getDataView = this.getDataView.bind(this); + this.getField = this.getField.bind(this); + this.isFilterable = this.isFilterable.bind(this); + } + + clearField(column: DatatableColumn): void { + delete column.meta.field; + } + + clearFieldFormat(column: DatatableColumn): void { + delete column.meta.params; + } + + async getAggConfig(column: DatatableColumn): Promise { + const dataView = await this.getDataView(column); + + if (!dataView) { + return; + } + + const { aggs } = await this.aggs.createAggConfigs( + dataView, + column.meta.sourceParams && [column.meta.sourceParams as CreateAggConfigParams] + ); + + return aggs[0]; + } + + async getDataView(column: DatatableColumn): Promise { + if (!column.meta.index) { + return; + } + + return this.dataViews.get(column.meta.index); + } + + async getField(column: DatatableColumn): Promise { + if (!column.meta.field) { + return; + } + + const dataView = await this.getDataView(column); + if (!dataView) { + return; + } + + return dataView.getFieldByName(column.meta.field); + } + + getFieldFormat(column: DatatableColumn): FieldFormat | undefined { + return this.fieldFormats.deserialize(column.meta.params); + } + + getInterval(column: DatatableColumn): string | undefined { + const params = column.meta.sourceParams?.params as { interval: string } | undefined; + + return params?.interval; + } + + isFilterable(column: DatatableColumn): boolean { + if (column.meta.source !== 'esaggs') { + return false; + } + + const aggType = this.aggs.types.get(column.meta.sourceParams?.type as string) as IAggType; + + return Boolean(aggType.createFilter); + } + + setFieldFormat(column: DatatableColumn, fieldFormat: FieldFormat): void { + column.meta.params = fieldFormat.toJSON(); + } +} diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.4.js b/src/plugins/data/common/datatable_utilities/index.ts similarity index 78% rename from packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.4.js rename to src/plugins/data/common/datatable_utilities/index.ts index 6dc8aa803613d3..34df78137510a4 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.4.js +++ b/src/plugins/data/common/datatable_utilities/index.ts @@ -6,10 +6,4 @@ * Side Public License, v 1. */ -export default function () { - return { - screenshots: { - directory: 'bar', - }, - }; -} +export * from './datatable_utilities_service'; diff --git a/src/plugins/data/common/datatable_utilities/mock.ts b/src/plugins/data/common/datatable_utilities/mock.ts new file mode 100644 index 00000000000000..4266e501f2ca20 --- /dev/null +++ b/src/plugins/data/common/datatable_utilities/mock.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 type { DatatableUtilitiesService } from './datatable_utilities_service'; + +export function createDatatableUtilitiesMock(): jest.Mocked { + return { + clearField: jest.fn(), + clearFieldFormat: jest.fn(), + getAggConfig: jest.fn(), + getDataView: jest.fn(), + getField: jest.fn(), + getFieldFormat: jest.fn(), + isFilterable: jest.fn(), + setFieldFormat: jest.fn(), + } as unknown as jest.Mocked; +} diff --git a/src/plugins/data/common/es_query/index.ts b/src/plugins/data/common/es_query/index.ts index fa9b7ac86a7fa6..d717af0107e8c3 100644 --- a/src/plugins/data/common/es_query/index.ts +++ b/src/plugins/data/common/es_query/index.ts @@ -43,13 +43,10 @@ import { buildExistsFilter as oldBuildExistsFilter, toggleFilterNegated as oldtoggleFilterNegated, Filter as oldFilter, - RangeFilterMeta as oldRangeFilterMeta, RangeFilterParams as oldRangeFilterParams, ExistsFilter as oldExistsFilter, - PhrasesFilter as oldPhrasesFilter, PhraseFilter as oldPhraseFilter, MatchAllFilter as oldMatchAllFilter, - CustomFilter as oldCustomFilter, RangeFilter as oldRangeFilter, KueryNode as oldKueryNode, FilterMeta as oldFilterMeta, @@ -58,17 +55,11 @@ import { compareFilters as oldCompareFilters, COMPARE_ALL_OPTIONS as OLD_COMPARE_ALL_OPTIONS, dedupFilters as oldDedupFilters, - isFilter as oldIsFilter, onlyDisabledFiltersChanged as oldOnlyDisabledFiltersChanged, uniqFilters as oldUniqFilters, FilterStateStore, } from '@kbn/es-query'; -/** - * @deprecated Import from the "@kbn/es-query" package directly instead. - * @removeBy 8.1 - */ -const isFilter = oldIsFilter; /** * @deprecated Import from the "@kbn/es-query" package directly instead. * @removeBy 8.1 @@ -295,12 +286,6 @@ const FILTERS = oldFILTERS; */ type Filter = oldFilter; -/** - * @deprecated Import from the "@kbn/es-query" package directly instead. - * @removeBy 8.1 - */ -type RangeFilterMeta = oldRangeFilterMeta; - /** * @deprecated Import from the "@kbn/es-query" package directly instead. * @removeBy 8.1 @@ -313,12 +298,6 @@ type RangeFilterParams = oldRangeFilterParams; */ type ExistsFilter = oldExistsFilter; -/** - * @deprecated Import from the "@kbn/es-query" package directly instead. - * @removeBy 8.1 - */ -type PhrasesFilter = oldPhrasesFilter; - /** * @deprecated Import from the "@kbn/es-query" package directly instead. * @removeBy 8.1 @@ -331,12 +310,6 @@ type PhraseFilter = oldPhraseFilter; */ type MatchAllFilter = oldMatchAllFilter; -/** - * @deprecated Import from the "@kbn/es-query" package directly instead. - * @removeBy 8.1 - */ -type CustomFilter = oldCustomFilter; - /** * @deprecated Import from the "@kbn/es-query" package directly instead. * @removeBy 8.1 @@ -368,13 +341,10 @@ type EsQueryConfig = oldEsQueryConfig; export type { Filter, - RangeFilterMeta, RangeFilterParams, ExistsFilter, - PhrasesFilter, PhraseFilter, MatchAllFilter, - CustomFilter, RangeFilter, KueryNode, FilterMeta, @@ -415,7 +385,6 @@ export { buildExistsFilter, toggleFilterNegated, FILTERS, - isFilter, isFilterDisabled, dedupFilters, onlyDisabledFiltersChanged, diff --git a/src/plugins/data/common/index.ts b/src/plugins/data/common/index.ts index 7bb4b78850dcda..a793050eb6556d 100644 --- a/src/plugins/data/common/index.ts +++ b/src/plugins/data/common/index.ts @@ -10,6 +10,7 @@ /* eslint-disable @kbn/eslint/no_export_all */ export * from './constants'; +export * from './datatable_utilities'; export * from './es_query'; export * from './kbn_field_types'; export * from './query'; diff --git a/src/plugins/data/common/mocks.ts b/src/plugins/data/common/mocks.ts index c656d9d21346e1..cf7d6bef6a4e80 100644 --- a/src/plugins/data/common/mocks.ts +++ b/src/plugins/data/common/mocks.ts @@ -7,3 +7,4 @@ */ export * from '../../data_views/common/fields/fields.mocks'; +export * from './datatable_utilities/mock'; diff --git a/src/plugins/data/common/search/aggs/aggs_service.test.ts b/src/plugins/data/common/search/aggs/aggs_service.test.ts index 998b8bf286b52b..b7237c7b801342 100644 --- a/src/plugins/data/common/search/aggs/aggs_service.test.ts +++ b/src/plugins/data/common/search/aggs/aggs_service.test.ts @@ -206,11 +206,10 @@ describe('Aggs service', () => { describe('start()', () => { test('exposes proper contract', () => { const start = service.start(startDeps); - expect(Object.keys(start).length).toBe(4); + expect(Object.keys(start).length).toBe(3); expect(start).toHaveProperty('calculateAutoTimeExpression'); expect(start).toHaveProperty('createAggConfigs'); expect(start).toHaveProperty('types'); - expect(start).toHaveProperty('datatableUtilities'); }); test('types registry returns uninitialized type providers', () => { diff --git a/src/plugins/data/common/search/aggs/aggs_service.ts b/src/plugins/data/common/search/aggs/aggs_service.ts index 58f65bb0cab44b..6fe7eef5b87b45 100644 --- a/src/plugins/data/common/search/aggs/aggs_service.ts +++ b/src/plugins/data/common/search/aggs/aggs_service.ts @@ -17,7 +17,6 @@ import { getCalculateAutoTimeExpression, } from './'; import { AggsCommonSetup, AggsCommonStart } from './types'; -import { getDatatableColumnUtilities } from './utils/datatable_column_meta'; /** @internal */ export const aggsRequiredUiSettings = [ @@ -67,11 +66,7 @@ export class AggsCommonService { }; } - public start({ - getConfig, - getIndexPattern, - isDefaultTimezone, - }: AggsCommonStartDependencies): AggsCommonStart { + public start({ getConfig }: AggsCommonStartDependencies): AggsCommonStart { const aggTypesStart = this.aggTypesRegistry.start(); const calculateAutoTimeExpression = getCalculateAutoTimeExpression(getConfig); @@ -86,11 +81,6 @@ export class AggsCommonService { return { calculateAutoTimeExpression, - datatableUtilities: getDatatableColumnUtilities({ - getIndexPattern, - createAggConfigs, - aggTypesStart, - }), createAggConfigs, types: aggTypesStart, }; diff --git a/src/plugins/data/common/search/aggs/types.ts b/src/plugins/data/common/search/aggs/types.ts index 34d773b0ba518e..cf9a6123b14c8d 100644 --- a/src/plugins/data/common/search/aggs/types.ts +++ b/src/plugins/data/common/search/aggs/types.ts @@ -7,7 +7,6 @@ */ import { Assign } from '@kbn/utility-types'; -import { DatatableColumn } from 'src/plugins/expressions'; import { IndexPattern } from '../..'; import { aggAvg, @@ -88,7 +87,6 @@ import { CreateAggConfigParams, getCalculateAutoTimeExpression, METRIC_TYPES, - AggConfig, aggFilteredMetric, aggSinglePercentile, } from './'; @@ -111,11 +109,6 @@ export interface AggsCommonSetup { export interface AggsCommonStart { calculateAutoTimeExpression: ReturnType; - datatableUtilities: { - getIndexPattern: (column: DatatableColumn) => Promise; - getAggConfig: (column: DatatableColumn) => Promise; - isFilterable: (column: DatatableColumn) => boolean; - }; createAggConfigs: ( indexPattern: IndexPattern, configStates?: CreateAggConfigParams[] diff --git a/src/plugins/data/common/search/aggs/utils/datatable_column_meta.ts b/src/plugins/data/common/search/aggs/utils/datatable_column_meta.ts deleted file mode 100644 index 0e3ff69fac1d12..00000000000000 --- a/src/plugins/data/common/search/aggs/utils/datatable_column_meta.ts +++ /dev/null @@ -1,57 +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. - */ - -import { DatatableColumn } from 'src/plugins/expressions/common'; -import { IndexPattern } from '../../..'; -import { AggConfigs, CreateAggConfigParams } from '../agg_configs'; -import { AggTypesRegistryStart } from '../agg_types_registry'; -import { IAggType } from '../agg_type'; - -export interface MetaByColumnDeps { - getIndexPattern: (id: string) => Promise; - createAggConfigs: ( - indexPattern: IndexPattern, - configStates?: CreateAggConfigParams[] - ) => InstanceType; - aggTypesStart: AggTypesRegistryStart; -} - -export const getDatatableColumnUtilities = (deps: MetaByColumnDeps) => { - const { getIndexPattern, createAggConfigs, aggTypesStart } = deps; - - const getIndexPatternFromDatatableColumn = async (column: DatatableColumn) => { - if (!column.meta.index) return; - - return await getIndexPattern(column.meta.index); - }; - - const getAggConfigFromDatatableColumn = async (column: DatatableColumn) => { - const indexPattern = await getIndexPatternFromDatatableColumn(column); - - if (!indexPattern) return; - - const aggConfigs = await createAggConfigs(indexPattern, [column.meta.sourceParams as any]); - return aggConfigs.aggs[0]; - }; - - const isFilterableAggDatatableColumn = (column: DatatableColumn) => { - if (column.meta.source !== 'esaggs') { - return false; - } - const aggType = (aggTypesStart.get(column.meta.sourceParams?.type as string) as any)( - {} - ) as IAggType; - return Boolean(aggType.createFilter); - }; - - return { - getIndexPattern: getIndexPatternFromDatatableColumn, - getAggConfig: getAggConfigFromDatatableColumn, - isFilterable: isFilterableAggDatatableColumn, - }; -}; diff --git a/src/plugins/data/public/deprecated.ts b/src/plugins/data/public/deprecated.ts index 0458a940482de2..6a6c7bbb2cd2c1 100644 --- a/src/plugins/data/public/deprecated.ts +++ b/src/plugins/data/public/deprecated.ts @@ -36,16 +36,12 @@ import { luceneStringToDsl, decorateQuery, FILTERS, - isFilter, isFilters, KueryNode, RangeFilter, - RangeFilterMeta, RangeFilterParams, ExistsFilter, - PhrasesFilter, PhraseFilter, - CustomFilter, MatchAllFilter, EsQueryConfig, FilterStateStore, @@ -139,16 +135,13 @@ export const esFilters = { export type { KueryNode, RangeFilter, - RangeFilterMeta, RangeFilterParams, ExistsFilter, - PhrasesFilter, PhraseFilter, - CustomFilter, MatchAllFilter, EsQueryConfig, }; -export { isFilter, isFilters }; +export { isFilters }; /** * @deprecated Import helpers from the "@kbn/es-query" package directly instead. diff --git a/src/plugins/data/public/mocks.ts b/src/plugins/data/public/mocks.ts index 53600a1f444695..630a29a8a7854d 100644 --- a/src/plugins/data/public/mocks.ts +++ b/src/plugins/data/public/mocks.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { createDatatableUtilitiesMock } from '../common/mocks'; import { DataPlugin, DataViewsContract } from '.'; import { fieldFormatsServiceMock } from '../../field_formats/public/mocks'; import { searchServiceMock } from './search/mocks'; @@ -58,6 +59,7 @@ const createStartContract = (): Start => { createFiltersFromRangeSelectAction: jest.fn(), }, autocomplete: autocompleteStartMock, + datatableUtilities: createDatatableUtilitiesMock(), search: searchServiceMock.createStartContract(), fieldFormats: fieldFormatsServiceMock.createStartContract(), query: queryStartMock, diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 7d19c1eb3ac19c..50795b44162476 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -42,7 +42,7 @@ import { APPLY_FILTER_TRIGGER, applyFilterTrigger } from './triggers'; import { UsageCollectionSetup } from '../../usage_collection/public'; import { getTableViewDescription } from './utils/table_inspector_view'; import { NowProvider, NowProviderInternalContract } from './now_provider'; -import { getAggsFormats } from '../common'; +import { getAggsFormats, DatatableUtilitiesService } from '../common'; export class DataPublicPlugin implements @@ -108,7 +108,7 @@ export class DataPublicPlugin uiActions: startServices().plugins.uiActions, uiSettings: startServices().core.uiSettings, fieldFormats: startServices().self.fieldFormats, - isFilterable: startServices().self.search.aggs.datatableUtilities.isFilterable, + isFilterable: startServices().self.datatableUtilities.isFilterable, })) ); @@ -166,12 +166,14 @@ export class DataPublicPlugin uiActions.getAction(ACTION_GLOBAL_APPLY_FILTER) ); + const datatableUtilities = new DatatableUtilitiesService(search.aggs, dataViews, fieldFormats); const dataServices = { actions: { createFiltersFromValueClickAction, createFiltersFromRangeSelectAction, }, autocomplete: this.autocomplete.start(), + datatableUtilities, fieldFormats, indexPatterns: dataViews, dataViews, diff --git a/src/plugins/data/public/search/aggs/aggs_service.test.ts b/src/plugins/data/public/search/aggs/aggs_service.test.ts index b0e6e0327e6544..101c2c909c7e1d 100644 --- a/src/plugins/data/public/search/aggs/aggs_service.test.ts +++ b/src/plugins/data/public/search/aggs/aggs_service.test.ts @@ -79,11 +79,10 @@ describe('AggsService - public', () => { describe('start()', () => { test('exposes proper contract', () => { const start = service.start(startDeps); - expect(Object.keys(start).length).toBe(4); + expect(Object.keys(start).length).toBe(3); expect(start).toHaveProperty('calculateAutoTimeExpression'); expect(start).toHaveProperty('createAggConfigs'); expect(start).toHaveProperty('types'); - expect(start).toHaveProperty('datatableUtilities'); }); test('types registry returns initialized agg types', () => { diff --git a/src/plugins/data/public/search/aggs/aggs_service.ts b/src/plugins/data/public/search/aggs/aggs_service.ts index 4907c3bcbad26e..99930a95831ea4 100644 --- a/src/plugins/data/public/search/aggs/aggs_service.ts +++ b/src/plugins/data/public/search/aggs/aggs_service.ts @@ -91,13 +91,11 @@ export class AggsService { public start({ fieldFormats, uiSettings, indexPatterns }: AggsStartDependencies): AggsStart { const isDefaultTimezone = () => uiSettings.isDefault('dateFormat:tz'); - const { calculateAutoTimeExpression, datatableUtilities, types } = this.aggsCommonService.start( - { - getConfig: this.getConfig!, - getIndexPattern: indexPatterns.get, - isDefaultTimezone, - } - ); + const { calculateAutoTimeExpression, types } = this.aggsCommonService.start({ + getConfig: this.getConfig!, + getIndexPattern: indexPatterns.get, + isDefaultTimezone, + }); const aggTypesDependencies: AggTypesDependencies = { calculateBounds: this.calculateBounds, @@ -137,7 +135,6 @@ export class AggsService { return { calculateAutoTimeExpression, - datatableUtilities, createAggConfigs: (indexPattern, configStates = []) => { return new AggConfigs(indexPattern, configStates, { typesRegistry }); }, diff --git a/src/plugins/data/public/search/aggs/mocks.ts b/src/plugins/data/public/search/aggs/mocks.ts index fb50058f083489..c45d024384ba6d 100644 --- a/src/plugins/data/public/search/aggs/mocks.ts +++ b/src/plugins/data/public/search/aggs/mocks.ts @@ -56,11 +56,6 @@ export const searchAggsSetupMock = (): AggsSetup => ({ export const searchAggsStartMock = (): AggsStart => ({ calculateAutoTimeExpression: getCalculateAutoTimeExpression(getConfig), - datatableUtilities: { - isFilterable: jest.fn(), - getAggConfig: jest.fn(), - getIndexPattern: jest.fn(), - }, createAggConfigs: jest.fn().mockImplementation((indexPattern, configStates = [], schemas) => { return new AggConfigs(indexPattern, configStates, { typesRegistry: mockAggTypesRegistry(), diff --git a/src/plugins/data/public/types.ts b/src/plugins/data/public/types.ts index e2e7c6b222b906..bfc35b8f39c512 100644 --- a/src/plugins/data/public/types.ts +++ b/src/plugins/data/public/types.ts @@ -14,6 +14,7 @@ import { ExpressionsSetup } from 'src/plugins/expressions/public'; import { DataViewsPublicPluginStart } from 'src/plugins/data_views/public'; import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; import { FieldFormatsSetup, FieldFormatsStart } from 'src/plugins/field_formats/public'; +import { DatatableUtilitiesService } from '../common'; import { AutocompleteSetup, AutocompleteStart } from './autocomplete'; import { createFiltersFromRangeSelectAction, createFiltersFromValueClickAction } from './actions'; import type { ISearchSetup, ISearchStart } from './search'; @@ -83,6 +84,12 @@ export interface DataPublicPluginStart { * {@link DataViewsContract} */ dataViews: DataViewsContract; + + /** + * Datatable type utility functions. + */ + datatableUtilities: DatatableUtilitiesService; + /** * index patterns service * {@link DataViewsContract} diff --git a/src/plugins/data/server/datatable_utilities/datatable_utilities_service.ts b/src/plugins/data/server/datatable_utilities/datatable_utilities_service.ts new file mode 100644 index 00000000000000..3909003cd4d2cb --- /dev/null +++ b/src/plugins/data/server/datatable_utilities/datatable_utilities_service.ts @@ -0,0 +1,43 @@ +/* + * 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 type { + ElasticsearchClient, + SavedObjectsClientContract, + UiSettingsServiceStart, +} from 'src/core/server'; +import type { FieldFormatsStart } from 'src/plugins/field_formats/server'; +import type { IndexPatternsServiceStart } from 'src/plugins/data_views/server'; +import { DatatableUtilitiesService as DatatableUtilitiesServiceCommon } from '../../common'; +import type { AggsStart } from '../search'; + +export class DatatableUtilitiesService { + constructor( + private aggs: AggsStart, + private dataViews: IndexPatternsServiceStart, + private fieldFormats: FieldFormatsStart, + private uiSettings: UiSettingsServiceStart + ) { + this.asScopedToClient = this.asScopedToClient.bind(this); + } + + async asScopedToClient( + savedObjectsClient: SavedObjectsClientContract, + elasticsearchClient: ElasticsearchClient + ): Promise { + const aggs = await this.aggs.asScopedToClient(savedObjectsClient, elasticsearchClient); + const dataViews = await this.dataViews.dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient + ); + const uiSettings = this.uiSettings.asScopedToClient(savedObjectsClient); + const fieldFormats = await this.fieldFormats.fieldFormatServiceFactory(uiSettings); + + return new DatatableUtilitiesServiceCommon(aggs, dataViews, fieldFormats); + } +} diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.3.js b/src/plugins/data/server/datatable_utilities/index.ts similarity index 60% rename from packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.3.js rename to src/plugins/data/server/datatable_utilities/index.ts index b68a5115553f54..34df78137510a4 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/__fixtures__/config.3.js +++ b/src/plugins/data/server/datatable_utilities/index.ts @@ -6,12 +6,4 @@ * Side Public License, v 1. */ -export default async function ({ readConfigFile }) { - const config4 = await readConfigFile(require.resolve('./config.4')); - return { - testFiles: ['baz'], - screenshots: { - ...config4.get('screenshots'), - }, - }; -} +export * from './datatable_utilities_service'; diff --git a/src/plugins/data/server/datatable_utilities/mock.ts b/src/plugins/data/server/datatable_utilities/mock.ts new file mode 100644 index 00000000000000..9ec069fda7ab09 --- /dev/null +++ b/src/plugins/data/server/datatable_utilities/mock.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { createDatatableUtilitiesMock as createDatatableUtilitiesCommonMock } from '../../common/mocks'; +import type { DatatableUtilitiesService } from './datatable_utilities_service'; + +export function createDatatableUtilitiesMock(): jest.Mocked { + return { + asScopedToClient: jest.fn(createDatatableUtilitiesCommonMock), + } as unknown as jest.Mocked; +} diff --git a/src/plugins/data/server/mocks.ts b/src/plugins/data/server/mocks.ts index 6fd670d869c20f..355e809888bd4f 100644 --- a/src/plugins/data/server/mocks.ts +++ b/src/plugins/data/server/mocks.ts @@ -16,6 +16,7 @@ import { createFieldFormatsStartMock, } from '../../field_formats/server/mocks'; import { createIndexPatternsStartMock } from './data_views/mocks'; +import { createDatatableUtilitiesMock } from './datatable_utilities/mock'; import { DataRequestHandlerContext } from './search'; import { AutocompleteSetup } from './autocomplete'; @@ -42,6 +43,7 @@ function createStartContract() { */ fieldFormats: createFieldFormatsStartMock(), indexPatterns: createIndexPatternsStartMock(), + datatableUtilities: createDatatableUtilitiesMock(), }; } diff --git a/src/plugins/data/server/plugin.ts b/src/plugins/data/server/plugin.ts index ab8e28755cd776..9d5b3792da566e 100644 --- a/src/plugins/data/server/plugin.ts +++ b/src/plugins/data/server/plugin.ts @@ -11,6 +11,7 @@ import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { PluginStart as DataViewsServerPluginStart } from 'src/plugins/data_views/server'; import { ConfigSchema } from '../config'; +import { DatatableUtilitiesService } from './datatable_utilities'; import type { ISearchSetup, ISearchStart, SearchEnhancements } from './search'; import { SearchService } from './search/search_service'; import { QueryService } from './query/query_service'; @@ -48,6 +49,11 @@ export interface DataPluginStart { */ fieldFormats: FieldFormatsStart; indexPatterns: DataViewsServerPluginStart; + + /** + * Datatable type utility functions. + */ + datatableUtilities: DatatableUtilitiesService; } export interface DataPluginSetupDependencies { @@ -115,10 +121,19 @@ export class DataServerPlugin } public start(core: CoreStart, { fieldFormats, dataViews }: DataPluginStartDependencies) { + const search = this.searchService.start(core, { fieldFormats, indexPatterns: dataViews }); + const datatableUtilities = new DatatableUtilitiesService( + search.aggs, + dataViews, + fieldFormats, + core.uiSettings + ); + return { + datatableUtilities, + search, fieldFormats, indexPatterns: dataViews, - search: this.searchService.start(core, { fieldFormats, indexPatterns: dataViews }), }; } diff --git a/src/plugins/data/server/search/aggs/aggs_service.ts b/src/plugins/data/server/search/aggs/aggs_service.ts index e65c6d4134970e..808c0e9cc8499f 100644 --- a/src/plugins/data/server/search/aggs/aggs_service.ts +++ b/src/plugins/data/server/search/aggs/aggs_service.ts @@ -72,17 +72,13 @@ export class AggsService { }; const isDefaultTimezone = () => getConfig('dateFormat:tz') === 'Browser'; - const { calculateAutoTimeExpression, datatableUtilities, types } = - this.aggsCommonService.start({ - getConfig, - getIndexPattern: ( - await indexPatterns.indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient - ) - ).get, - isDefaultTimezone, - }); + const { calculateAutoTimeExpression, types } = this.aggsCommonService.start({ + getConfig, + getIndexPattern: ( + await indexPatterns.indexPatternsServiceFactory(savedObjectsClient, elasticsearchClient) + ).get, + isDefaultTimezone, + }); const aggTypesDependencies: AggTypesDependencies = { calculateBounds: this.calculateBounds, @@ -118,7 +114,6 @@ export class AggsService { return { calculateAutoTimeExpression, - datatableUtilities, createAggConfigs: (indexPattern, configStates = []) => { return new AggConfigs(indexPattern, configStates, { typesRegistry }); }, diff --git a/src/plugins/data/server/search/aggs/mocks.ts b/src/plugins/data/server/search/aggs/mocks.ts index 3644a3c13c48d7..301bc3e5e12405 100644 --- a/src/plugins/data/server/search/aggs/mocks.ts +++ b/src/plugins/data/server/search/aggs/mocks.ts @@ -58,11 +58,6 @@ export const searchAggsSetupMock = (): AggsSetup => ({ const commonStartMock = (): AggsCommonStart => ({ calculateAutoTimeExpression: getCalculateAutoTimeExpression(getConfig), - datatableUtilities: { - getIndexPattern: jest.fn(), - getAggConfig: jest.fn(), - isFilterable: jest.fn(), - }, createAggConfigs: jest.fn().mockImplementation((indexPattern, configStates = [], schemas) => { return new AggConfigs(indexPattern, configStates, { typesRegistry: mockAggTypesRegistry(), diff --git a/src/plugins/input_control_vis/public/components/editor/control_editor.tsx b/src/plugins/input_control_vis/public/components/editor/control_editor.tsx index 76727fcaa645fc..cb0ea78d613b32 100644 --- a/src/plugins/input_control_vis/public/components/editor/control_editor.tsx +++ b/src/plugins/input_control_vis/public/components/editor/control_editor.tsx @@ -22,7 +22,7 @@ import { import { RangeControlEditor } from './range_control_editor'; import { ListControlEditor } from './list_control_editor'; import { getTitle, ControlParams, CONTROL_TYPES, ControlParamsOptions } from '../../editor_utils'; -import { IndexPattern } from '../../../../data/public'; +import { DataView } from '../../../../data_views/public'; import { InputControlVisDependencies } from '../../plugin'; import './control_editor.scss'; @@ -35,7 +35,7 @@ interface ControlEditorUiProps { handleRemoveControl: (controlIndex: number) => void; handleIndexPatternChange: (controlIndex: number, indexPatternId: string) => void; handleFieldNameChange: (controlIndex: number, fieldName: string) => void; - getIndexPattern: (indexPatternId: string) => Promise; + getIndexPattern: (indexPatternId: string) => Promise; handleOptionsChange: ( controlIndex: number, optionName: T, diff --git a/src/plugins/input_control_vis/public/components/editor/controls_tab.tsx b/src/plugins/input_control_vis/public/components/editor/controls_tab.tsx index 41a6b34259a728..0b000aa61f34ed 100644 --- a/src/plugins/input_control_vis/public/components/editor/controls_tab.tsx +++ b/src/plugins/input_control_vis/public/components/editor/controls_tab.tsx @@ -20,7 +20,7 @@ import { } from '@elastic/eui'; import { VisEditorOptionsProps } from 'src/plugins/visualizations/public'; -import { IndexPattern } from 'src/plugins/data/public'; +import { DataView } from '../../../../data_views/public'; import { ControlEditor } from './control_editor'; import { addControl, @@ -49,7 +49,7 @@ class ControlsTab extends PureComponent { type: CONTROL_TYPES.LIST, }; - getIndexPattern = async (indexPatternId: string): Promise => { + getIndexPattern = async (indexPatternId: string): Promise => { const [, startDeps] = await this.props.deps.core.getStartServices(); return await startDeps.data.indexPatterns.get(indexPatternId); }; diff --git a/src/plugins/input_control_vis/public/components/editor/field_select.tsx b/src/plugins/input_control_vis/public/components/editor/field_select.tsx index 1ecbf2772ebfd0..7cc818b71d7950 100644 --- a/src/plugins/input_control_vis/public/components/editor/field_select.tsx +++ b/src/plugins/input_control_vis/public/components/editor/field_select.tsx @@ -12,7 +12,7 @@ import React, { Component } from 'react'; import { injectI18n, FormattedMessage, InjectedIntlProps } from '@kbn/i18n-react'; import { EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { IndexPattern, IndexPatternField } from '../../../../data/public'; +import { DataView, DataViewField } from '../../../../data_views/public'; interface FieldSelectUiState { isLoading: boolean; @@ -21,11 +21,11 @@ interface FieldSelectUiState { } export type FieldSelectUiProps = InjectedIntlProps & { - getIndexPattern: (indexPatternId: string) => Promise; + getIndexPattern: (indexPatternId: string) => Promise; indexPatternId: string; onChange: (value: any) => void; fieldName?: string; - filterField?: (field: IndexPatternField) => boolean; + filterField?: (field: DataViewField) => boolean; controlIndex: number; }; @@ -74,7 +74,7 @@ class FieldSelectUi extends Component { return; } - let indexPattern: IndexPattern; + let indexPattern: DataView; try { indexPattern = await this.props.getIndexPattern(indexPatternId); } catch (err) { @@ -96,7 +96,7 @@ class FieldSelectUi extends Component { const fields: Array> = []; indexPattern.fields .filter(this.props.filterField ?? (() => true)) - .forEach((field: IndexPatternField) => { + .forEach((field: DataViewField) => { const fieldsList = fieldsByTypeMap.get(field.type) ?? []; fieldsList.push(field.name); fieldsByTypeMap.set(field.type, fieldsList); diff --git a/src/plugins/input_control_vis/public/components/editor/list_control_editor.tsx b/src/plugins/input_control_vis/public/components/editor/list_control_editor.tsx index 2bf1bacbbcd5b8..720b1325142ec5 100644 --- a/src/plugins/input_control_vis/public/components/editor/list_control_editor.tsx +++ b/src/plugins/input_control_vis/public/components/editor/list_control_editor.tsx @@ -14,7 +14,8 @@ import { EuiFormRow, EuiFieldNumber, EuiSwitch, EuiSelect } from '@elastic/eui'; import { IndexPatternSelectFormRow } from './index_pattern_select_form_row'; import { FieldSelect } from './field_select'; import { ControlParams, ControlParamsOptions } from '../../editor_utils'; -import { IndexPattern, IndexPatternField, IndexPatternSelectProps } from '../../../../data/public'; +import { IndexPatternSelectProps } from '../../../../data/public'; +import { DataView, DataViewField } from '../../../../data_views/public'; import { InputControlVisDependencies } from '../../plugin'; interface ListControlEditorState { @@ -25,7 +26,7 @@ interface ListControlEditorState { } interface ListControlEditorProps { - getIndexPattern: (indexPatternId: string) => Promise; + getIndexPattern: (indexPatternId: string) => Promise; controlIndex: number; controlParams: ControlParams; handleFieldNameChange: (fieldName: string) => void; @@ -40,7 +41,7 @@ interface ListControlEditorProps { deps: InputControlVisDependencies; } -function filterField(field: IndexPatternField) { +function filterField(field: DataViewField) { return ( Boolean(field.aggregatable) && ['number', 'boolean', 'date', 'ip', 'string'].includes(field.type) @@ -104,7 +105,7 @@ export class ListControlEditor extends PureComponent< return; } - let indexPattern: IndexPattern; + let indexPattern: DataView; try { indexPattern = await this.props.getIndexPattern(this.props.controlParams.indexPattern); } catch (err) { @@ -116,7 +117,7 @@ export class ListControlEditor extends PureComponent< return; } - const field = (indexPattern.fields as IndexPatternField[]).find( + const field = (indexPattern.fields as DataViewField[]).find( ({ name }) => name === this.props.controlParams.fieldName ); if (!field) { diff --git a/src/plugins/input_control_vis/public/components/editor/range_control_editor.tsx b/src/plugins/input_control_vis/public/components/editor/range_control_editor.tsx index cdf8663caea568..913eb49c96cfef 100644 --- a/src/plugins/input_control_vis/public/components/editor/range_control_editor.tsx +++ b/src/plugins/input_control_vis/public/components/editor/range_control_editor.tsx @@ -14,13 +14,14 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { IndexPatternSelectFormRow } from './index_pattern_select_form_row'; import { FieldSelect } from './field_select'; import { ControlParams, ControlParamsOptions } from '../../editor_utils'; -import { IndexPattern, IndexPatternField, IndexPatternSelectProps } from '../../../../data/public'; +import { IndexPatternSelectProps } from '../../../../data/public'; +import { DataView, DataViewField } from '../../../../data_views/public'; import { InputControlVisDependencies } from '../../plugin'; interface RangeControlEditorProps { controlIndex: number; controlParams: ControlParams; - getIndexPattern: (indexPatternId: string) => Promise; + getIndexPattern: (indexPatternId: string) => Promise; handleFieldNameChange: (fieldName: string) => void; handleIndexPatternChange: (indexPatternId: string) => void; handleOptionsChange: ( @@ -35,7 +36,7 @@ interface RangeControlEditorState { IndexPatternSelect: ComponentType | null; } -function filterField(field: IndexPatternField) { +function filterField(field: DataViewField) { return field.type === 'number'; } diff --git a/src/plugins/input_control_vis/public/control/control.ts b/src/plugins/input_control_vis/public/control/control.ts index 2df4a417da43c3..26a88be6cd9078 100644 --- a/src/plugins/input_control_vis/public/control/control.ts +++ b/src/plugins/input_control_vis/public/control/control.ts @@ -9,7 +9,7 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { Filter } from 'src/plugins/data/public'; +import { Filter } from '@kbn/es-query'; import { ControlParams, ControlParamsOptions, CONTROL_TYPES } from '../editor_utils'; import { RangeFilterManager } from './filter_manager/range_filter_manager'; import { PhraseFilterManager } from './filter_manager/phrase_filter_manager'; diff --git a/src/plugins/input_control_vis/public/control/create_search_source.ts b/src/plugins/input_control_vis/public/control/create_search_source.ts index 87dec8b1d9a241..c9db1de9f7f225 100644 --- a/src/plugins/input_control_vis/public/control/create_search_source.ts +++ b/src/plugins/input_control_vis/public/control/create_search_source.ts @@ -9,15 +9,16 @@ import { Filter } from '@kbn/es-query'; import { SerializedSearchSourceFields, - IndexPattern, TimefilterContract, DataPublicPluginStart, } from 'src/plugins/data/public'; +import { DataView } from '../../../data_views/public'; + export async function createSearchSource( { create }: DataPublicPluginStart['search']['searchSource'], initialState: SerializedSearchSourceFields | null, - indexPattern: IndexPattern, + indexPattern: DataView, aggs: any, useTimeFilter: boolean, filters: Filter[] = [], diff --git a/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.test.ts b/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.test.ts index a96326a626a27d..7759ba3b346076 100644 --- a/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.test.ts +++ b/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.test.ts @@ -10,11 +10,8 @@ import expect from '@kbn/expect'; import { FilterManager } from './filter_manager'; import { coreMock } from '../../../../../core/public/mocks'; -import { - Filter, - FilterManager as QueryFilterManager, - IndexPatternsContract, -} from '../../../../data/public'; +import { FilterManager as QueryFilterManager, DataViewsContract } from '../../../../data/public'; +import { Filter } from '@kbn/es-query'; const setupMock = coreMock.createSetup(); @@ -44,7 +41,7 @@ describe('FilterManager', function () { controlId, 'field1', '1', - {} as IndexPatternsContract, + {} as DataViewsContract, queryFilterMock ); }); diff --git a/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.ts b/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.ts index f35eb364ecaf61..420cb8fe844d71 100644 --- a/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.ts +++ b/src/plugins/input_control_vis/public/control/filter_manager/filter_manager.ts @@ -6,23 +6,20 @@ * Side Public License, v 1. */ +import { Filter } from '@kbn/es-query'; import _ from 'lodash'; -import { - FilterManager as QueryFilterManager, - IndexPattern, - Filter, - IndexPatternsContract, -} from '../../../../data/public'; +import { FilterManager as QueryFilterManager, DataViewsContract } from '../../../../data/public'; +import { DataView } from '../../../../data_views/public'; export abstract class FilterManager { - protected indexPattern: IndexPattern | undefined; + protected indexPattern: DataView | undefined; constructor( public controlId: string, public fieldName: string, private indexPatternId: string, - private indexPatternsService: IndexPatternsContract, + private indexPatternsService: DataViewsContract, protected queryFilter: QueryFilterManager ) {} @@ -48,7 +45,7 @@ export abstract class FilterManager { } } - getIndexPattern(): IndexPattern | undefined { + getIndexPattern(): DataView | undefined { return this.indexPattern; } diff --git a/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.test.ts b/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.test.ts index 14a616e8a0dbed..45e67ad742a645 100644 --- a/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.test.ts +++ b/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.test.ts @@ -9,11 +9,8 @@ import { Filter } from '@kbn/es-query'; import expect from '@kbn/expect'; -import { - IndexPattern, - FilterManager as QueryFilterManager, - IndexPatternsContract, -} from '../../../../data/public'; +import { FilterManager as QueryFilterManager, DataViewsContract } from '../../../../data/public'; +import { DataView } from '../../../../data_views/public'; import { PhraseFilterManager } from './phrase_filter_manager'; describe('PhraseFilterManager', function () { @@ -27,7 +24,7 @@ describe('PhraseFilterManager', function () { convert: (value: any) => value, }, }; - const indexPatternMock: IndexPattern = { + const indexPatternMock: DataView = { id: indexPatternId, fields: { getByName: (name: string) => { @@ -35,10 +32,10 @@ describe('PhraseFilterManager', function () { return fields[name]; }, }, - } as IndexPattern; + } as DataView; const indexPatternsServiceMock = { get: jest.fn().mockReturnValue(Promise.resolve(indexPatternMock)), - } as unknown as jest.Mocked; + } as unknown as jest.Mocked; const queryFilterMock: QueryFilterManager = {} as QueryFilterManager; let filterManager: PhraseFilterManager; beforeEach(async () => { @@ -89,7 +86,7 @@ describe('PhraseFilterManager', function () { id: string, fieldName: string, indexPatternId: string, - indexPatternsService: IndexPatternsContract, + indexPatternsService: DataViewsContract, queryFilter: QueryFilterManager ) { super(id, fieldName, indexPatternId, indexPatternsService, queryFilter); @@ -105,7 +102,7 @@ describe('PhraseFilterManager', function () { } } - const indexPatternsServiceMock = {} as IndexPatternsContract; + const indexPatternsServiceMock = {} as DataViewsContract; const queryFilterMock: QueryFilterManager = {} as QueryFilterManager; let filterManager: MockFindFiltersPhraseFilterManager; beforeEach(() => { diff --git a/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.ts b/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.ts index 98ba8b4fbcda8f..0653d25f16d449 100644 --- a/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.ts +++ b/src/plugins/input_control_vis/public/control/filter_manager/phrase_filter_manager.ts @@ -18,17 +18,14 @@ import { PhraseFilter, } from '@kbn/es-query'; import { FilterManager } from './filter_manager'; -import { - IndexPatternsContract, - FilterManager as QueryFilterManager, -} from '../../../../data/public'; +import { DataViewsContract, FilterManager as QueryFilterManager } from '../../../../data/public'; export class PhraseFilterManager extends FilterManager { constructor( controlId: string, fieldName: string, indexPatternId: string, - indexPatternsService: IndexPatternsContract, + indexPatternsService: DataViewsContract, queryFilter: QueryFilterManager ) { super(controlId, fieldName, indexPatternId, indexPatternsService, queryFilter); diff --git a/src/plugins/input_control_vis/public/control/filter_manager/range_filter_manager.test.ts b/src/plugins/input_control_vis/public/control/filter_manager/range_filter_manager.test.ts index bdcd1a34573d6e..a329773720bc9f 100644 --- a/src/plugins/input_control_vis/public/control/filter_manager/range_filter_manager.test.ts +++ b/src/plugins/input_control_vis/public/control/filter_manager/range_filter_manager.test.ts @@ -9,11 +9,8 @@ import expect from '@kbn/expect'; import { RangeFilterManager } from './range_filter_manager'; -import { - IndexPattern, - FilterManager as QueryFilterManager, - IndexPatternsContract, -} from '../../../../data/public'; +import { FilterManager as QueryFilterManager, DataViewsContract } from '../../../../data/public'; +import { DataView } from '../../../../data_views/public'; import { RangeFilter, RangeFilterMeta } from '@kbn/es-query'; describe('RangeFilterManager', function () { @@ -24,7 +21,7 @@ describe('RangeFilterManager', function () { const fieldMock = { name: 'field1', }; - const indexPatternMock: IndexPattern = { + const indexPatternMock: DataView = { id: indexPatternId, fields: { getByName: (name: any) => { @@ -34,10 +31,10 @@ describe('RangeFilterManager', function () { return fields[name]; }, }, - } as IndexPattern; + } as DataView; const indexPatternsServiceMock = { get: jest.fn().mockReturnValue(Promise.resolve(indexPatternMock)), - } as unknown as jest.Mocked; + } as unknown as jest.Mocked; const queryFilterMock: QueryFilterManager = {} as QueryFilterManager; let filterManager: RangeFilterManager; beforeEach(async () => { @@ -70,7 +67,7 @@ describe('RangeFilterManager', function () { id: string, fieldName: string, indexPatternId: string, - indexPatternsService: IndexPatternsContract, + indexPatternsService: DataViewsContract, queryFilter: QueryFilterManager ) { super(id, fieldName, indexPatternId, indexPatternsService, queryFilter); @@ -86,7 +83,7 @@ describe('RangeFilterManager', function () { } } - const indexPatternsServiceMock = {} as IndexPatternsContract; + const indexPatternsServiceMock = {} as DataViewsContract; const queryFilterMock: QueryFilterManager = {} as QueryFilterManager; let filterManager: MockFindFiltersRangeFilterManager; beforeEach(() => { diff --git a/src/plugins/input_control_vis/public/control/list_control_factory.ts b/src/plugins/input_control_vis/public/control/list_control_factory.ts index 39c5f259c2735a..f6bd0bc0cd28a0 100644 --- a/src/plugins/input_control_vis/public/control/list_control_factory.ts +++ b/src/plugins/input_control_vis/public/control/list_control_factory.ts @@ -9,11 +9,11 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { - IndexPatternField, TimefilterContract, SerializedSearchSourceFields, DataPublicPluginStart, } from 'src/plugins/data/public'; +import { DataViewField } from '../../../data_views/public'; import { Control, noValuesDisableMsg, noIndexPatternMsg } from './control'; import { PhraseFilterManager } from './filter_manager/phrase_filter_manager'; import { createSearchSource } from './create_search_source'; @@ -26,7 +26,7 @@ function getEscapedQuery(query = '') { } interface TermsAggArgs { - field?: IndexPatternField; + field?: DataViewField; size: number | null; direction: string; query?: string; diff --git a/src/plugins/input_control_vis/public/control/range_control_factory.ts b/src/plugins/input_control_vis/public/control/range_control_factory.ts index 906762266a7b35..6cd477d28b4f68 100644 --- a/src/plugins/input_control_vis/public/control/range_control_factory.ts +++ b/src/plugins/input_control_vis/public/control/range_control_factory.ts @@ -9,18 +9,15 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; -import { - IndexPatternField, - TimefilterContract, - DataPublicPluginStart, -} from 'src/plugins/data/public'; +import { TimefilterContract, DataPublicPluginStart } from 'src/plugins/data/public'; +import { DataViewField } from '../../../data_views/public'; import { Control, noValuesDisableMsg, noIndexPatternMsg } from './control'; import { RangeFilterManager } from './filter_manager/range_filter_manager'; import { createSearchSource } from './create_search_source'; import { ControlParams } from '../editor_utils'; import { InputControlVisDependencies } from '../plugin'; -const minMaxAgg = (field?: IndexPatternField) => { +const minMaxAgg = (field?: DataViewField) => { const aggBody: any = {}; if (field) { if (field.scripted) { diff --git a/src/plugins/input_control_vis/public/test_utils/get_index_pattern_mock.ts b/src/plugins/input_control_vis/public/test_utils/get_index_pattern_mock.ts index 122800198f092c..40f01b05d18b2e 100644 --- a/src/plugins/input_control_vis/public/test_utils/get_index_pattern_mock.ts +++ b/src/plugins/input_control_vis/public/test_utils/get_index_pattern_mock.ts @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { IndexPattern } from 'src/plugins/data/public'; +import { DataView } from 'src/plugins/data_views/public'; /** * Returns forced **Partial** IndexPattern for use in tests */ -export const getIndexPatternMock = (): Promise => { +export const getIndexPatternMock = (): Promise => { return Promise.resolve({ id: 'mockIndexPattern', title: 'mockIndexPattern', @@ -20,5 +20,5 @@ export const getIndexPatternMock = (): Promise => { { name: 'textField', type: 'string', aggregatable: false }, { name: 'numberField', type: 'number', aggregatable: true }, ], - } as IndexPattern); + } as DataView); }; diff --git a/src/plugins/input_control_vis/public/vis_controller.tsx b/src/plugins/input_control_vis/public/vis_controller.tsx index bb09a90bb9dd66..51c88962f3cb24 100644 --- a/src/plugins/input_control_vis/public/vis_controller.tsx +++ b/src/plugins/input_control_vis/public/vis_controller.tsx @@ -13,8 +13,9 @@ import { Subscription } from 'rxjs'; import { I18nStart } from 'kibana/public'; import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { Filter } from '@kbn/es-query'; import { VisualizationContainer } from '../../visualizations/public'; -import { FilterManager, Filter } from '../../data/public'; +import { FilterManager } from '../../data/public'; import { InputControlVis } from './components/vis/input_control_vis'; import { getControlFactory } from './control/control_factory'; diff --git a/src/plugins/input_control_vis/tsconfig.json b/src/plugins/input_control_vis/tsconfig.json index 5e53199bb1e6e8..43b1539e87da2c 100644 --- a/src/plugins/input_control_vis/tsconfig.json +++ b/src/plugins/input_control_vis/tsconfig.json @@ -13,6 +13,7 @@ "references": [ { "path": "../kibana_react/tsconfig.json" }, { "path": "../data/tsconfig.json"}, + { "path": "../data_views/tsconfig.json"}, { "path": "../expressions/tsconfig.json" }, { "path": "../visualizations/tsconfig.json" }, { "path": "../vis_default_editor/tsconfig.json" }, diff --git a/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx b/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx index 4c71581fcb0bff..59710cbcff6164 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/vis_editor.tsx @@ -239,6 +239,7 @@ export class VisEditor extends Component ({ async fn( input, args, - { getSearchSessionId, isSyncColorsEnabled, getExecutionContext, inspectorAdapters } + { + getSearchSessionId, + isSyncColorsEnabled, + getExecutionContext, + inspectorAdapters, + abortSignal: expressionAbortSignal, + } ) { const visParams: TimeseriesVisParams = JSON.parse(args.params); const uiState = JSON.parse(args.uiState); @@ -70,6 +76,7 @@ export const createMetricsFn = (): TimeseriesExpressionFunctionDefinition => ({ searchSessionId: getSearchSessionId(), executionContext: getExecutionContext(), inspectorAdapters, + expressionAbortSignal, }); return { diff --git a/src/plugins/vis_types/timeseries/public/request_handler.ts b/src/plugins/vis_types/timeseries/public/request_handler.ts index bb15f32886cdc7..dcb1b0691602d3 100644 --- a/src/plugins/vis_types/timeseries/public/request_handler.ts +++ b/src/plugins/vis_types/timeseries/public/request_handler.ts @@ -22,6 +22,7 @@ interface MetricsRequestHandlerParams { searchSessionId?: string; executionContext?: KibanaExecutionContext; inspectorAdapters?: Adapters; + expressionAbortSignal: AbortSignal; } export const metricsRequestHandler = async ({ @@ -31,63 +32,72 @@ export const metricsRequestHandler = async ({ searchSessionId, executionContext, inspectorAdapters, + expressionAbortSignal, }: MetricsRequestHandlerParams): Promise => { - const config = getUISettings(); - const data = getDataStart(); - const theme = getCoreStart().theme; + if (!expressionAbortSignal.aborted) { + const config = getUISettings(); + const data = getDataStart(); + const theme = getCoreStart().theme; + const abortController = new AbortController(); + const expressionAbortHandler = function () { + abortController.abort(); + }; - const timezone = getTimezone(config); - const uiStateObj = uiState[visParams.type] ?? {}; - const dataSearch = data.search; - const parsedTimeRange = data.query.timefilter.timefilter.calculateBounds(input?.timeRange!); + expressionAbortSignal.addEventListener('abort', expressionAbortHandler); - if (visParams && visParams.id && !visParams.isModelInvalid) { - const untrackSearch = - dataSearch.session.isCurrentSession(searchSessionId) && - dataSearch.session.trackSearch({ - abort: () => { - // TODO: support search cancellations - }, - }); + const timezone = getTimezone(config); + const uiStateObj = uiState[visParams.type] ?? {}; + const dataSearch = data.search; + const parsedTimeRange = data.query.timefilter.timefilter.calculateBounds(input?.timeRange!); - try { - const searchSessionOptions = dataSearch.session.getSearchOptions(searchSessionId); + if (visParams && visParams.id && !visParams.isModelInvalid && !expressionAbortSignal.aborted) { + const untrackSearch = + dataSearch.session.isCurrentSession(searchSessionId) && + dataSearch.session.trackSearch({ + abort: () => abortController.abort(), + }); - const visData: TimeseriesVisData = await getCoreStart().http.post(ROUTES.VIS_DATA, { - body: JSON.stringify({ - timerange: { - timezone, - ...parsedTimeRange, - }, - query: input?.query, - filters: input?.filters, - panels: [visParams], - state: uiStateObj, - ...(searchSessionOptions && { - searchSession: searchSessionOptions, + try { + const searchSessionOptions = dataSearch.session.getSearchOptions(searchSessionId); + + const visData: TimeseriesVisData = await getCoreStart().http.post(ROUTES.VIS_DATA, { + body: JSON.stringify({ + timerange: { + timezone, + ...parsedTimeRange, + }, + query: input?.query, + filters: input?.filters, + panels: [visParams], + state: uiStateObj, + ...(searchSessionOptions && { + searchSession: searchSessionOptions, + }), }), - }), - context: executionContext, - }); + context: executionContext, + signal: abortController.signal, + }); - inspectorAdapters?.requests?.reset(); + inspectorAdapters?.requests?.reset(); - Object.entries(visData.trackedEsSearches || {}).forEach(([key, query]) => { - inspectorAdapters?.requests - ?.start(query.label ?? key, { searchSessionId }) - .json(query.body) - .ok({ time: query.time }); + Object.entries(visData.trackedEsSearches || {}).forEach(([key, query]) => { + inspectorAdapters?.requests + ?.start(query.label ?? key, { searchSessionId }) + .json(query.body) + .ok({ time: query.time }); - if (query.response && config.get(UI_SETTINGS.ALLOW_CHECKING_FOR_FAILED_SHARDS)) { - handleResponse({ body: query.body }, { rawResponse: query.response }, theme); - } - }); + if (query.response && config.get(UI_SETTINGS.ALLOW_CHECKING_FOR_FAILED_SHARDS)) { + handleResponse({ body: query.body }, { rawResponse: query.response }, theme); + } + }); - return visData; - } finally { - if (untrackSearch && dataSearch.session.isCurrentSession(searchSessionId)) { - // untrack if this search still belongs to current session - untrackSearch(); + return visData; + } finally { + if (untrackSearch && dataSearch.session.isCurrentSession(searchSessionId)) { + // untrack if this search still belongs to current session + untrackSearch(); + } + expressionAbortSignal.removeEventListener('abort', expressionAbortHandler); } } } diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts index 1a52132612f718..f52d1bd9b74273 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts @@ -7,8 +7,8 @@ */ import { IndexPatternsService } from '../../../../../../data/common'; - import { from } from 'rxjs'; + import { AbstractSearchStrategy, EsSearchRequest } from './abstract_search_strategy'; import type { FieldSpec } from '../../../../../../data/common'; import type { CachedIndexPatternFetcher } from '../lib/cached_index_pattern_fetcher'; @@ -76,6 +76,9 @@ describe('AbstractSearchStrategy', () => { isStored: true, }, }, + events: { + aborted$: from([]), + }, } as unknown as VisTypeTimeseriesVisDataRequest, searches ); @@ -90,6 +93,7 @@ describe('AbstractSearchStrategy', () => { indexType: undefined, }, { + abortSignal: new AbortController().signal, sessionId: '1', isRestore: false, isStored: true, diff --git a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index 1d3650ccedbd38..58c67f84a9373f 100644 --- a/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_types/timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -5,9 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - import { tap } from 'rxjs/operators'; import { omit } from 'lodash'; +import type { Observable } from 'rxjs'; import { IndexPatternsService } from '../../../../../../data/server'; import { toSanitizedFieldType } from '../../../../common/fields_utils'; @@ -27,6 +27,12 @@ export interface EsSearchRequest { }; } +function getRequestAbortedSignal(aborted$: Observable): AbortSignal { + const controller = new AbortController(); + aborted$.subscribe(() => controller.abort()); + return controller.signal; +} + export abstract class AbstractSearchStrategy { async search( requestContext: VisTypeTimeseriesRequestHandlerContext, @@ -37,6 +43,10 @@ export abstract class AbstractSearchStrategy { ) { const requests: any[] = []; + // User may abort the request without waiting for the results + // we need to handle this scenario by aborting underlying server requests + const abortSignal = getRequestAbortedSignal(req.events.aborted$); + esRequests.forEach(({ body, index, trackingEsSearchMeta }) => { const startTime = Date.now(); requests.push( @@ -49,7 +59,7 @@ export abstract class AbstractSearchStrategy { index, }, }, - req.body.searchSession + { ...req.body.searchSession, abortSignal } ) .pipe( tap((data) => { diff --git a/src/plugins/visualizations/kibana.json b/src/plugins/visualizations/kibana.json index c7fd9c977bc2e5..79b04f132077bc 100644 --- a/src/plugins/visualizations/kibana.json +++ b/src/plugins/visualizations/kibana.json @@ -12,9 +12,10 @@ "embeddable", "inspector", "savedObjects", + "screenshotMode", "presentationUtil" ], - "optionalPlugins": [ "home", "share", "usageCollection", "spaces", "savedObjectsTaggingOss"], + "optionalPlugins": ["home", "share", "usageCollection", "spaces", "savedObjectsTaggingOss"], "requiredBundles": ["kibanaUtils", "discover", "kibanaReact", "home"], "extraPublicDirs": ["common/constants", "common/utils", "common/expression_functions"], "owner": { diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index 0fc142aeead63b..69a7c61e688936 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -23,6 +23,7 @@ import { urlForwardingPluginMock } from '../../../plugins/url_forwarding/public/ import { navigationPluginMock } from '../../../plugins/navigation/public/mocks'; import { presentationUtilPluginMock } from '../../../plugins/presentation_util/public/mocks'; import { savedObjectTaggingOssPluginMock } from '../../saved_objects_tagging_oss/public/mocks'; +import { screenshotModePluginMock } from '../../screenshot_mode/public/mocks'; const createSetupContract = (): VisualizationsSetup => ({ createBaseVisualization: jest.fn(), @@ -68,6 +69,7 @@ const createInstance = async () => { navigation: navigationPluginMock.createStartContract(), presentationUtil: presentationUtilPluginMock.createStartContract(coreMock.createStart()), urlForwarding: urlForwardingPluginMock.createStartContract(), + screenshotMode: screenshotModePluginMock.createStartContract(), }); return { diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index c8c4d57543a02c..92bcf1dfe6a964 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -86,6 +86,7 @@ import type { SharePluginSetup, SharePluginStart } from '../../share/public'; import type { UrlForwardingSetup, UrlForwardingStart } from '../../url_forwarding/public'; import type { PresentationUtilPluginStart } from '../../presentation_util/public'; import type { UsageCollectionStart } from '../../usage_collection/public'; +import type { ScreenshotModePluginStart } from '../../screenshot_mode/public'; import type { HomePublicPluginSetup } from '../../home/public'; import type { SpacesPluginStart } from '../../../../x-pack/plugins/spaces/public'; @@ -130,6 +131,7 @@ export interface VisualizationsStartDeps { share?: SharePluginStart; urlForwarding: UrlForwardingStart; usageCollection?: UsageCollectionStart; + screenshotMode: ScreenshotModePluginStart; } /** @@ -289,6 +291,11 @@ export class VisualizationsPlugin params.element.classList.add('visAppWrapper'); const { renderApp } = await import('./visualize_app'); + if (pluginsStart.screenshotMode.isScreenshotMode()) { + params.element.classList.add('visEditorScreenshotModeActive'); + // @ts-expect-error TS error, cannot find type declaration for scss + await import('./visualize_screenshot_mode.scss'); + } const unmount = renderApp(params, services); return () => { data.search.session.clear(); diff --git a/src/plugins/visualizations/public/visualize_screenshot_mode.scss b/src/plugins/visualizations/public/visualize_screenshot_mode.scss new file mode 100644 index 00000000000000..b0a8bb35835bdd --- /dev/null +++ b/src/plugins/visualizations/public/visualize_screenshot_mode.scss @@ -0,0 +1,60 @@ +/* hide unusable controls */ +/* TODO: This is the legacy way of hiding chrome elements. Rather use chrome.setIsVisible */ +kbn-top-nav, +filter-bar, +.kbnTopNavMenu__wrapper, +::-webkit-scrollbar, +.euiNavDrawer { + display: none !important; +} + +/* hide unusable controls +* !important is required to override resizable panel inline display */ +.visEditorScreenshotModeActive .visEditor__content .visEditor--default > :not(.visEditor__visualization__wrapper) { + display: none !important; +} + +/** THIS IS FOR TSVB UNTIL REFACTOR **/ +.visEditorScreenshotModeActive .tvbEditorVisualization { + position: static !important; +} +.visEditorScreenshotModeActive .visualize .tvbVisTimeSeries__legendToggle { + /* all non-content rows in interface */ + display: none; +} + +.visEditorScreenshotModeActive .tvbEditor--hideForReporting { + /* all non-content rows in interface */ + display: none; +} +/** END TSVB BAD BAD HACKS **/ + +/* remove left padding from visualizations so that map lines up with .leaflet-container and +* setting the position to be fixed and to take up the entire screen, because some zoom levels/viewports +* are triggering the media breakpoints that cause the .visEditor__canvas to take up more room than the viewport */ + +.visEditorScreenshotModeActive .visEditor .visEditor__canvas { + padding-left: 0; + position: fixed; + width: 100%; + height: 100%; + top: 0; + left: 0; +} + +/** + * Visualization tweaks + */ + +/* hide unusable controls */ +.visEditorScreenshotModeActive .visualize .visLegend__toggle, +.visEditorScreenshotModeActive .visualize .kbnAggTable__controls, +.visEditorScreenshotModeActive .visualize .leaflet-container .leaflet-top.leaflet-left, +.visEditorScreenshotModeActive .visualize paginate-controls /* page numbers */ { + display: none; +} + +/* Ensure the min-height of the small breakpoint isn't used */ +.visEditorScreenshotModeActive .vis-editor visualization { + min-height: 0 !important; +} diff --git a/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.test.ts b/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.test.ts index 94daa0030cd600..15f5a37edb910e 100644 --- a/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.test.ts +++ b/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.test.ts @@ -33,6 +33,10 @@ describe('wrapScopedClusterClient', () => { jest.useRealTimers(); }); + afterEach(() => { + jest.resetAllMocks(); + }); + test('searches with asInternalUser when specified', async () => { const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); const childClient = elasticsearchServiceMock.createElasticsearchClient(); @@ -119,6 +123,35 @@ describe('wrapScopedClusterClient', () => { ).rejects.toThrowErrorMatchingInlineSnapshot(`"something went wrong!"`); }); + test('handles empty search result object', async () => { + const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + const childClient = elasticsearchServiceMock.createElasticsearchClient(); + + ( + scopedClusterClient.asInternalUser as unknown as jest.Mocked + ).child.mockReturnValue(childClient as unknown as Client); + const asInternalUserWrappedSearchFn = childClient.search; + // @ts-ignore incomplete return type + asInternalUserWrappedSearchFn.mockResolvedValue({}); + + const wrappedSearchClientFactory = createWrappedScopedClusterClientFactory({ + scopedClusterClient, + rule, + logger, + }); + + const wrappedSearchClient = wrappedSearchClientFactory.client(); + await wrappedSearchClient.asInternalUser.search(esQuery); + + expect(asInternalUserWrappedSearchFn).toHaveBeenCalledTimes(1); + expect(scopedClusterClient.asInternalUser.search).not.toHaveBeenCalled(); + expect(scopedClusterClient.asCurrentUser.search).not.toHaveBeenCalled(); + + const stats = wrappedSearchClientFactory.getMetrics(); + expect(stats.numSearches).toEqual(1); + expect(stats.esSearchDurationMs).toEqual(0); + }); + test('keeps track of number of queries', async () => { const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); const childClient = elasticsearchServiceMock.createElasticsearchClient(); diff --git a/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts b/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts index 00ee3302c88c51..dfe32a48ce4384 100644 --- a/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts +++ b/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts @@ -158,7 +158,7 @@ function getWrappedSearchFn(opts: WrapEsClientOpts) { took = (result as SearchResponse).took; } - opts.logMetricsFn({ esSearchDuration: took, totalSearchDuration: durationMs }); + opts.logMetricsFn({ esSearchDuration: took ?? 0, totalSearchDuration: durationMs }); return result; } catch (e) { throw e; diff --git a/x-pack/plugins/alerting/server/mocks.ts b/x-pack/plugins/alerting/server/mocks.ts index d58630e18cd4d8..a94b30b59104cd 100644 --- a/x-pack/plugins/alerting/server/mocks.ts +++ b/x-pack/plugins/alerting/server/mocks.ts @@ -11,6 +11,7 @@ import { Alert, AlertFactoryDoneUtils } from './alert'; import { elasticsearchServiceMock, savedObjectsClientMock, + uiSettingsServiceMock, } from '../../../../src/core/server/mocks'; import { AlertInstanceContext, AlertInstanceState } from './types'; @@ -105,6 +106,7 @@ const createAlertServicesMock = < done: jest.fn().mockReturnValue(alertFactoryMockDone), }, savedObjectsClient: savedObjectsClientMock.create(), + uiSettingsClient: uiSettingsServiceMock.createClient(), scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), shouldWriteAlerts: () => true, shouldStopExecution: () => true, diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 760aa6e0050a9b..939068e23e2b4a 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -384,6 +384,7 @@ export class AlertingPlugin { taskRunnerFactory.initialize({ logger, savedObjects: core.savedObjects, + uiSettings: core.uiSettings, elasticsearch: core.elasticsearch, getRulesClientWithRequest, spaceIdToNamespace, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 99feefb472df1c..bdebc66911e94d 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -30,6 +30,7 @@ import { executionContextServiceMock, savedObjectsServiceMock, elasticsearchServiceMock, + uiSettingsServiceMock, } from '../../../../../src/core/server/mocks'; import { PluginStartContract as ActionsPluginStart } from '../../../actions/server'; import { actionsMock, actionsClientMock } from '../../../actions/server/mocks'; @@ -95,6 +96,7 @@ describe('Task Runner', () => { const ruleTypeRegistry = ruleTypeRegistryMock.create(); const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); const elasticsearchService = elasticsearchServiceMock.createInternalStart(); + const uiSettingsService = uiSettingsServiceMock.createStartContract(); type TaskRunnerFactoryInitializerParamsType = jest.Mocked & { actionsPlugin: jest.Mocked; @@ -106,6 +108,7 @@ describe('Task Runner', () => { const taskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType = { savedObjects: savedObjectsService, + uiSettings: uiSettingsService, elasticsearch: elasticsearchService, actionsPlugin: actionsMock.createStart(), getRulesClientWithRequest: jest.fn().mockReturnValue(rulesClient), diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index dbc7749a0fbdf6..c05bdc3cf7bd94 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -352,14 +352,17 @@ export class TaskRunner< }] namespace`, }; + const savedObjectsClient = this.context.savedObjects.getScopedClient(fakeRequest, { + includedHiddenTypes: ['alert', 'action'], + }); + updatedRuleTypeState = await this.context.executionContext.withContext(ctx, () => this.ruleType.executor({ alertId: ruleId, executionId: this.executionId, services: { - savedObjectsClient: this.context.savedObjects.getScopedClient(fakeRequest, { - includedHiddenTypes: ['alert', 'action'], - }), + savedObjectsClient, + uiSettingsClient: this.context.uiSettings.asScopedToClient(savedObjectsClient), scopedClusterClient: wrappedScopedClusterClient.client(), alertFactory: createAlertFactory< InstanceState, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts index d70b36ff48a8f3..add8d7a24912da 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts @@ -25,6 +25,7 @@ import { executionContextServiceMock, savedObjectsServiceMock, elasticsearchServiceMock, + uiSettingsServiceMock, } from '../../../../../src/core/server/mocks'; import { PluginStartContract as ActionsPluginStart } from '../../../actions/server'; import { actionsMock, actionsClientMock } from '../../../actions/server/mocks'; @@ -94,6 +95,7 @@ describe('Task Runner Cancel', () => { const ruleTypeRegistry = ruleTypeRegistryMock.create(); const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); const elasticsearchService = elasticsearchServiceMock.createInternalStart(); + const uiSettingsService = uiSettingsServiceMock.createStartContract(); type TaskRunnerFactoryInitializerParamsType = jest.Mocked & { actionsPlugin: jest.Mocked; @@ -103,6 +105,7 @@ describe('Task Runner Cancel', () => { const taskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType = { savedObjects: savedObjectsService, + uiSettings: uiSettingsService, elasticsearch: elasticsearchService, actionsPlugin: actionsMock.createStart(), getRulesClientWithRequest: jest.fn().mockReturnValue(rulesClient), diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts index 6dea8df475503c..d4e92015d41129 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -16,6 +16,7 @@ import { httpServiceMock, savedObjectsServiceMock, elasticsearchServiceMock, + uiSettingsServiceMock, } from '../../../../../src/core/server/mocks'; import { actionsMock } from '../../../actions/server/mocks'; import { rulesClientMock } from '../mocks'; @@ -28,6 +29,7 @@ const executionContext = executionContextServiceMock.createSetupContract(); const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); +const uiSettingsService = uiSettingsServiceMock.createStartContract(); const elasticsearchService = elasticsearchServiceMock.createInternalStart(); const ruleType: UntypedNormalizedRuleType = { id: 'test', @@ -77,6 +79,7 @@ describe('Task Runner Factory', () => { const taskRunnerFactoryInitializerParams: jest.Mocked = { savedObjects: savedObjectsService, + uiSettings: uiSettingsService, elasticsearch: elasticsearchService, getRulesClientWithRequest: jest.fn().mockReturnValue(rulesClient), actionsPlugin: actionsMock.createStart(), diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts index f60370dd7daf7f..0b8ffe2f93d7bc 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -15,6 +15,7 @@ import type { ExecutionContextStart, SavedObjectsServiceStart, ElasticsearchServiceStart, + UiSettingsServiceStart, } from '../../../../../src/core/server'; import { RunContext } from '../../../task_manager/server'; import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server'; @@ -35,6 +36,7 @@ import { NormalizedRuleType } from '../rule_type_registry'; export interface TaskRunnerContext { logger: Logger; savedObjects: SavedObjectsServiceStart; + uiSettings: UiSettingsServiceStart; elasticsearch: ElasticsearchServiceStart; getRulesClientWithRequest(request: KibanaRequest): PublicMethodsOf; actionsPlugin: ActionsPluginStartContract; diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 95c1a07e241b21..1642cc13d4dec8 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -11,6 +11,7 @@ import type { RequestHandlerContext, SavedObjectReference, ElasticsearchClient, + IUiSettingsClient, } from 'src/core/server'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { AlertFactoryDoneUtils, PublicAlert } from './alert'; @@ -78,6 +79,7 @@ export interface AlertServices< ActionGroupIds extends string = never > { savedObjectsClient: SavedObjectsClientContract; + uiSettingsClient: IUiSettingsClient; scopedClusterClient: IScopedClusterClient; alertFactory: { create: (id: string) => PublicAlert; diff --git a/x-pack/plugins/apm/dev_docs/local_setup.md b/x-pack/plugins/apm/dev_docs/local_setup.md index 19864abd795ba5..b3c9d0acbaebc9 100644 --- a/x-pack/plugins/apm/dev_docs/local_setup.md +++ b/x-pack/plugins/apm/dev_docs/local_setup.md @@ -21,7 +21,7 @@ yarn es snapshot **Create APM mappings** ``` -node ./scripts/es_archiver load "x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_mappings_only_8.0.0" --es-url=http://elastic:changeme@localhost:9200 --kibana-url=http://elastic:changeme@localhost:5601 --config=./test/functional/config.js +node ./scripts/es_archiver load "x-pack/plugins/apm/ftr_e2e/cypress/fixtures/es_archiver/apm_mappings_only_8.0.0" --es-url=http://system_indices_superuser:changeme@localhost:9200 --kibana-url=http://elastic:changeme@localhost:5601 --config=./test/functional/config.js ``` *Note: Elasticsearch must be available before running the above command* diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index 8523c4b5757d47..84c62e62c3351b 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -5,11 +5,15 @@ * 2.0. */ -export const CSP_KUBEBEAT_INDEX_PATTERN = 'logs-k8s_cis*'; -export const CSP_FINDINGS_INDEX_NAME = 'findings'; export const STATS_ROUTE_PATH = '/api/csp/stats'; export const FINDINGS_ROUTE_PATH = '/api/csp/findings'; -export const AGENT_LOGS_INDEX_PATTERN = '.logs-k8s_cis.metadata*'; +export const BENCHMARKS_ROUTE_PATH = '/api/csp/benchmarks'; + +export const CSP_KUBEBEAT_INDEX_PATTERN = 'logs-cis_kubernetes_benchmark.findings*'; +export const AGENT_LOGS_INDEX_PATTERN = '.logs-cis_kubernetes_benchmark.metadata*'; + +export const CSP_FINDINGS_INDEX_NAME = 'findings'; +export const CIS_KUBERNETES_PACKAGE_NAME = 'cis_kubernetes_benchmark'; export const RULE_PASSED = `passed`; export const RULE_FAILED = `failed`; @@ -17,5 +21,9 @@ export const RULE_FAILED = `failed`; // A mapping of in-development features to their status. These features should be hidden from users but can be easily // activated via a simple code change in a single location. export const INTERNAL_FEATURE_FLAGS = { - benchmarks: false, + showBenchmarks: false, + showTrendLineMock: false, + showClusterMetaMock: false, + showManageRulesMock: false, + showRisksMock: false, } as const; diff --git a/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule.ts b/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule.ts new file mode 100644 index 00000000000000..d5c8e9fab1f2ea --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/common/schemas/csp_rule.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema as rt, TypeOf } from '@kbn/config-schema'; + +export const cspRuleAssetSavedObjectType = 'csp_rule'; + +// TODO: needs to be shared with kubebeat +export const cspRuleSchema = rt.object({ + id: rt.string(), + name: rt.string(), + description: rt.string(), + rationale: rt.string(), + impact: rt.string(), + default_value: rt.string(), + remediation: rt.string(), + benchmark: rt.object({ name: rt.string(), version: rt.string() }), + tags: rt.arrayOf(rt.string()), + enabled: rt.boolean(), + muted: rt.boolean(), +}); + +export type CspRuleSchema = TypeOf; diff --git a/x-pack/plugins/cloud_security_posture/common/types.ts b/x-pack/plugins/cloud_security_posture/common/types.ts index aa6b1d5bc98541..435b9836c5754e 100644 --- a/x-pack/plugins/cloud_security_posture/common/types.ts +++ b/x-pack/plugins/cloud_security_posture/common/types.ts @@ -9,25 +9,31 @@ export type Evaluation = 'passed' | 'failed' | 'NA'; /** number between 1-100 */ export type Score = number; -export interface FindingsResults { +export interface FindingsEvaluation { totalFindings: number; totalPassed: number; totalFailed: number; } -export interface Stats extends FindingsResults { +export interface Stats extends FindingsEvaluation { postureScore: Score; } -export interface ResourceTypeAgg extends FindingsResults { - resourceType: string; +export interface ResourceType extends FindingsEvaluation { + name: string; } -export interface BenchmarkStats extends Stats { - name: string; +export interface Cluster { + meta: { + clusterId: string; + benchmarkName: string; + }; + stats: Stats; + resourcesTypes: ResourceType[]; } -export interface CloudPostureStats extends Stats { - benchmarksStats: BenchmarkStats[]; - resourceTypesAggs: ResourceTypeAgg[]; +export interface CloudPostureStats { + stats: Stats; + resourcesTypes: ResourceType[]; + clusters: Cluster[]; } diff --git a/x-pack/plugins/cloud_security_posture/kibana.json b/x-pack/plugins/cloud_security_posture/kibana.json index 67143c15e2b7be..29f3813c211c7d 100755 --- a/x-pack/plugins/cloud_security_posture/kibana.json +++ b/x-pack/plugins/cloud_security_posture/kibana.json @@ -10,6 +10,6 @@ "description": "The cloud security posture plugin", "server": true, "ui": true, - "requiredPlugins": ["navigation", "data"], + "requiredPlugins": ["navigation", "data", "fleet"], "requiredBundles": ["kibanaReact"] } diff --git a/x-pack/plugins/cloud_security_posture/public/application/constants.tsx b/x-pack/plugins/cloud_security_posture/public/application/constants.tsx index b592a30aeb2b4a..128382d039f15c 100644 --- a/x-pack/plugins/cloud_security_posture/public/application/constants.tsx +++ b/x-pack/plugins/cloud_security_posture/public/application/constants.tsx @@ -12,4 +12,5 @@ export const pageToComponentMapping: Record = findings: pages.Findings, dashboard: pages.ComplianceDashboard, benchmarks: pages.Benchmarks, + rules: pages.Rules, }; diff --git a/x-pack/plugins/cloud_security_posture/public/application/index.tsx b/x-pack/plugins/cloud_security_posture/public/application/index.tsx index 6530483bf2198d..38fbef254ea448 100644 --- a/x-pack/plugins/cloud_security_posture/public/application/index.tsx +++ b/x-pack/plugins/cloud_security_posture/public/application/index.tsx @@ -7,8 +7,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; import { CspApp } from './app'; - import type { AppMountParameters, CoreStart } from '../../../../../src/core/public'; import type { CspClientPluginStartDeps } from '../types'; @@ -17,7 +17,12 @@ export const renderApp = ( deps: CspClientPluginStartDeps, params: AppMountParameters ) => { - ReactDOM.render(, params.element); + ReactDOM.render( + + + , + params.element + ); return () => ReactDOM.unmountComponentAtNode(params.element); }; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_kibana.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_kibana.ts new file mode 100644 index 00000000000000..b2d1d2a7b6bbe5 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_kibana.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 type { CoreStart } from '../../../../../../src/core/public'; +import type { CspClientPluginStartDeps } from '../../types'; +import { useKibana as useKibanaBase } from '../../../../../../src/plugins/kibana_react/public'; + +type CspKibanaContext = CoreStart & CspClientPluginStartDeps; + +export const useKibana = () => useKibanaBase(); diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts new file mode 100644 index 00000000000000..6cc1582d7ff7b8 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useHistory } from 'react-router-dom'; +import { Query } from '@kbn/es-query'; +import { allNavigationItems } from '../navigation/constants'; +import { encodeQuery } from '../navigation/query_utils'; +import { CspFindingsRequest } from '../../pages/findings/use_findings'; + +const getFindingsQuery = (queryValue: Query['query']): Pick => { + const query = + typeof queryValue === 'string' + ? queryValue + : // TODO: use a tested query builder instead ASAP + Object.entries(queryValue) + .reduce((a, [key, value]) => { + a.push(`${key} : "${value}"`); + return a; + }, []) + .join(' and '); + + return { + query: { + language: 'kuery', + // NOTE: a query object is valid TS but throws on runtime + query, + }, + }!; +}; + +export const useNavigateFindings = () => { + const history = useHistory(); + + return (query?: Query['query']) => { + history.push({ + pathname: allNavigationItems.findings.path, + ...(query && { search: encodeQuery(getFindingsQuery(query)) }), + }); + }; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts b/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts index bde28fa1ce3b52..4e07a4c800f53b 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts @@ -12,9 +12,10 @@ import type { CspPage, CspNavigationItem } from './types'; export const allNavigationItems: Record = { dashboard: { name: TEXT.DASHBOARD, path: '/dashboard' }, findings: { name: TEXT.FINDINGS, path: '/findings' }, + rules: { name: 'Rules', path: '/rules', disabled: true }, benchmarks: { name: TEXT.MY_BENCHMARKS, path: '/benchmarks', - disabled: !INTERNAL_FEATURE_FLAGS.benchmarks, + disabled: !INTERNAL_FEATURE_FLAGS.showBenchmarks, }, }; diff --git a/x-pack/plugins/cloud_security_posture/public/common/navigation/types.ts b/x-pack/plugins/cloud_security_posture/public/common/navigation/types.ts index 64db2e59b667f3..87f62b88ba171e 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/navigation/types.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/navigation/types.ts @@ -10,4 +10,4 @@ export interface CspNavigationItem { readonly disabled?: boolean; } -export type CspPage = 'dashboard' | 'findings' | 'benchmarks'; +export type CspPage = 'dashboard' | 'findings' | 'benchmarks' | 'rules'; diff --git a/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx b/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx index 5190f71a1721ac..2b0882d0916e69 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/chart_panel.tsx @@ -6,7 +6,6 @@ */ import React from 'react'; -import { css } from '@emotion/react'; import { EuiPanel, EuiText, @@ -64,7 +63,7 @@ export const ChartPanel: React.FC = ({ {title && ( - +

{title}

)} @@ -74,7 +73,3 @@ export const ChartPanel: React.FC = ({ ); }; - -const euiTitleStyle = css` - font-weight: 400; -`; diff --git a/x-pack/plugins/cloud_security_posture/public/components/csp_evaluation_badge.tsx b/x-pack/plugins/cloud_security_posture/public/components/csp_evaluation_badge.tsx index 8603bef59122e8..93aa87c18a9b83 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/csp_evaluation_badge.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/csp_evaluation_badge.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiBadge, EuiBadgeProps } from '@elastic/eui'; +import { EuiBadge, type EuiBadgeProps } from '@elastic/eui'; import { CSP_EVALUATION_BADGE_FAILED, CSP_EVALUATION_BADGE_PASSED } from './translations'; interface Props { diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cases_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cases_table.tsx new file mode 100644 index 00000000000000..30107d66897522 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cases_table.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { EuiIcon } from '@elastic/eui'; +import * as TEXT from '../translations'; + +export const CasesTable = () => { + return ( + + + + + + {TEXT.COMING_SOON} + + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx index 01dfd837fca2f8..a1f044241e5d31 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/cloud_posture_score_chart.tsx @@ -7,18 +7,23 @@ import React from 'react'; import { + AreaSeries, + Axis, Chart, ElementClickListener, + niceTimeFormatByDay, Partition, PartitionElementEvent, PartitionLayout, Settings, + timeFormatter, } from '@elastic/charts'; import { EuiFlexGroup, EuiText, EuiHorizontalRule, EuiFlexItem } from '@elastic/eui'; import { statusColors } from '../../../common/constants'; import type { Stats } from '../../../../common/types'; import * as TEXT from '../translations'; import { CompactFormattedNumber } from '../../../components/compact_formatted_number'; +import { INTERNAL_FEATURE_FLAGS } from '../../../../common/constants'; interface CloudPostureScoreChartProps { data: Stats; @@ -37,7 +42,7 @@ const ScoreChart = ({ ]; return ( - +
Trend Placeholder
; +const mockData = [ + [0, 9], + [1000, 70], + [2000, 40], + [4000, 90], + [5000, 53], +]; + +const ComplianceTrendChart = () => ( + + + + + + +); export const CloudPostureScoreChart = ({ data, @@ -97,8 +124,8 @@ export const CloudPostureScoreChart = ({ }: CloudPostureScoreChartProps) => ( - - + + @@ -106,7 +133,7 @@ export const CloudPostureScoreChart = ({ - + diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/compliance_stats.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/compliance_stats.tsx deleted file mode 100644 index 1dff4aba203b94..00000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/compliance_stats.tsx +++ /dev/null @@ -1,158 +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 React from 'react'; -import { - EuiStat, - EuiFlexItem, - EuiPanel, - EuiIcon, - EuiFlexGrid, - EuiText, - // EuiFlexGroup, -} from '@elastic/eui'; -// import { Chart, Settings, LineSeries } from '@elastic/charts'; -import type { IconType, EuiStatProps } from '@elastic/eui'; -import { useCloudPostureStatsApi } from '../../../common/api'; -import { statusColors } from '../../../common/constants'; -import { Score } from '../../../../common/types'; -import * as TEXT from '../translations'; -import { NO_DATA_TO_DISPLAY } from '../translations'; - -// type Trend = Array<[time: number, value: number]>; - -// TODO: this is the warning color hash listen in EUI's docs. need to find where to import it from. - -const getTitleColor = (value: Score): EuiStatProps['titleColor'] => { - if (value <= 65) return 'danger'; - if (value <= 95) return statusColors.warning; - if (value <= 100) return 'success'; - return 'default'; -}; - -const getScoreIcon = (value: Score): IconType => { - if (value <= 65) return 'alert'; - if (value <= 86) return 'alert'; - if (value <= 100) return 'check'; - return 'error'; -}; - -// TODO: make score trend check for length, cases for less than 2 or more than 5 should be handled -// const getScoreTrendPercentage = (scoreTrend: Trend) => { -// const beforeLast = scoreTrend[scoreTrend.length - 2][1]; -// const last = scoreTrend[scoreTrend.length - 1][1]; -// -// return Number((last - beforeLast).toFixed(1)); -// }; - -const placeholder = ( - - {NO_DATA_TO_DISPLAY} - -); - -export const ComplianceStats = () => { - const getStats = useCloudPostureStatsApi(); - // TODO: add error/loading state - if (!getStats.isSuccess) return null; - const { postureScore, benchmarksStats: benchmarks } = getStats.data; - - // TODO: in case we dont have a full length trend we will need to handle the sparkline chart alone. not rendering anything is just a temporary solution - if (!benchmarks || !postureScore) return null; - - // TODO: mock data, needs BE - // const scoreTrend = [ - // [0, 0], - // [1, 10], - // [2, 100], - // [3, 50], - // [4, postureScore], - // ] as Trend; - // - // const scoreChange = getScoreTrendPercentage(scoreTrend); - // const isPositiveChange = scoreChange > 0; - - const stats = [ - { - title: postureScore, - description: TEXT.POSTURE_SCORE, - titleColor: getTitleColor(postureScore), - iconType: getScoreIcon(postureScore), - }, - { - // TODO: remove placeholder for the commented out component, needs BE - title: placeholder, - description: TEXT.POSTURE_SCORE_TREND, - }, - // { - // title: ( - // - // - // {`${scoreChange}%`} - // - // ), - // description: 'Posture Score Trend', - // titleColor: isPositiveChange ? 'success' : 'danger', - // renderBody: ( - // <> - // - // - // - // - // - // ), - // }, - { - // TODO: this should count only ACTIVE benchmarks. needs BE - title: benchmarks.length, - description: TEXT.ACTIVE_FRAMEWORKS, - }, - { - // TODO: should be relatively simple to return from BE. needs BE - title: placeholder, - description: TEXT.TOTAL_RESOURCES, - }, - ]; - - return ( - - {stats.map((s) => ( - - - - { - // s.renderBody || - - } - - - - ))} - - ); -}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.test.ts b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.test.ts index 0c750e10f060a2..6b2c00c507e6f1 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.test.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.test.ts @@ -5,45 +5,45 @@ * 2.0. */ -import { getTop5Risks, RisksTableProps } from './risks_table'; +import { getTopRisks, RisksTableProps } from './risks_table'; const podsAgg = { - resourceType: 'pods', + name: 'pods', totalFindings: 2, totalPassed: 1, totalFailed: 1, }; const etcdAgg = { - resourceType: 'etcd', + name: 'etcd', totalFindings: 5, totalPassed: 0, totalFailed: 5, }; const clusterAgg = { - resourceType: 'cluster', + name: 'cluster', totalFindings: 2, totalPassed: 2, totalFailed: 0, }; const systemAgg = { - resourceType: 'system', + name: 'system', totalFindings: 10, totalPassed: 6, totalFailed: 4, }; const apiAgg = { - resourceType: 'api', + name: 'api', totalFindings: 19100, totalPassed: 2100, totalFailed: 17000, }; const serverAgg = { - resourceType: 'server', + name: 'server', totalFindings: 7, totalPassed: 4, totalFailed: 3, @@ -58,16 +58,16 @@ const mockData: RisksTableProps['data'] = [ serverAgg, ]; -describe('getTop5Risks', () => { +describe('getTopRisks', () => { it('returns sorted by failed findings', () => { - expect(getTop5Risks([systemAgg, etcdAgg, apiAgg])).toEqual([apiAgg, etcdAgg, systemAgg]); + expect(getTopRisks([systemAgg, etcdAgg, apiAgg], 3)).toEqual([apiAgg, etcdAgg, systemAgg]); }); it('return array filtered with failed findings only', () => { - expect(getTop5Risks([systemAgg, clusterAgg, apiAgg])).toEqual([apiAgg, systemAgg]); + expect(getTopRisks([systemAgg, clusterAgg, apiAgg], 3)).toEqual([apiAgg, systemAgg]); }); - it('return sorted and filtered array with no more then 5 elements', () => { - expect(getTop5Risks(mockData)).toEqual([apiAgg, etcdAgg, systemAgg, serverAgg, podsAgg]); + it('return sorted and filtered array with the correct number of elements', () => { + expect(getTopRisks(mockData, 5)).toEqual([apiAgg, etcdAgg, systemAgg, serverAgg, podsAgg]); }); }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx index fb43a8129ed774..1e355b3f3c82ff 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { EuiBasicTable, EuiButtonEmpty, @@ -14,50 +14,44 @@ import { EuiLink, EuiText, } from '@elastic/eui'; -import type { Query } from '@kbn/es-query'; -import { useHistory } from 'react-router-dom'; -import { CloudPostureStats, ResourceTypeAgg } from '../../../../common/types'; -import { allNavigationItems } from '../../../common/navigation/constants'; -import { encodeQuery } from '../../../common/navigation/query_utils'; +import { CloudPostureStats, ResourceType } from '../../../../common/types'; import { CompactFormattedNumber } from '../../../components/compact_formatted_number'; import * as TEXT from '../translations'; -import { RULE_FAILED } from '../../../../common/constants'; +import { INTERNAL_FEATURE_FLAGS } from '../../../../common/constants'; -// TODO: remove this option after we get data from the beat -const useMockData: boolean = false; -const mock = [ +const mockData = [ { - resourceType: 'pods', + name: 'pods', totalFindings: 2, totalPassed: 1, totalFailed: 1, }, { - resourceType: 'etcd', + name: 'etcd', totalFindings: 5, totalPassed: 0, totalFailed: 5, }, { - resourceType: 'cluster', + name: 'cluster', totalFindings: 2, totalPassed: 2, totalFailed: 0, }, { - resourceType: 'system', + name: 'system', totalFindings: 10, totalPassed: 6, totalFailed: 4, }, { - resourceType: 'api', + name: 'api', totalFindings: 19100, totalPassed: 2100, totalFailed: 17000, }, { - resourceType: 'server', + name: 'server', totalFindings: 7, totalPassed: 4, totalFailed: 3, @@ -65,62 +59,41 @@ const mock = [ ]; export interface RisksTableProps { - data: CloudPostureStats['resourceTypesAggs']; + data: CloudPostureStats['resourcesTypes']; + maxItems: number; + onCellClick: (resourceTypeName: string) => void; + onViewAllClick: () => void; } -const maxRisks = 5; - -export const getTop5Risks = (resourceTypesAggs: CloudPostureStats['resourceTypesAggs']) => { - const filtered = resourceTypesAggs.filter((x) => x.totalFailed > 0); +export const getTopRisks = ( + resourcesTypes: CloudPostureStats['resourcesTypes'], + maxItems: number +) => { + const filtered = resourcesTypes.filter((x) => x.totalFailed > 0); const sorted = filtered.slice().sort((first, second) => second.totalFailed - first.totalFailed); - return sorted.slice(0, maxRisks); + return sorted.slice(0, maxItems); }; -const getFailedFindingsQuery = (): Query => ({ - language: 'kuery', - query: `result.evaluation : "${RULE_FAILED}" `, -}); - -const getResourceTypeFailedFindingsQuery = (resourceType: string): Query => ({ - language: 'kuery', - query: `resource.type : "${resourceType}" and result.evaluation : "${RULE_FAILED}" `, -}); - -export const RisksTable = ({ data: resourceTypesAggs }: RisksTableProps) => { - const { push } = useHistory(); - - const handleCellClick = useCallback( - (resourceType: ResourceTypeAgg['resourceType']) => - push({ - pathname: allNavigationItems.findings.path, - search: encodeQuery(getResourceTypeFailedFindingsQuery(resourceType)), - }), - [push] - ); - - const handleViewAllClick = useCallback( - () => - push({ - pathname: allNavigationItems.findings.path, - search: encodeQuery(getFailedFindingsQuery()), - }), - [push] - ); - +export const RisksTable = ({ + data: resourcesTypes, + maxItems, + onCellClick, + onViewAllClick, +}: RisksTableProps) => { const columns = useMemo( () => [ { - field: 'resourceType', + field: 'name', name: TEXT.RESOURCE_TYPE, - render: (resourceType: ResourceTypeAgg['resourceType']) => ( - handleCellClick(resourceType)}>{resourceType} + render: (resourceTypeName: ResourceType['name']) => ( + onCellClick(resourceTypeName)}>{resourceTypeName} ), }, { field: 'totalFailed', - name: TEXT.FAILED_FINDINGS, - render: (totalFailed: ResourceTypeAgg['totalFailed'], resource: ResourceTypeAgg) => ( + name: TEXT.FINDINGS, + render: (totalFailed: ResourceType['totalFailed'], resource: ResourceType) => ( <> @@ -133,22 +106,24 @@ export const RisksTable = ({ data: resourceTypesAggs }: RisksTableProps) => { ), }, ], - [handleCellClick] + [onCellClick] ); + const items = useMemo(() => getTopRisks(resourcesTypes, maxItems), [resourcesTypes, maxItems]); + return ( - - rowHeader="resourceType" - items={useMockData ? getTop5Risks(mock) : getTop5Risks(resourceTypesAggs)} + + rowHeader="name" + items={INTERNAL_FEATURE_FLAGS.showRisksMock ? getTopRisks(mockData, maxItems) : items} columns={columns} /> - + {TEXT.VIEW_ALL_FAILED_FINDINGS} diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/score_per_account_chart.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/score_per_account_chart.tsx deleted file mode 100644 index fd47a3ecf9e436..00000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/score_per_account_chart.tsx +++ /dev/null @@ -1,45 +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 React from 'react'; -import { Axis, BarSeries, Chart, Settings } from '@elastic/charts'; -import { statusColors } from '../../../common/constants'; - -// soon to be deprecated -export const ScorePerAccountChart = () => { - return ( - - - - `${Number(v * 100).toFixed(0)}%`, - }} - id="bars" - data={[]} - xAccessor={'resource'} - yAccessors={['value']} - splitSeriesAccessors={['evaluation']} - stackAccessors={['evaluation']} - stackMode="percentage" - /> - - ); -}; - -const theme = { - colors: { vizColors: [statusColors.success, statusColors.danger] }, - barSeriesStyle: { - displayValue: { - fontSize: 14, - fill: { color: 'white', borderColor: 'blue', borderWidth: 0 }, - offsetX: 5, - offsetY: -5, - }, - }, -}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx index fcbfe47ea6d2ca..c94f138616f55a 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx @@ -7,27 +7,26 @@ import React from 'react'; import { - EuiFlexGrid, EuiFlexItem, EuiPanel, EuiIcon, - EuiTitle, EuiSpacer, - EuiDescriptionList, + EuiFlexGroup, + EuiText, + EuiButtonEmpty, + useEuiTheme, } from '@elastic/eui'; import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; -import { Query } from '@kbn/es-query'; -import { useHistory } from 'react-router-dom'; import { PartitionElementEvent } from '@elastic/charts'; +import { EuiThemeComputed } from '@elastic/eui/src/services/theme/types'; import { CloudPostureScoreChart } from '../compliance_charts/cloud_posture_score_chart'; -import { ComplianceTrendChart } from '../compliance_charts/compliance_trend_chart'; import { useCloudPostureStatsApi } from '../../../common/api/use_cloud_posture_stats_api'; -import { CspHealthBadge } from '../../../components/csp_health_badge'; import { ChartPanel } from '../../../components/chart_panel'; import * as TEXT from '../translations'; -import { allNavigationItems } from '../../../common/navigation/constants'; -import { encodeQuery } from '../../../common/navigation/query_utils'; import { Evaluation } from '../../../../common/types'; +import { RisksTable } from '../compliance_charts/risks_table'; +import { INTERNAL_FEATURE_FLAGS, RULE_FAILED } from '../../../../common/constants'; +import { useNavigateFindings } from '../../../common/hooks/use_navigate_findings'; const logoMap: ReadonlyMap = new Map([['CIS Kubernetes', 'logoKubernetes']]); @@ -35,118 +34,120 @@ const getBenchmarkLogo = (benchmarkName: string): EuiIconType => { return logoMap.get(benchmarkName) ?? 'logoElastic'; }; -const getBenchmarkEvaluationQuery = (name: string, evaluation: Evaluation): Query => ({ - language: 'kuery', - query: `rule.benchmark : "${name}" and result.evaluation : "${evaluation}"`, -}); +const mockClusterId = '2468540'; + +const cardHeight = 300; export const BenchmarksSection = () => { - const history = useHistory(); + const { euiTheme } = useEuiTheme(); + const navToFindings = useNavigateFindings(); const getStats = useCloudPostureStatsApi(); - const benchmarks = getStats.isSuccess && getStats.data.benchmarksStats; - if (!benchmarks) return null; + const clusters = getStats.isSuccess && getStats.data.clusters; + if (!clusters) return null; - const handleElementClick = (name: string, elements: PartitionElementEvent[]) => { + const handleElementClick = (clusterId: string, elements: PartitionElementEvent[]) => { const [element] = elements; const [layerValue] = element; - const rollupValue = layerValue[0].groupByRollup as Evaluation; + const evaluation = layerValue[0].groupByRollup as Evaluation; - history.push({ - pathname: allNavigationItems.findings.path, - search: encodeQuery(getBenchmarkEvaluationQuery(name, rollupValue)), + navToFindings({ cluster_id: clusterId, 'result.evaluation': evaluation }); + }; + + const handleCellClick = (clusterId: string, resourceTypeName: string) => { + navToFindings({ + cluster_id: clusterId, + 'resource.type': resourceTypeName, + 'result.evaluation': RULE_FAILED, }); }; + const handleViewAllClick = (clusterId: string) => { + navToFindings({ cluster_id: clusterId, 'result.evaluation': RULE_FAILED }); + }; + return ( <> - {benchmarks.map((benchmark) => ( - - - - - - -

{benchmark.name}

-
-
- - - - handleElementClick(benchmark.name, elements) - } - /> - - ), - }, - ]} - /> - - - - {/* TODO: no api for this chart yet, using empty state for now. needs BE */} - - - ), - }, - ]} - /> - - - - ) : ( - TEXT.ERROR - ), - }, - { - title: TEXT.TOTAL_FAILURES, - description: benchmark.totalFailed || TEXT.ERROR, - }, - ]} - /> - -
-
- ))} + {clusters.map((cluster) => { + const shortId = cluster.meta.clusterId.slice(0, 6); + + return ( + <> + + + + + + +

{cluster.meta.benchmarkName}

+
+ +

{`Cluster ID ${shortId || mockClusterId}`}

+
+ {INTERNAL_FEATURE_FLAGS.showClusterMetaMock && ( + + + {' Updated 7 second ago'} + + )} +
+ + + + + {INTERNAL_FEATURE_FLAGS.showManageRulesMock && ( + {'Manage Rules'} + )} + +
+
+ + + + handleElementClick(cluster.meta.clusterId, elements) + } + /> + + + + + + handleCellClick(cluster.meta.clusterId, resourceTypeName) + } + onViewAllClick={() => handleViewAllClick(cluster.meta.clusterId)} + /> + + +
+
+ + + ); + })} ); }; + +const getIntegrationBoxStyle = (euiTheme: EuiThemeComputed) => ({ + border: `1px solid ${euiTheme.colors.lightShade}`, + borderRadius: `${euiTheme.border.radius.medium} 0 0 ${euiTheme.border.radius.medium}`, + background: euiTheme.colors.lightestShade, +}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx index db768aa5c7b785..01dd072907472e 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/summary_section.tsx @@ -7,23 +7,16 @@ import React from 'react'; import { EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; -import { useHistory } from 'react-router-dom'; import { PartitionElementEvent } from '@elastic/charts'; -import { Query } from '@kbn/es-query'; -import { ScorePerAccountChart } from '../compliance_charts/score_per_account_chart'; import { ChartPanel } from '../../../components/chart_panel'; import { useCloudPostureStatsApi } from '../../../common/api'; import * as TEXT from '../translations'; import { CloudPostureScoreChart } from '../compliance_charts/cloud_posture_score_chart'; -import { allNavigationItems } from '../../../common/navigation/constants'; -import { encodeQuery } from '../../../common/navigation/query_utils'; import { Evaluation } from '../../../../common/types'; import { RisksTable } from '../compliance_charts/risks_table'; - -const getEvaluationQuery = (evaluation: Evaluation): Query => ({ - language: 'kuery', - query: `"result.evaluation : "${evaluation}"`, -}); +import { CasesTable } from '../compliance_charts/cases_table'; +import { useNavigateFindings } from '../../../common/hooks/use_navigate_findings'; +import { RULE_FAILED } from '../../../../common/constants'; const defaultHeight = 360; @@ -33,19 +26,24 @@ const summarySectionWrapperStyle = { }; export const SummarySection = () => { - const history = useHistory(); + const navToFindings = useNavigateFindings(); const getStats = useCloudPostureStatsApi(); if (!getStats.isSuccess) return null; const handleElementClick = (elements: PartitionElementEvent[]) => { const [element] = elements; const [layerValue] = element; - const rollupValue = layerValue[0].groupByRollup as Evaluation; + const evaluation = layerValue[0].groupByRollup as Evaluation; + + navToFindings({ 'result.evaluation': evaluation }); + }; - history.push({ - pathname: allNavigationItems.findings.path, - search: encodeQuery(getEvaluationQuery(rollupValue)), - }); + const handleCellClick = (resourceTypeName: string) => { + navToFindings({ 'resource.type': resourceTypeName, 'result.evaluation': RULE_FAILED }); + }; + + const handleViewAllClick = () => { + navToFindings({ 'result.evaluation': RULE_FAILED }); }; return ( @@ -58,24 +56,28 @@ export const SummarySection = () => { >
- + - {/* TODO: no api for this chart yet, using empty state for now. needs BE */} - + diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/translations.ts b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/translations.ts index 975c0069f1479d..0d62625f982541 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/translations.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/translations.ts @@ -19,12 +19,13 @@ export const RISKS = i18n.translate('xpack.csp.risks', { defaultMessage: 'Risks', }); -export const SCORE_PER_CLUSTER_CHART_TITLE = i18n.translate( - 'xpack.csp.score_per_cluster_chart_title', - { - defaultMessage: 'Score Per Account / Cluster', - } -); +export const OPEN_CASES = i18n.translate('xpack.csp.open_cases', { + defaultMessage: 'Open Cases', +}); + +export const COMING_SOON = i18n.translate('xpack.csp.coming_soon', { + defaultMessage: 'Coming soon', +}); export const COMPLIANCE_SCORE = i18n.translate('xpack.csp.compliance_score', { defaultMessage: 'Compliance Score', @@ -78,10 +79,6 @@ export const RESOURCE_TYPE = i18n.translate('xpack.csp.resource_type', { defaultMessage: 'Resource Type', }); -export const FAILED_FINDINGS = i18n.translate('xpack.csp.failed_findings', { - defaultMessage: 'Failed Findings', -}); - -export const NO_DATA_TO_DISPLAY = i18n.translate('xpack.csp.complianceDashboard.noDataLabel', { - defaultMessage: 'No data to display', +export const FINDINGS = i18n.translate('xpack.csp.findings', { + defaultMessage: 'Findings', }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts index c1b83bc671d165..38228e513e31bf 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts @@ -67,9 +67,10 @@ const mapEsQuerySortKey = (sort: readonly EsQuerySortValue[]): EsQuerySortValue[ }, []); const showResponseErrorToast = - ({ toasts: { addDanger } }: CoreStart['notifications']) => + ({ toasts }: CoreStart['notifications']) => (error: unknown): void => { - addDanger(extractErrorMessage(error, TEXT.SEARCH_FAILED)); + if (error instanceof Error) toasts.addError(error, { title: TEXT.SEARCH_FAILED }); + else toasts.addDanger(extractErrorMessage(error, TEXT.SEARCH_FAILED)); }; const extractFindings = ({ diff --git a/x-pack/plugins/cloud_security_posture/public/pages/index.ts b/x-pack/plugins/cloud_security_posture/public/pages/index.ts index 55d62913e4474f..1e667a8949fc00 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/index.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/index.ts @@ -8,3 +8,4 @@ export { Findings } from './findings'; export * from './compliance_dashboard'; export { Benchmarks } from './benchmarks'; +export { Rules } from './rules'; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/index.tsx b/x-pack/plugins/cloud_security_posture/public/pages/rules/index.tsx new file mode 100644 index 00000000000000..130f03fd5784d1 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/index.tsx @@ -0,0 +1,20 @@ +/* + * 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 type { EuiPageHeaderProps } from '@elastic/eui'; +import { CspPageTemplate } from '../../components/page_template'; + +// TODO: +// - get selected integration + +const pageHeader: EuiPageHeaderProps = { + pageTitle: 'Rules', +}; + +export const Rules = () => { + return ; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_rules.ts b/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_rules.ts new file mode 100644 index 00000000000000..a11256edaa40e2 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/rules/use_csp_rules.ts @@ -0,0 +1,66 @@ +/* + * 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 { useQuery, useMutation, useQueryClient } from 'react-query'; +import { cspRuleAssetSavedObjectType, type CspRuleSchema } from '../../../common/schemas/csp_rule'; +import type { + SavedObjectsBatchResponse, + SavedObjectsFindOptions, +} from '../../../../../../src/core/public'; +import { useKibana } from '../../common/hooks/use_kibana'; + +export type UseCspRulesOptions = Pick< + SavedObjectsFindOptions, + 'search' | 'searchFields' | 'page' | 'perPage' +>; + +export const useFindCspRules = ({ + search, + searchFields, + page = 1, + perPage = 10, +}: UseCspRulesOptions) => { + const { savedObjects } = useKibana().services; + return useQuery( + [cspRuleAssetSavedObjectType, { search, searchFields, page, perPage }], + () => + savedObjects.client.find({ + type: cspRuleAssetSavedObjectType, + search, + searchFields, + page, + // NOTE: 'name.raw' is a field maping we defined on 'name' so it'd also be sortable + // TODO: this needs to be shared or removed + sortField: 'name.raw', + perPage, + }), + { refetchOnWindowFocus: false } + ); +}; + +export const useBulkUpdateCspRules = () => { + const { savedObjects } = useKibana().services; + const queryClient = useQueryClient(); + + return useMutation( + (rules: CspRuleSchema[]) => + savedObjects.client.bulkUpdate( + rules.map((rule) => ({ + type: cspRuleAssetSavedObjectType, + id: rule.id, + attributes: rule, + })) + // TODO: fix bulkUpdate types in core + ) as Promise>, + { + onSettled: () => + queryClient.invalidateQueries({ + queryKey: cspRuleAssetSavedObjectType, + exact: false, + }), + } + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/server/index.ts b/x-pack/plugins/cloud_security_posture/server/index.ts index c0912e68218c82..f790ac5256ff8e 100755 --- a/x-pack/plugins/cloud_security_posture/server/index.ts +++ b/x-pack/plugins/cloud_security_posture/server/index.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import type { PluginInitializerContext } from '../../../../src/core/server'; import { CspPlugin } from './plugin'; diff --git a/x-pack/plugins/cloud_security_posture/server/lib/csp_app_services.ts b/x-pack/plugins/cloud_security_posture/server/lib/csp_app_services.ts new file mode 100644 index 00000000000000..f769ea171c1763 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/lib/csp_app_services.ts @@ -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 { + AgentService, + PackageService, + AgentPolicyServiceInterface, + PackagePolicyServiceInterface, +} from '../../../fleet/server'; + +export interface CspAppServiceDependencies { + packageService: PackageService; + agentService: AgentService; + packagePolicyService: PackagePolicyServiceInterface; + agentPolicyService: AgentPolicyServiceInterface; +} + +export class CspAppService { + public agentService: AgentService | undefined; + public packageService: PackageService | undefined; + public packagePolicyService: PackagePolicyServiceInterface | undefined; + public agentPolicyService: AgentPolicyServiceInterface | undefined; + + public start(dependencies: CspAppServiceDependencies) { + this.agentService = dependencies.agentService; + this.packageService = dependencies.packageService; + this.packagePolicyService = dependencies.packagePolicyService; + this.agentPolicyService = dependencies.agentPolicyService; + } + + public stop() {} +} diff --git a/x-pack/plugins/cloud_security_posture/server/plugin.ts b/x-pack/plugins/cloud_security_posture/server/plugin.ts index 1225d3481d3340..ce6e38e4c63c5e 100755 --- a/x-pack/plugins/cloud_security_posture/server/plugin.ts +++ b/x-pack/plugins/cloud_security_posture/server/plugin.ts @@ -12,6 +12,7 @@ import type { Plugin, Logger, } from '../../../../src/core/server'; +import { CspAppService } from './lib/csp_app_services'; import type { CspServerPluginSetup, CspServerPluginStart, @@ -19,6 +20,13 @@ import type { CspServerPluginStartDeps, } from './types'; import { defineRoutes } from './routes'; +import { cspRuleAssetType } from './saved_objects/cis_1_4_1/csp_rule_type'; +import { initializeCspRules } from './saved_objects/cis_1_4_1/initialize_rules'; + +export interface CspAppContext { + logger: Logger; + service: CspAppService; +} export class CspPlugin implements @@ -33,20 +41,34 @@ export class CspPlugin constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); } + private readonly CspAppService = new CspAppService(); public setup( core: CoreSetup, plugins: CspServerPluginSetupDeps ): CspServerPluginSetup { + const cspAppContext: CspAppContext = { + logger: this.logger, + service: this.CspAppService, + }; + + core.savedObjects.registerType(cspRuleAssetType); + const router = core.http.createRouter(); // Register server side APIs - defineRoutes(router, this.logger); + defineRoutes(router, cspAppContext); return {}; } public start(core: CoreStart, plugins: CspServerPluginStartDeps): CspServerPluginStart { + this.CspAppService.start({ + ...plugins.fleet, + }); + + initializeCspRules(core.savedObjects.createInternalRepository()); + return {}; } public stop() {} diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts new file mode 100644 index 00000000000000..b728948cf2a056 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.test.ts @@ -0,0 +1,182 @@ +/* + * 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 { httpServiceMock, loggingSystemMock, savedObjectsClientMock } from 'src/core/server/mocks'; +import { + defineGetBenchmarksRoute, + benchmarksInputSchema, + DEFAULT_BENCHMARKS_PER_PAGE, + PACKAGE_POLICY_SAVED_OBJECT_TYPE, + getPackagePolicies, + getAgentPolicies, + createBenchmarkEntry, +} from './benchmarks'; +import { SavedObjectsClientContract } from 'src/core/server'; +import { + createMockAgentPolicyService, + createPackagePolicyServiceMock, +} from '../../../../fleet/server/mocks'; +import { createPackagePolicyMock } from '../../../../fleet/common/mocks'; +import { AgentPolicy } from '../../../../fleet/common'; + +import { CspAppService } from '../../lib/csp_app_services'; +import { CspAppContext } from '../../plugin'; + +function createMockAgentPolicy(props: Partial = {}): AgentPolicy { + return { + id: 'some-uuid1', + namespace: 'default', + monitoring_enabled: [], + name: 'Test Policy', + description: '', + is_default: false, + is_preconfigured: false, + status: 'active', + is_managed: false, + revision: 1, + updated_at: '', + updated_by: 'elastic', + package_policies: [], + ...props, + }; +} +describe('benchmarks API', () => { + let logger: ReturnType; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + jest.clearAllMocks(); + }); + + it('validate the API route path', async () => { + const router = httpServiceMock.createRouter(); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineGetBenchmarksRoute(router, cspContext); + + const [config, _] = router.get.mock.calls[0]; + + expect(config.path).toEqual('/api/csp/benchmarks'); + }); + + describe('test input schema', () => { + it('expect to find default values', async () => { + const validatedQuery = benchmarksInputSchema.validate({}); + + expect(validatedQuery).toMatchObject({ + page: 1, + per_page: DEFAULT_BENCHMARKS_PER_PAGE, + }); + }); + + it('should throw when page field is not a positive integer', async () => { + expect(() => { + benchmarksInputSchema.validate({ page: -2 }); + }).toThrow(); + }); + + it('should throw when per_page field is not a positive integer', async () => { + expect(() => { + benchmarksInputSchema.validate({ per_page: -2 }); + }).toThrow(); + }); + }); + + describe('test benchmarks utils', () => { + let mockSoClient: jest.Mocked; + + beforeEach(() => { + mockSoClient = savedObjectsClientMock.create(); + }); + + describe('test getPackagePolicies', () => { + it('should throw when agentPolicyService is undefined', async () => { + const mockAgentPolicyService = undefined; + expect( + getPackagePolicies(mockSoClient, mockAgentPolicyService, 'myPackage', { + page: 1, + per_page: 100, + }) + ).rejects.toThrow(); + }); + + it('should format request by package name', async () => { + const mockAgentPolicyService = createPackagePolicyServiceMock(); + + await getPackagePolicies(mockSoClient, mockAgentPolicyService, 'myPackage', { + page: 1, + per_page: 100, + }); + + expect(mockAgentPolicyService.list.mock.calls[0][1]).toMatchObject( + expect.objectContaining({ + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:myPackage`, + page: 1, + perPage: 100, + }) + ); + }); + }); + + describe('test getAgentPolicies', () => { + it('should return one agent policy id when there is duplication', async () => { + const agentPolicyService = createMockAgentPolicyService(); + const packagePolicies = [createPackagePolicyMock(), createPackagePolicyMock()]; + + await getAgentPolicies(mockSoClient, packagePolicies, agentPolicyService); + + expect(agentPolicyService.getByIds.mock.calls[0][1]).toHaveLength(1); + }); + + it('should return full policy ids list when there is no id duplication', async () => { + const agentPolicyService = createMockAgentPolicyService(); + + const packagePolicy1 = createPackagePolicyMock(); + const packagePolicy2 = createPackagePolicyMock(); + packagePolicy2.policy_id = 'AnotherId'; + const packagePolicies = [packagePolicy1, packagePolicy2]; + + await getAgentPolicies(mockSoClient, packagePolicies, agentPolicyService); + + expect(agentPolicyService.getByIds.mock.calls[0][1]).toHaveLength(2); + }); + }); + + describe('test createBenchmarkEntry', () => { + it('should build benchmark entry agent policy and package policy', async () => { + const packagePolicy = createPackagePolicyMock(); + const agentPolicy = createMockAgentPolicy(); + // @ts-expect-error + agentPolicy.agents = 3; + + const enrichAgentPolicy = await createBenchmarkEntry(agentPolicy, packagePolicy); + + expect(enrichAgentPolicy).toMatchObject({ + package_policy: { + id: 'c6d16e42-c32d-4dce-8a88-113cfe276ad1', + name: 'endpoint-1', + policy_id: '93c46720-c217-11ea-9906-b5b8a21b268e', + namespace: 'default', + updated_at: '2020-06-25T16:03:38.159292', + updated_by: 'kibana', + created_at: '2020-06-25T16:03:38.159292', + created_by: 'kibana', + package: { + name: 'endpoint', + title: 'Elastic Endpoint', + version: '0.9.0', + }, + }, + agent_policy: { id: 'some-uuid1', name: 'Test Policy', agents: 3 }, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts new file mode 100644 index 00000000000000..80c526c248c0ff --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/benchmarks/benchmarks.ts @@ -0,0 +1,196 @@ +/* + * 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 { uniq, map } from 'lodash'; +import type { IRouter, SavedObjectsClientContract } from 'src/core/server'; +import { schema as rt, TypeOf } from '@kbn/config-schema'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { string } from 'io-ts'; +import { + PackagePolicyServiceInterface, + AgentPolicyServiceInterface, + AgentService, +} from '../../../../fleet/server'; +import { GetAgentPoliciesResponseItem, PackagePolicy, AgentPolicy } from '../../../../fleet/common'; +import { BENCHMARKS_ROUTE_PATH, CIS_KUBERNETES_PACKAGE_NAME } from '../../../common/constants'; +import { CspAppContext } from '../../plugin'; + +// TODO: use the same method from common/ once PR 106 is merged +export const isNonNullable = (v: T): v is NonNullable => + v !== null && v !== undefined; + +type BenchmarksQuerySchema = TypeOf; + +export interface Benchmark { + package_policy: Pick< + PackagePolicy, + | 'id' + | 'name' + | 'policy_id' + | 'namespace' + | 'package' + | 'updated_at' + | 'updated_by' + | 'created_at' + | 'created_by' + >; + agent_policy: Pick; +} + +export const DEFAULT_BENCHMARKS_PER_PAGE = 20; +export const PACKAGE_POLICY_SAVED_OBJECT_TYPE = 'ingest-package-policies'; + +const getPackageNameQuery = (packageName: string): string => { + return `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${packageName}`; +}; + +export const getPackagePolicies = async ( + soClient: SavedObjectsClientContract, + packagePolicyService: PackagePolicyServiceInterface | undefined, + packageName: string, + queryParams: BenchmarksQuerySchema +): Promise => { + if (!packagePolicyService) { + throw new Error('packagePolicyService is undefined'); + } + + const packageNameQuery = getPackageNameQuery(packageName); + + const { items: packagePolicies } = (await packagePolicyService?.list(soClient, { + kuery: packageNameQuery, + page: queryParams.page, + perPage: queryParams.per_page, + })) ?? { items: [] as PackagePolicy[] }; + + return packagePolicies; +}; + +export const getAgentPolicies = async ( + soClient: SavedObjectsClientContract, + packagePolicies: PackagePolicy[], + agentPolicyService: AgentPolicyServiceInterface +): Promise => { + const agentPolicyIds = uniq(map(packagePolicies, 'policy_id')); + const agentPolicies = await agentPolicyService.getByIds(soClient, agentPolicyIds); + + return agentPolicies; +}; + +const addRunningAgentToAgentPolicy = async ( + agentService: AgentService, + agentPolicies: AgentPolicy[] +): Promise => { + if (!agentPolicies?.length) return []; + return Promise.all( + agentPolicies.map((agentPolicy) => + agentService.asInternalUser + .getAgentStatusForAgentPolicy(agentPolicy.id) + .then((agentStatus) => ({ + ...agentPolicy, + agents: agentStatus.total, + })) + ) + ); +}; + +export const createBenchmarkEntry = ( + agentPolicy: GetAgentPoliciesResponseItem, + packagePolicy: PackagePolicy +): Benchmark => ({ + package_policy: { + id: packagePolicy.id, + name: packagePolicy.name, + policy_id: packagePolicy.policy_id, + namespace: packagePolicy.namespace, + updated_at: packagePolicy.updated_at, + updated_by: packagePolicy.updated_by, + created_at: packagePolicy.created_at, + created_by: packagePolicy.created_by, + package: packagePolicy.package + ? { + name: packagePolicy.package.name, + title: packagePolicy.package.title, + version: packagePolicy.package.version, + } + : undefined, + }, + agent_policy: { + id: agentPolicy.id, + name: agentPolicy.name, + agents: agentPolicy.agents, + }, +}); + +const createBenchmarks = ( + agentPolicies: GetAgentPoliciesResponseItem[], + packagePolicies: PackagePolicy[] +): Benchmark[] => + agentPolicies + .flatMap((agentPolicy) => + agentPolicy.package_policies.map((agentPackagePolicy) => { + const id = string.is(agentPackagePolicy) ? agentPackagePolicy : agentPackagePolicy.id; + const packagePolicy = packagePolicies.find((pkgPolicy) => pkgPolicy.id === id); + if (!packagePolicy) return; + return createBenchmarkEntry(agentPolicy, packagePolicy); + }) + ) + .filter(isNonNullable); + +export const defineGetBenchmarksRoute = (router: IRouter, cspContext: CspAppContext): void => + router.get( + { + path: BENCHMARKS_ROUTE_PATH, + validate: { query: benchmarksInputSchema }, + }, + async (context, request, response) => { + try { + const soClient = context.core.savedObjects.client; + const { query } = request; + + const agentService = cspContext.service.agentService; + const agentPolicyService = cspContext.service.agentPolicyService; + const packagePolicyService = cspContext.service.packagePolicyService; + + // TODO: This validate can be remove after #2819 will be merged + if (!agentPolicyService || !agentService) { + throw new Error(`Failed to get Fleet services`); + } + + const packagePolicies = await getPackagePolicies( + soClient, + packagePolicyService, + CIS_KUBERNETES_PACKAGE_NAME, + query + ); + + const agentPolicies = await getAgentPolicies(soClient, packagePolicies, agentPolicyService); + const enrichAgentPolicies = await addRunningAgentToAgentPolicy(agentService, agentPolicies); + const benchmarks = createBenchmarks(enrichAgentPolicies, packagePolicies); + + return response.ok({ + body: benchmarks, + }); + } catch (err) { + const error = transformError(err); + cspContext.logger.error(`Failed to fetch benchmarks ${err}`); + return response.customError({ + body: { message: error.message }, + statusCode: error.statusCode, + }); + } + } + ); + +export const benchmarksInputSchema = rt.object({ + /** + * The page of objects to return + */ + page: rt.number({ defaultValue: 1, min: 1 }), + /** + * The number of objects to include in each page + */ + per_page: rt.number({ defaultValue: DEFAULT_BENCHMARKS_PER_PAGE, min: 0 }), +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts new file mode 100644 index 00000000000000..f554eb91a4a49a --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/compliance_dashboard.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient, IRouter } from 'src/core/server'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import type { + AggregationsMultiBucketAggregateBase as Aggregation, + AggregationsTopHitsAggregate, + QueryDslQueryContainer, + SearchRequest, +} from '@elastic/elasticsearch/lib/api/types'; +import type { CloudPostureStats } from '../../../common/types'; +import { CSP_KUBEBEAT_INDEX_PATTERN, STATS_ROUTE_PATH } from '../../../common/constants'; +import { CspAppContext } from '../../plugin'; +import { getResourcesTypes } from './get_resources_types'; +import { getClusters } from './get_clusters'; +import { getStats } from './get_stats'; + +export interface ClusterBucket { + ordered_top_hits: AggregationsTopHitsAggregate; +} + +interface ClustersQueryResult { + aggs_by_cluster_id: Aggregation; +} + +export interface KeyDocCount { + key: TKey; + doc_count: number; +} + +export const getLatestFindingQuery = (): SearchRequest => ({ + index: CSP_KUBEBEAT_INDEX_PATTERN, + size: 0, + query: { + match_all: {}, + }, + aggs: { + aggs_by_cluster_id: { + terms: { field: 'cluster_id.keyword' }, + aggs: { + ordered_top_hits: { + top_hits: { + size: 1, + sort: { + '@timestamp': { + order: 'desc', + }, + }, + }, + }, + }, + }, + }, +}); + +const getLatestCyclesIds = async (esClient: ElasticsearchClient): Promise => { + const queryResult = await esClient.search(getLatestFindingQuery(), { + meta: true, + }); + + const clusters = queryResult.body.aggregations?.aggs_by_cluster_id.buckets; + if (!Array.isArray(clusters)) throw new Error('missing aggs by cluster id'); + + return clusters.map((c) => { + const topHit = c.ordered_top_hits.hits.hits[0]; + if (!topHit) throw new Error('missing cluster latest hit'); + return topHit._source.cycle_id; + }); +}; + +// TODO: Utilize ES "Point in Time" feature https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html +export const defineGetComplianceDashboardRoute = ( + router: IRouter, + cspContext: CspAppContext +): void => + router.get( + { + path: STATS_ROUTE_PATH, + validate: false, + }, + async (context, _, response) => { + try { + const esClient = context.core.elasticsearch.client.asCurrentUser; + const latestCyclesIds = await getLatestCyclesIds(esClient); + const query: QueryDslQueryContainer = { + bool: { + should: latestCyclesIds.map((id) => ({ + match: { 'cycle_id.keyword': { query: id } }, + })), + }, + }; + + const [stats, resourcesTypes, clusters] = await Promise.all([ + getStats(esClient, query), + getResourcesTypes(esClient, query), + getClusters(esClient, query), + ]); + + const body: CloudPostureStats = { + stats, + resourcesTypes, + clusters, + }; + + return response.ok({ + body, + }); + } catch (err) { + const error = transformError(err); + + return response.customError({ + body: { message: error.message }, + statusCode: error.statusCode, + }); + } + } + ); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.test.ts new file mode 100644 index 00000000000000..8ee05a6e4755f7 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.test.ts @@ -0,0 +1,82 @@ +/* + * 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 { ClusterBucket, getClustersFromAggs } from './get_clusters'; + +const mockClusterBuckets: ClusterBucket[] = [ + { + key: 'cluster_id', + doc_count: 10, + benchmarks: { + buckets: [{ key: 'CIS Kubernetes', doc_count: 10 }], + }, + failed_findings: { + doc_count: 6, + }, + passed_findings: { + doc_count: 6, + }, + aggs_by_resource_type: { + buckets: [ + { + key: 'foo_type', + doc_count: 6, + failed_findings: { + doc_count: 3, + }, + passed_findings: { + doc_count: 3, + }, + }, + { + key: 'boo_type', + doc_count: 6, + failed_findings: { + doc_count: 3, + }, + passed_findings: { + doc_count: 3, + }, + }, + ], + }, + }, +]; + +describe('getClustersFromAggs', () => { + it('should return value matching CloudPostureStats["clusters"]', async () => { + const clusters = getClustersFromAggs(mockClusterBuckets); + expect(clusters).toEqual([ + { + meta: { + clusterId: 'cluster_id', + benchmarkName: 'CIS Kubernetes', + }, + stats: { + totalFindings: 12, + totalFailed: 6, + totalPassed: 6, + postureScore: 50.0, + }, + resourcesTypes: [ + { + name: 'foo_type', + totalFindings: 6, + totalFailed: 3, + totalPassed: 3, + }, + { + name: 'boo_type', + totalFindings: 6, + totalFailed: 3, + totalPassed: 3, + }, + ], + }, + ]); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.ts new file mode 100644 index 00000000000000..5be94f7246e53a --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_clusters.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient } from 'kibana/server'; +import type { + AggregationsMultiBucketAggregateBase as Aggregation, + QueryDslQueryContainer, + SearchRequest, +} from '@elastic/elasticsearch/lib/api/types'; +import { CloudPostureStats } from '../../../common/types'; +import { getResourceTypeFromAggs, resourceTypeAggQuery } from './get_resources_types'; +import type { ResourceTypeQueryResult } from './get_resources_types'; +import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; +import { findingsEvaluationAggsQuery, getStatsFromFindingsEvaluationsAggs } from './get_stats'; +import { KeyDocCount } from './compliance_dashboard'; + +export interface ClusterBucket extends ResourceTypeQueryResult, KeyDocCount { + failed_findings: { + doc_count: number; + }; + passed_findings: { + doc_count: number; + }; + benchmarks: Aggregation; +} + +interface ClustersQueryResult { + aggs_by_cluster_id: Aggregation; +} + +export const getClustersQuery = (query: QueryDslQueryContainer): SearchRequest => ({ + index: CSP_KUBEBEAT_INDEX_PATTERN, + size: 0, + query, + aggs: { + aggs_by_cluster_id: { + terms: { + field: 'cluster_id.keyword', + }, + aggs: { + benchmarks: { + terms: { + field: 'rule.benchmark.name.keyword', + }, + }, + ...resourceTypeAggQuery, + ...findingsEvaluationAggsQuery, + }, + }, + }, +}); + +export const getClustersFromAggs = (clusters: ClusterBucket[]): CloudPostureStats['clusters'] => + clusters.map((cluster) => { + // get cluster's meta data + const benchmarks = cluster.benchmarks.buckets; + if (!Array.isArray(benchmarks)) throw new Error('missing aggs by benchmarks per cluster'); + + const meta = { + clusterId: cluster.key, + benchmarkName: benchmarks[0].key, + }; + + // get cluster's stats + if (!cluster.failed_findings || !cluster.passed_findings) + throw new Error('missing findings evaluations per cluster'); + const stats = getStatsFromFindingsEvaluationsAggs(cluster); + + // get cluster's resource types aggs + const resourcesTypesAggs = cluster.aggs_by_resource_type.buckets; + if (!Array.isArray(resourcesTypesAggs)) + throw new Error('missing aggs by resource type per cluster'); + const resourcesTypes = getResourceTypeFromAggs(resourcesTypesAggs); + + return { + meta, + stats, + resourcesTypes, + }; + }); + +export const getClusters = async ( + esClient: ElasticsearchClient, + query: QueryDslQueryContainer +): Promise => { + const queryResult = await esClient.search(getClustersQuery(query), { + meta: true, + }); + + const clusters = queryResult.body.aggregations?.aggs_by_cluster_id.buckets; + if (!Array.isArray(clusters)) throw new Error('missing aggs by cluster id'); + + return getClustersFromAggs(clusters); +}; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.test.ts new file mode 100644 index 00000000000000..b01644fc3f45b0 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.test.ts @@ -0,0 +1,51 @@ +/* + * 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 { getResourceTypeFromAggs, ResourceTypeBucket } from './get_resources_types'; + +const resourceTypeBuckets: ResourceTypeBucket[] = [ + { + key: 'foo_type', + doc_count: 41, + failed_findings: { + doc_count: 30, + }, + passed_findings: { + doc_count: 11, + }, + }, + { + key: 'boo_type', + doc_count: 11, + failed_findings: { + doc_count: 5, + }, + passed_findings: { + doc_count: 6, + }, + }, +]; + +describe('getResourceTypeFromAggs', () => { + it('should return value matching CloudPostureStats["resourcesTypes"]', async () => { + const resourceTypes = getResourceTypeFromAggs(resourceTypeBuckets); + expect(resourceTypes).toEqual([ + { + name: 'foo_type', + totalFindings: 41, + totalFailed: 30, + totalPassed: 11, + }, + { + name: 'boo_type', + totalFindings: 11, + totalFailed: 5, + totalPassed: 6, + }, + ]); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.ts new file mode 100644 index 00000000000000..459dce56042da4 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_resources_types.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient } from 'kibana/server'; +import { + AggregationsMultiBucketAggregateBase as Aggregation, + QueryDslQueryContainer, + SearchRequest, +} from '@elastic/elasticsearch/lib/api/types'; +import { CloudPostureStats } from '../../../common/types'; +import { KeyDocCount } from './compliance_dashboard'; +import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; + +export interface ResourceTypeQueryResult { + aggs_by_resource_type: Aggregation; +} + +export interface ResourceTypeBucket extends KeyDocCount { + failed_findings: { + doc_count: number; + }; + passed_findings: { + doc_count: number; + }; +} + +export const resourceTypeAggQuery = { + aggs_by_resource_type: { + terms: { + field: 'resource.type.keyword', + }, + aggs: { + failed_findings: { + filter: { term: { 'result.evaluation.keyword': 'failed' } }, + }, + passed_findings: { + filter: { term: { 'result.evaluation.keyword': 'passed' } }, + }, + }, + }, +}; + +export const getRisksEsQuery = (query: QueryDslQueryContainer): SearchRequest => ({ + index: CSP_KUBEBEAT_INDEX_PATTERN, + size: 0, + query, + aggs: resourceTypeAggQuery, +}); + +export const getResourceTypeFromAggs = ( + queryResult: ResourceTypeBucket[] +): CloudPostureStats['resourcesTypes'] => + queryResult.map((bucket) => ({ + name: bucket.key, + totalFindings: bucket.doc_count, + totalFailed: bucket.failed_findings.doc_count || 0, + totalPassed: bucket.passed_findings.doc_count || 0, + })); + +export const getResourcesTypes = async ( + esClient: ElasticsearchClient, + query: QueryDslQueryContainer +): Promise => { + const resourceTypesQueryResult = await esClient.search( + getRisksEsQuery(query), + { meta: true } + ); + + const resourceTypes = resourceTypesQueryResult.body.aggregations?.aggs_by_resource_type.buckets; + if (!Array.isArray(resourceTypes)) throw new Error('missing resources types buckets'); + + return getResourceTypeFromAggs(resourceTypes); +}; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.test.ts new file mode 100644 index 00000000000000..558fec85860eac --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.test.ts @@ -0,0 +1,86 @@ +/* + * 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 { + calculatePostureScore, + FindingsEvaluationsQueryResult, + getStatsFromFindingsEvaluationsAggs, + roundScore, +} from './get_stats'; + +const standardQueryResult: FindingsEvaluationsQueryResult = { + failed_findings: { + doc_count: 30, + }, + passed_findings: { + doc_count: 11, + }, +}; + +const oneIsZeroQueryResult: FindingsEvaluationsQueryResult = { + failed_findings: { + doc_count: 0, + }, + passed_findings: { + doc_count: 11, + }, +}; + +const bothAreZeroQueryResult: FindingsEvaluationsQueryResult = { + failed_findings: { + doc_count: 0, + }, + passed_findings: { + doc_count: 0, + }, +}; + +describe('roundScore', () => { + it('should return decimal values with one fraction digit', async () => { + const rounded = roundScore(0.85245); + expect(rounded).toEqual(85.2); + }); +}); + +describe('calculatePostureScore', () => { + it('should return calculated posture score', async () => { + const score = calculatePostureScore(4, 7); + expect(score).toEqual(36.4); + }); +}); + +describe('getStatsFromFindingsEvaluationsAggs', () => { + it('should throw error in case no findings were found', async () => { + const score = calculatePostureScore(4, 7); + expect(score).toEqual(36.4); + }); + + it('should return value matching CloudPostureStats["stats"]', async () => { + const stats = getStatsFromFindingsEvaluationsAggs(standardQueryResult); + expect(stats).toEqual({ + totalFailed: 30, + totalPassed: 11, + totalFindings: 41, + postureScore: 26.8, + }); + }); + + it('checks for stability in case one of the values is zero', async () => { + const stats = getStatsFromFindingsEvaluationsAggs(oneIsZeroQueryResult); + expect(stats).toEqual({ + totalFailed: 0, + totalPassed: 11, + totalFindings: 11, + postureScore: 100.0, + }); + }); + + it('should throw error if both evaluations are zero', async () => { + // const stats = getStatsFromFindingsEvaluationsAggs(bothAreZeroQueryResult); + expect(() => getStatsFromFindingsEvaluationsAggs(bothAreZeroQueryResult)).toThrow(); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.ts b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.ts new file mode 100644 index 00000000000000..8d5417de24c522 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/compliance_dashboard/get_stats.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 { ElasticsearchClient } from 'kibana/server'; +import { QueryDslQueryContainer, SearchRequest } from '@elastic/elasticsearch/lib/api/types'; +import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; +import { CloudPostureStats, Score } from '../../../common/types'; + +/** + * @param value value is [0, 1] range + */ +export const roundScore = (value: number): Score => Number((value * 100).toFixed(1)); + +export const calculatePostureScore = (passed: number, failed: number): Score => + roundScore(passed / (passed + failed)); + +export interface FindingsEvaluationsQueryResult { + failed_findings: { + doc_count: number; + }; + passed_findings: { + doc_count: number; + }; +} + +export const findingsEvaluationAggsQuery = { + failed_findings: { + filter: { term: { 'result.evaluation.keyword': 'failed' } }, + }, + passed_findings: { + filter: { term: { 'result.evaluation.keyword': 'passed' } }, + }, +}; + +export const getEvaluationsQuery = (query: QueryDslQueryContainer): SearchRequest => ({ + index: CSP_KUBEBEAT_INDEX_PATTERN, + query, + aggs: findingsEvaluationAggsQuery, +}); + +export const getStatsFromFindingsEvaluationsAggs = ( + findingsEvaluationsAggs: FindingsEvaluationsQueryResult +): CloudPostureStats['stats'] => { + const failedFindings = findingsEvaluationsAggs.failed_findings.doc_count || 0; + const passedFindings = findingsEvaluationsAggs.passed_findings.doc_count || 0; + const totalFindings = failedFindings + passedFindings; + if (!totalFindings) throw new Error("couldn't calculate posture score"); + const postureScore = calculatePostureScore(passedFindings, failedFindings); + + return { + totalFailed: failedFindings, + totalPassed: passedFindings, + totalFindings, + postureScore, + }; +}; + +export const getStats = async ( + esClient: ElasticsearchClient, + query: QueryDslQueryContainer +): Promise => { + const evaluationsQueryResult = await esClient.search( + getEvaluationsQuery(query), + { meta: true } + ); + const findingsEvaluations = evaluationsQueryResult.body.aggregations; + if (!findingsEvaluations) throw new Error('missing findings evaluations'); + + return getStatsFromFindingsEvaluationsAggs(findingsEvaluations); +}; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts index ffc5526e2fe426..76fc97e921045c 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.test.ts @@ -12,6 +12,8 @@ import { // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { KibanaRequest } from 'src/core/server/http/router/request'; import { httpServerMock, httpServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { CspAppService } from '../../lib/csp_app_services'; +import { CspAppContext } from '../../plugin'; import { defineFindingsIndexRoute, findingsInputSchema, @@ -41,7 +43,13 @@ describe('findings API', () => { it('validate the API route path', async () => { const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); const [config, _] = router.get.mock.calls[0]; @@ -130,7 +138,13 @@ describe('findings API', () => { it('takes cycle_id and validate the filter was built right', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); @@ -178,7 +192,14 @@ describe('findings API', () => { it('validate that default sort is timestamp desc', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); const mockResponse = httpServerMock.createResponseFactory(); @@ -202,7 +223,14 @@ describe('findings API', () => { it('should build sort request by `sort_field` and `sort_order` - asc', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); const mockResponse = httpServerMock.createResponseFactory(); @@ -227,7 +255,14 @@ describe('findings API', () => { it('should build sort request by `sort_field` and `sort_order` - desc', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); const mockResponse = httpServerMock.createResponseFactory(); @@ -249,10 +284,17 @@ describe('findings API', () => { }); }); - it('takes `page_number` and `per_page` validate that the requested selected page was called', async () => { + it('takes `page` number and `per_page` validate that the requested selected page was called', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); const mockResponse = httpServerMock.createResponseFactory(); @@ -278,7 +320,14 @@ describe('findings API', () => { it('should format request by fields filter', async () => { const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; const router = httpServiceMock.createRouter(); - defineFindingsIndexRoute(router, logger); + const cspAppContextService = new CspAppService(); + + const cspContext: CspAppContext = { + logger, + service: cspAppContextService, + }; + defineFindingsIndexRoute(router, cspContext); + const [_, handler] = router.get.mock.calls[0]; const mockContext = getMockCspContext(mockEsClient); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts index a5c8f67a41cac2..5fea7cdbba9db0 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/findings/findings.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { IRouter, Logger } from 'src/core/server'; +import type { IRouter } from 'src/core/server'; import { SearchRequest, QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { schema as rt, TypeOf } from '@kbn/config-schema'; import type { SortOrder } from '@elastic/elasticsearch/lib/api/types'; @@ -13,6 +13,7 @@ import { transformError } from '@kbn/securitysolution-es-utils'; import { getLatestCycleIds } from './get_latest_cycle_ids'; import { CSP_KUBEBEAT_INDEX_PATTERN, FINDINGS_ROUTE_PATH } from '../../../common/constants'; +import { CspAppContext } from '../../plugin'; type FindingsQuerySchema = TypeOf; @@ -70,7 +71,7 @@ const buildOptionsRequest = (queryParams: FindingsQuerySchema): FindingsOptions ...getSearchFields(queryParams.fields), }); -export const defineFindingsIndexRoute = (router: IRouter, logger: Logger): void => +export const defineFindingsIndexRoute = (router: IRouter, cspContext: CspAppContext): void => router.get( { path: FINDINGS_ROUTE_PATH, @@ -83,7 +84,7 @@ export const defineFindingsIndexRoute = (router: IRouter, logger: Logger): void const latestCycleIds = request.query.latest_cycle === true - ? await getLatestCycleIds(esClient, logger) + ? await getLatestCycleIds(esClient, cspContext.logger) : undefined; const query = buildQueryRequest(latestCycleIds); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/index.ts b/x-pack/plugins/cloud_security_posture/server/routes/index.ts index ab8d1cc3bbedff..c0b333e4058aaf 100755 --- a/x-pack/plugins/cloud_security_posture/server/routes/index.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/index.ts @@ -5,11 +5,14 @@ * 2.0. */ -import type { IRouter, Logger } from '../../../../../src/core/server'; -import { defineGetStatsRoute } from './stats/stats'; +import type { IRouter } from '../../../../../src/core/server'; +import { defineGetComplianceDashboardRoute } from './compliance_dashboard/compliance_dashboard'; +import { defineGetBenchmarksRoute } from './benchmarks/benchmarks'; import { defineFindingsIndexRoute as defineGetFindingsIndexRoute } from './findings/findings'; +import { CspAppContext } from '../plugin'; -export function defineRoutes(router: IRouter, logger: Logger) { - defineGetStatsRoute(router, logger); - defineGetFindingsIndexRoute(router, logger); +export function defineRoutes(router: IRouter, cspContext: CspAppContext) { + defineGetComplianceDashboardRoute(router, cspContext); + defineGetFindingsIndexRoute(router, cspContext); + defineGetBenchmarksRoute(router, cspContext); } diff --git a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.test.ts deleted file mode 100644 index 549e8d45c989fe..00000000000000 --- a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.test.ts +++ /dev/null @@ -1,201 +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 { - elasticsearchClientMock, - ElasticsearchClientMock, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from 'src/core/server/elasticsearch/client/mocks'; - -import { - getBenchmarks, - getAllFindingsStats, - roundScore, - getBenchmarksStats, - getResourceTypesAggs, -} from './stats'; - -export const mockCountResultOnce = async (mockEsClient: ElasticsearchClientMock, count: number) => { - mockEsClient.count.mockReturnValueOnce( - // @ts-expect-error @elast ic/elasticsearch Aggregate only allows unknown values - elasticsearchClientMock.createSuccessTransportRequestPromise({ count }) - ); -}; - -export const mockSearchResultOnce = async ( - mockEsClient: ElasticsearchClientMock, - returnedMock: object -) => { - mockEsClient.search.mockReturnValueOnce( - // @ts-expect-error @elastic/elasticsearch Aggregate only allows unknown values - elasticsearchClientMock.createSuccessTransportRequestPromise(returnedMock) - ); -}; - -const mockEsClient = elasticsearchClientMock.createClusterClient().asScoped().asInternalUser; - -const resourceTypeAggsMockData = { - aggregations: { - resource_types: { - buckets: [ - { - key: 'pods', - doc_count: 3, - bucket_evaluation: { - buckets: [ - { - key: 'passed', - doc_count: 1, - }, - { - key: 'failed', - doc_count: 2, - }, - ], - }, - }, - { - key: 'etcd', - doc_count: 4, - bucket_evaluation: { - buckets: [ - // there is only one bucket here, in cases where aggs can't find an evaluation we count that as 0. - { - key: 'failed', - doc_count: 4, - }, - ], - }, - }, - ], - }, - }, -}; - -afterEach(() => { - jest.clearAllMocks(); -}); - -describe('testing round score', () => { - it('take decimal and expect the roundScore will return it with one digit after the dot ', async () => { - const score = roundScore(0.85245); - expect(score).toEqual(85.2); - }); -}); - -describe('general cloud posture score', () => { - it('expect to valid score from getAllFindingsStats', async () => { - mockCountResultOnce(mockEsClient, 10); // total findings - mockCountResultOnce(mockEsClient, 3); // pass findings - mockCountResultOnce(mockEsClient, 7); // fail findings - - const generalScore = await getAllFindingsStats(mockEsClient, 'randomCycleId'); - expect(generalScore).toEqual({ - name: 'general', - postureScore: 30, - totalFailed: 7, - totalFindings: 10, - totalPassed: 3, - }); - }); - - it("getAllFindingsStats throws when cycleId doesn't exists", async () => { - try { - await getAllFindingsStats(mockEsClient, 'randomCycleId'); - } catch (e) { - expect(e).toBeInstanceOf(Error); - expect(e.message).toEqual('missing stats'); - } - }); -}); - -describe('get benchmarks list', () => { - it('getBenchmarks - takes aggregated data and expect unique benchmarks array', async () => { - const returnedMock = { - aggregations: { - benchmarks: { - buckets: [ - { key: 'CIS Kubernetes', doc_count: 248514 }, - { key: 'GDPR', doc_count: 248514 }, - ], - }, - }, - }; - mockSearchResultOnce(mockEsClient, returnedMock); - const benchmarks = await getBenchmarks(mockEsClient); - expect(benchmarks).toEqual(['CIS Kubernetes', 'GDPR']); - }); -}); - -describe('score per benchmark, testing getBenchmarksStats', () => { - it('get data for only one benchmark and check', async () => { - mockCountResultOnce(mockEsClient, 10); // total findings - mockCountResultOnce(mockEsClient, 3); // pass findings - mockCountResultOnce(mockEsClient, 7); // fail findings - const benchmarkScore = await getBenchmarksStats(mockEsClient, 'randomCycleId', [ - 'CIS Benchmark', - ]); - expect(benchmarkScore).toEqual([ - { - name: 'CIS Benchmark', - postureScore: 30, - totalFailed: 7, - totalFindings: 10, - totalPassed: 3, - }, - ]); - }); - - it('get data two benchmarks and check', async () => { - mockCountResultOnce(mockEsClient, 10); // total findings - mockCountResultOnce(mockEsClient, 3); // pass findings - mockCountResultOnce(mockEsClient, 7); // fail findings - mockCountResultOnce(mockEsClient, 100); - mockCountResultOnce(mockEsClient, 50); - mockCountResultOnce(mockEsClient, 50); - const benchmarkScore = await getBenchmarksStats(mockEsClient, 'randomCycleId', [ - 'CIS Benchmark', - 'GDPR', - ]); - expect(benchmarkScore).toEqual([ - { - name: 'CIS Benchmark', - postureScore: 30, - totalFailed: 7, - totalFindings: 10, - totalPassed: 3, - }, - { - name: 'GDPR', - postureScore: 50, - totalFailed: 50, - totalFindings: 100, - totalPassed: 50, - }, - ]); - }); -}); - -describe('getResourceTypesAggs', () => { - it('get all resources types aggregations', async () => { - await mockSearchResultOnce(mockEsClient, resourceTypeAggsMockData); - const resourceTypeAggs = await getResourceTypesAggs(mockEsClient, 'RandomCycleId'); - expect(resourceTypeAggs).toEqual([ - { - resourceType: 'pods', - totalFindings: 3, - totalPassed: 1, - totalFailed: 2, - }, - { - resourceType: 'etcd', - totalFindings: 4, - totalPassed: 0, - totalFailed: 4, - }, - ]); - }); -}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.ts b/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.ts deleted file mode 100644 index 828d7f932113e6..00000000000000 --- a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats.ts +++ /dev/null @@ -1,226 +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 type { ElasticsearchClient, IRouter, Logger } from 'src/core/server'; -import type { AggregationsMultiBucketAggregateBase } from '@elastic/elasticsearch/lib/api/types'; -import { number, UnknownRecord } from 'io-ts'; -import { transformError } from '@kbn/securitysolution-es-utils'; - -import type { BenchmarkStats, CloudPostureStats, Evaluation, Score } from '../../../common/types'; -import { - getBenchmarksQuery, - getFindingsEsQuery, - getLatestFindingQuery, - getRisksEsQuery, -} from './stats_queries'; -import { RULE_FAILED, RULE_PASSED } from '../../constants'; -import { STATS_ROUTE_PATH } from '../../../common/constants'; - -// TODO: use a schema decoder -function assertBenchmarkStats(v: unknown): asserts v is BenchmarkStats { - if ( - !UnknownRecord.is(v) || - !number.is(v.totalFindings) || - !number.is(v.totalPassed) || - !number.is(v.totalFailed) || - !number.is(v.postureScore) - ) { - throw new Error('missing stats'); - } -} - -interface LastCycle { - cycle_id: string; -} - -interface GroupFilename { - // TODO find the 'key', 'doc_count' interface - key: string; - doc_count: number; -} - -interface ResourceTypeBucket { - resource_types: AggregationsMultiBucketAggregateBase<{ - key: string; - doc_count: number; - bucket_evaluation: AggregationsMultiBucketAggregateBase; - }>; -} - -interface ResourceTypeEvaluationBucket { - key: Evaluation; - doc_count: number; -} - -/** - * @param value value is [0, 1] range - */ -export const roundScore = (value: number): Score => Number((value * 100).toFixed(1)); - -const calculatePostureScore = (total: number, passed: number, failed: number): Score | undefined => - passed + failed === 0 || total === undefined ? undefined : roundScore(passed / (passed + failed)); - -const getLatestCycleId = async (esClient: ElasticsearchClient) => { - const latestFinding = await esClient.search(getLatestFindingQuery(), { meta: true }); - const lastCycle = latestFinding.body.hits.hits[0]; - - if (lastCycle?._source?.cycle_id === undefined) { - throw new Error('cycle id is missing'); - } - return lastCycle?._source?.cycle_id; -}; - -export const getBenchmarks = async (esClient: ElasticsearchClient) => { - const queryResult = await esClient.search< - {}, - { benchmarks: AggregationsMultiBucketAggregateBase> } - >(getBenchmarksQuery(), { meta: true }); - const benchmarksBuckets = queryResult.body.aggregations?.benchmarks; - - if (!benchmarksBuckets || !Array.isArray(benchmarksBuckets?.buckets)) { - throw new Error('missing buckets'); - } - - return benchmarksBuckets.buckets.map((e) => e.key); -}; - -export const getAllFindingsStats = async ( - esClient: ElasticsearchClient, - cycleId: string -): Promise => { - const [findings, passedFindings, failedFindings] = await Promise.all([ - esClient.count(getFindingsEsQuery(cycleId), { meta: true }), - esClient.count(getFindingsEsQuery(cycleId, RULE_PASSED), { meta: true }), - esClient.count(getFindingsEsQuery(cycleId, RULE_FAILED), { meta: true }), - ]); - - const totalFindings = findings.body.count; - const totalPassed = passedFindings.body.count; - const totalFailed = failedFindings.body.count; - const postureScore = calculatePostureScore(totalFindings, totalPassed, totalFailed); - const stats = { - name: 'general', - postureScore, - totalFindings, - totalPassed, - totalFailed, - }; - - assertBenchmarkStats(stats); - - return stats; -}; - -export const getBenchmarksStats = async ( - esClient: ElasticsearchClient, - cycleId: string, - benchmarks: string[] -): Promise => { - const benchmarkPromises = benchmarks.map((benchmark) => { - const benchmarkFindings = esClient.count(getFindingsEsQuery(cycleId, undefined, benchmark), { - meta: true, - }); - const benchmarkPassedFindings = esClient.count( - getFindingsEsQuery(cycleId, RULE_PASSED, benchmark), - { meta: true } - ); - const benchmarkFailedFindings = esClient.count( - getFindingsEsQuery(cycleId, RULE_FAILED, benchmark), - { meta: true } - ); - - return Promise.all([benchmarkFindings, benchmarkPassedFindings, benchmarkFailedFindings]).then( - ([benchmarkFindingsResult, benchmarkPassedFindingsResult, benchmarkFailedFindingsResult]) => { - const totalFindings = benchmarkFindingsResult.body.count; - const totalPassed = benchmarkPassedFindingsResult.body.count; - const totalFailed = benchmarkFailedFindingsResult.body.count; - const postureScore = calculatePostureScore(totalFindings, totalPassed, totalFailed); - const stats = { - name: benchmark, - postureScore, - totalFindings, - totalPassed, - totalFailed, - }; - - assertBenchmarkStats(stats); - return stats; - } - ); - }); - - return Promise.all(benchmarkPromises); -}; - -export const getResourceTypesAggs = async ( - esClient: ElasticsearchClient, - cycleId: string -): Promise => { - const resourceTypesQueryResult = await esClient.search( - getRisksEsQuery(cycleId), - { meta: true } - ); - - const resourceTypesAggs = resourceTypesQueryResult.body.aggregations?.resource_types.buckets; - if (!Array.isArray(resourceTypesAggs)) throw new Error('missing resources types buckets'); - - return resourceTypesAggs.map((bucket) => { - const evalBuckets = bucket.bucket_evaluation.buckets; - if (!Array.isArray(evalBuckets)) throw new Error('missing resources types evaluations buckets'); - - const failedBucket = evalBuckets.find((evalBucket) => evalBucket.key === RULE_FAILED); - const passedBucket = evalBuckets.find((evalBucket) => evalBucket.key === RULE_PASSED); - - return { - resourceType: bucket.key, - totalFindings: bucket.doc_count, - totalFailed: failedBucket?.doc_count || 0, - totalPassed: passedBucket?.doc_count || 0, - }; - }); -}; - -export const defineGetStatsRoute = (router: IRouter, logger: Logger): void => - router.get( - { - path: STATS_ROUTE_PATH, - validate: false, - }, - async (context, _, response) => { - try { - const esClient = context.core.elasticsearch.client.asCurrentUser; - const [benchmarks, latestCycleID] = await Promise.all([ - getBenchmarks(esClient), - getLatestCycleId(esClient), - ]); - - // TODO: Utilize ES "Point in Time" feature https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html - const [allFindingsStats, benchmarksStats, resourceTypesAggs] = await Promise.all([ - getAllFindingsStats(esClient, latestCycleID), - getBenchmarksStats(esClient, latestCycleID, benchmarks), - getResourceTypesAggs(esClient, latestCycleID), - ]); - - const body: CloudPostureStats = { - ...allFindingsStats, - benchmarksStats, - resourceTypesAggs, - }; - - return response.ok({ - body, - }); - } catch (err) { - const error = transformError(err); - - return response.customError({ - body: { message: error.message }, - statusCode: error.statusCode, - }); - } - } - ); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats_queries.ts b/x-pack/plugins/cloud_security_posture/server/routes/stats/stats_queries.ts deleted file mode 100644 index b88182a27fee1a..00000000000000 --- a/x-pack/plugins/cloud_security_posture/server/routes/stats/stats_queries.ts +++ /dev/null @@ -1,111 +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 type { - SearchRequest, - CountRequest, - QueryDslQueryContainer, -} from '@elastic/elasticsearch/lib/api/types'; - -import { CSP_KUBEBEAT_INDEX_PATTERN } from '../../../common/constants'; -import { Evaluation } from '../../../common/types'; - -export const getFindingsEsQuery = ( - cycleId: string, - evaluationResult?: string, - benchmark?: string -): CountRequest => { - const filter: QueryDslQueryContainer[] = [{ term: { 'cycle_id.keyword': cycleId } }]; - - if (benchmark) { - filter.push({ term: { 'rule.benchmark.keyword': benchmark } }); - } - - if (evaluationResult) { - filter.push({ term: { 'result.evaluation.keyword': evaluationResult } }); - } - - return { - index: CSP_KUBEBEAT_INDEX_PATTERN, - query: { - bool: { filter }, - }, - }; -}; - -export const getResourcesEvaluationEsQuery = ( - cycleId: string, - evaluation: Evaluation, - size: number, - resources?: string[] -): SearchRequest => { - const query: QueryDslQueryContainer = { - bool: { - filter: [ - { term: { 'cycle_id.keyword': cycleId } }, - { term: { 'result.evaluation.keyword': evaluation } }, - ], - }, - }; - if (resources) { - query.bool!.must = { terms: { 'resource.filename.keyword': resources } }; - } - return { - index: CSP_KUBEBEAT_INDEX_PATTERN, - size, - query, - aggs: { - group: { - terms: { field: 'resource.filename.keyword' }, - }, - }, - sort: 'resource.filename.keyword', - }; -}; - -export const getBenchmarksQuery = (): SearchRequest => ({ - index: CSP_KUBEBEAT_INDEX_PATTERN, - size: 0, - aggs: { - benchmarks: { - terms: { field: 'rule.benchmark.keyword' }, - }, - }, -}); - -export const getLatestFindingQuery = (): SearchRequest => ({ - index: CSP_KUBEBEAT_INDEX_PATTERN, - size: 1, - /* @ts-expect-error TS2322 - missing SearchSortContainer */ - sort: { '@timestamp': 'desc' }, - query: { - match_all: {}, - }, -}); - -export const getRisksEsQuery = (cycleId: string): SearchRequest => ({ - index: CSP_KUBEBEAT_INDEX_PATTERN, - size: 0, - query: { - bool: { - filter: [{ term: { 'cycle_id.keyword': cycleId } }], - }, - }, - aggs: { - resource_types: { - terms: { - field: 'resource.type.keyword', - }, - aggs: { - bucket_evaluation: { - terms: { - field: 'result.evaluation.keyword', - }, - }, - }, - }, - }, -}); diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts new file mode 100644 index 00000000000000..fcff7449fb3f5b --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/csp_rule_type.ts @@ -0,0 +1,58 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import type { + SavedObjectsType, + SavedObjectsValidationMap, +} from '../../../../../../src/core/server'; +import { + type CspRuleSchema, + cspRuleSchema, + cspRuleAssetSavedObjectType, +} from '../../../common/schemas/csp_rule'; + +const validationMap: SavedObjectsValidationMap = { + '1.0.0': cspRuleSchema, +}; + +export const ruleAssetSavedObjectMappings: SavedObjectsType['mappings'] = { + dynamic: false, + properties: { + name: { + type: 'text', // search + fields: { + // TODO: how is fields mapping shared with UI ? + raw: { + type: 'keyword', // sort + }, + }, + }, + description: { + type: 'text', + }, + }, +}; + +export const cspRuleAssetType: SavedObjectsType = { + name: cspRuleAssetSavedObjectType, + hidden: false, + namespaceType: 'agnostic', + mappings: ruleAssetSavedObjectMappings, + schemas: validationMap, + // migrations: {} + management: { + importableAndExportable: true, + visibleInManagement: true, + getTitle: (savedObject) => + `${i18n.translate('xpack.csp.cspSettings.rules', { + defaultMessage: `CSP Security Rules - `, + })} ${savedObject.attributes.benchmark.name} ${savedObject.attributes.benchmark.version} ${ + savedObject.attributes.name + }`, + }, +}; diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts new file mode 100644 index 00000000000000..1cb08ddc1be1a2 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/initialize_rules.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ISavedObjectsRepository } from 'src/core/server'; +import { CIS_BENCHMARK_1_4_1_RULES } from './rules'; +import { cspRuleAssetSavedObjectType } from '../../../common/schemas/csp_rule'; + +export const initializeCspRules = async (client: ISavedObjectsRepository) => { + const existingRules = await client.find({ type: cspRuleAssetSavedObjectType, perPage: 1 }); + + // TODO: version? + if (existingRules.total !== 0) return; + + try { + await client.bulkCreate(CIS_BENCHMARK_1_4_1_RULES); + } catch (e) { + // TODO: add logger + // TODO: handle error + } +}; diff --git a/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/rules.ts b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/rules.ts new file mode 100644 index 00000000000000..8f3d6df65b6b52 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/saved_objects/cis_1_4_1/rules.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsBulkCreateObject } from 'src/core/server'; +import type { CspRuleSchema } from '../../../common/schemas/csp_rule'; +import { cspRuleAssetSavedObjectType } from '../../../common/schemas/csp_rule'; + +const benchmark = { name: 'CIS', version: '1.4.1' } as const; + +const RULES: CspRuleSchema[] = [ + { + id: '1.1.1', + name: 'Ensure that the API server pod specification file permissions are set to 644 or more restrictive (Automated)', + description: 'Disable anonymous requests to the API server', + rationale: + 'When enabled, requests that are not rejected by other configured authentication methods\nare treated as anonymous requests. These requests are then served by the API server. You\nshould rely on authentication to authorize access and disallow anonymous requests.\nIf you are using RBAC authorization, it is generally considered reasonable to allow\nanonymous access to the API Server for health checks and discovery purposes, and hence\nthis recommendation is not scored. However, you should consider whether anonymous\ndiscovery is an acceptable risk for your purposes.', + impact: 'Anonymous requests will be rejected.', + default_value: 'By default, anonymous access is enabled.', + remediation: + 'Edit the API server pod specification file /etc/kubernetes/manifests/kubeapiserver.yaml on the master node and set the below parameter.\n--anonymous-auth=false', + tags: [], + enabled: true, + muted: false, + benchmark, + }, + { + id: '1.1.2', + name: 'Ensure that the --basic-auth-file argument is not set (Scored)', + description: 'Do not use basic authentication', + rationale: + 'Basic authentication uses plaintext credentials for authentication. Currently, the basic\nauthentication credentials last indefinitely, and the password cannot be changed without\nrestarting API server. The basic authentication is currently supported for convenience.\nHence, basic authentication should not be used', + impact: + 'You will have to configure and use alternate authentication mechanisms such as tokens and\ncertificates. Username and password for basic authentication could no longer be used.', + default_value: 'By default, basic authentication is not set', + remediation: + 'Follow the documentation and configure alternate mechanisms for authentication. Then,\nedit the API server pod specification file /etc/kubernetes/manifests/kubeapiserver.yaml on the master node and remove the --basic-auth-file=\nparameter.', + tags: [], + enabled: true, + muted: false, + benchmark, + }, +]; + +export const CIS_BENCHMARK_1_4_1_RULES: Array> = + RULES.map((rule) => ({ + attributes: rule, + id: rule.id, + type: cspRuleAssetSavedObjectType, + })); diff --git a/x-pack/plugins/cloud_security_posture/server/types.ts b/x-pack/plugins/cloud_security_posture/server/types.ts index 707002461d2a68..4e70027013df80 100644 --- a/x-pack/plugins/cloud_security_posture/server/types.ts +++ b/x-pack/plugins/cloud_security_posture/server/types.ts @@ -10,6 +10,8 @@ import type { PluginStart as DataPluginStart, } from '../../../../src/plugins/data/server'; +import type { FleetStartContract } from '../../fleet/server'; + // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface CspServerPluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -25,6 +27,5 @@ export interface CspServerPluginSetupDeps { export interface CspServerPluginStartDeps { // required data: DataPluginStart; - - // optional + fleet: FleetStartContract; } diff --git a/x-pack/plugins/cloud_security_posture/tsconfig.json b/x-pack/plugins/cloud_security_posture/tsconfig.json index 47625c59eae6cd..d7902b8b05977d 100755 --- a/x-pack/plugins/cloud_security_posture/tsconfig.json +++ b/x-pack/plugins/cloud_security_posture/tsconfig.json @@ -19,6 +19,7 @@ "references": [ { "path": "../../../src/core/tsconfig.json" }, { "path": "../../../src/plugins/data/tsconfig.json" }, - { "path": "../../../src/plugins/navigation/tsconfig.json" } + { "path": "../../../src/plugins/navigation/tsconfig.json" }, + { "path": "../../../x-pack/plugins/fleet/tsconfig.json" }, ] } diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/multi_select_picker/multi_select_picker.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/multi_select_picker/multi_select_picker.tsx index 8b95296b5f8236..24523450a0e7d3 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/multi_select_picker/multi_select_picker.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/multi_select_picker/multi_select_picker.tsx @@ -15,13 +15,16 @@ import { EuiPopoverTitle, EuiSpacer, } from '@elastic/eui'; -import React, { FC, ReactNode, useEffect, useState } from 'react'; +import React, { FC, ReactNode, useEffect, useMemo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import { euiDarkVars as euiThemeDark, euiLightVars as euiThemeLight } from '@kbn/ui-theme'; +import { useDataVisualizerKibana } from '../../../kibana_context'; export interface Option { name?: string | ReactNode; value: string; checked?: 'on' | 'off'; + disabled?: boolean; } const NoFilterItems = () => { @@ -41,6 +44,15 @@ const NoFilterItems = () => { ); }; +export function useCurrentEuiTheme() { + const { services } = useDataVisualizerKibana(); + const uiSettings = services.uiSettings; + return useMemo( + () => (uiSettings.get('theme:darkMode') ? euiThemeDark : euiThemeLight), + [uiSettings] + ); +} + export const MultiSelectPicker: FC<{ options: Option[]; onChange?: (items: string[]) => void; @@ -48,6 +60,8 @@ export const MultiSelectPicker: FC<{ checkedOptions: string[]; dataTestSubj: string; }> = ({ options, onChange, title, checkedOptions, dataTestSubj }) => { + const euiTheme = useCurrentEuiTheme(); + const [items, setItems] = useState(options); const [searchTerm, setSearchTerm] = useState(''); @@ -68,6 +82,7 @@ export const MultiSelectPicker: FC<{ const closePopover = () => { setIsPopoverOpen(false); + setSearchTerm(''); }; const handleOnChange = (index: number) => { @@ -126,7 +141,13 @@ export const MultiSelectPicker: FC<{ checked={checked ? 'on' : undefined} key={index} onClick={() => handleOnChange(index)} - style={{ flexDirection: 'row' }} + style={{ + flexDirection: 'row', + color: + item.disabled === true + ? euiTheme.euiColorDisabledText + : euiTheme.euiTextColor, + }} data-test-subj={`${dataTestSubj}-option-${item.value}${ checked ? '-checked' : '' }`} diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/field_name_filter.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/field_name_filter.tsx index 634bf25dbc8a01..c2d0e5d61402dc 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/field_name_filter.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/search_panel/field_name_filter.tsx @@ -35,6 +35,8 @@ export const DataVisualizerFieldNamesFilter: FC = ({ field.fieldName !== undefined ) { options.push({ value: field.fieldName }); + } else { + options.push({ value: field.fieldName, disabled: true }); } }); } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts index b5309d8fedc1bf..f262579f56c56e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts @@ -8,6 +8,7 @@ import { groups } from './groups.mock'; import { IndexingRule } from '../types'; +import { SourceConfigData } from '../views/content_sources/components/add_source/add_source_logic'; import { staticSourceData } from '../views/content_sources/source_data'; import { mergeServerAndStaticData } from '../views/content_sources/sources_logic'; @@ -339,23 +340,23 @@ export const mergedConfiguredSources = mergeServerAndStaticData( contentSources ); -export const sourceConfigData = { +export const sourceConfigData: SourceConfigData = { serviceType: 'confluence_cloud', name: 'Confluence', configured: true, needsPermissions: true, accountContextOnly: false, - supportedByLicense: true, privateSourcesEnabled: false, categories: ['wiki', 'atlassian', 'intranet'], configuredFields: { - isOauth1: false, clientId: 'CyztADsSECRETCSAUCEh1a', clientSecret: 'GSjJxqSECRETCSAUCEksHk', baseUrl: 'https://mine.atlassian.net', privateKey: '-----BEGIN PRIVATE KEY-----\nkeykeykeykey==\n-----END PRIVATE KEY-----\n', publicKey: '-----BEGIN PUBLIC KEY-----\nkeykeykeykey\n-----END PUBLIC KEY-----\n', consumerKey: 'elastic_enterprise_search_123', + apiKey: 'asdf1234', + url: 'https://www.elastic.co', }, }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts index fdccd536c3c6d6..5b893250235f79 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/index.ts @@ -19,6 +19,7 @@ import oneDrive from './onedrive.svg'; import salesforce from './salesforce.svg'; import serviceNow from './servicenow.svg'; import sharePoint from './sharepoint.svg'; +import sharePointServer from './sharepoint_server.svg'; import slack from './slack.svg'; import zendesk from './zendesk.svg'; @@ -29,6 +30,8 @@ export const images = { confluenceServer: confluence, custom, dropbox, + // TODO: For now external sources are all SharePoint. When this is no longer the case, this needs to be dynamic. + external: sharePoint, github, githubEnterpriseServer: github, githubViaApp: github, @@ -44,6 +47,7 @@ export const images = { salesforceSandbox: salesforce, serviceNow, sharePoint, + sharePointServer, slack, zendesk, } as { [key: string]: string }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/sharepoint_server.svg b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/sharepoint_server.svg new file mode 100644 index 00000000000000..aebfd7a8e49c0f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/source_icons/sharepoint_server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index 45104984657938..e83430504b3897 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -192,6 +192,10 @@ export const SOURCE_NAMES = { 'xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.sharePoint', { defaultMessage: 'SharePoint Online' } ), + SHAREPOINT_SERVER: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.sharePointServer', + { defaultMessage: 'SharePoint Server' } + ), SLACK: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.sourceNames.slack', { defaultMessage: 'Slack', }), @@ -357,6 +361,7 @@ export const GITHUB_VIA_APP_SERVICE_TYPE = 'github_via_app'; export const GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE = 'github_enterprise_server_via_app'; export const CUSTOM_SERVICE_TYPE = 'custom'; +export const EXTERNAL_SERVICE_TYPE = 'external'; export const WORKPLACE_SEARCH_URL_PREFIX = '/app/enterprise_search/workplace_search'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index 4857fa2a158a0b..cbcd1d885b120e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -7,11 +7,6 @@ import { generatePath } from 'react-router-dom'; -import { - GITHUB_VIA_APP_SERVICE_TYPE, - GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE, -} from './constants'; - export const SETUP_GUIDE_PATH = '/setup_guide'; export const NOT_FOUND_PATH = '/404'; @@ -40,25 +35,7 @@ export const PRIVATE_SOURCES_PATH = `${PERSONAL_PATH}${SOURCES_PATH}`; export const SOURCE_ADDED_PATH = `${SOURCES_PATH}/added`; export const ADD_SOURCE_PATH = `${SOURCES_PATH}/add`; -export const ADD_BOX_PATH = `${SOURCES_PATH}/add/box`; -export const ADD_CONFLUENCE_PATH = `${SOURCES_PATH}/add/confluence_cloud`; -export const ADD_CONFLUENCE_SERVER_PATH = `${SOURCES_PATH}/add/confluence_server`; -export const ADD_DROPBOX_PATH = `${SOURCES_PATH}/add/dropbox`; -export const ADD_GITHUB_ENTERPRISE_PATH = `${SOURCES_PATH}/add/github_enterprise_server`; -export const ADD_GITHUB_PATH = `${SOURCES_PATH}/add/github`; -export const ADD_GITHUB_VIA_APP_PATH = `${SOURCES_PATH}/add/${GITHUB_VIA_APP_SERVICE_TYPE}`; -export const ADD_GITHUB_ENTERPRISE_SERVER_VIA_APP_PATH = `${SOURCES_PATH}/add/${GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE}`; -export const ADD_GMAIL_PATH = `${SOURCES_PATH}/add/gmail`; -export const ADD_GOOGLE_DRIVE_PATH = `${SOURCES_PATH}/add/google_drive`; -export const ADD_JIRA_PATH = `${SOURCES_PATH}/add/jira_cloud`; -export const ADD_JIRA_SERVER_PATH = `${SOURCES_PATH}/add/jira_server`; -export const ADD_ONEDRIVE_PATH = `${SOURCES_PATH}/add/one_drive`; -export const ADD_SALESFORCE_PATH = `${SOURCES_PATH}/add/salesforce`; -export const ADD_SALESFORCE_SANDBOX_PATH = `${SOURCES_PATH}/add/salesforce_sandbox`; -export const ADD_SERVICENOW_PATH = `${SOURCES_PATH}/add/servicenow`; -export const ADD_SHAREPOINT_PATH = `${SOURCES_PATH}/add/share_point`; -export const ADD_SLACK_PATH = `${SOURCES_PATH}/add/slack`; -export const ADD_ZENDESK_PATH = `${SOURCES_PATH}/add/zendesk`; +export const ADD_EXTERNAL_PATH = `${SOURCES_PATH}/add/external`; export const ADD_CUSTOM_PATH = `${SOURCES_PATH}/add/custom`; export const PERSONAL_SETTINGS_PATH = `${PERSONAL_PATH}/settings`; @@ -83,24 +60,6 @@ export const ORG_SETTINGS_PATH = '/settings'; export const ORG_SETTINGS_CUSTOMIZE_PATH = `${ORG_SETTINGS_PATH}/customize`; export const ORG_SETTINGS_CONNECTORS_PATH = `${ORG_SETTINGS_PATH}/connectors`; export const ORG_SETTINGS_OAUTH_APPLICATION_PATH = `${ORG_SETTINGS_PATH}/oauth`; -export const EDIT_BOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/box/edit`; -export const EDIT_CONFLUENCE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/confluence_cloud/edit`; -export const EDIT_CONFLUENCE_SERVER_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/confluence_server/edit`; -export const EDIT_DROPBOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/dropbox/edit`; -export const EDIT_GITHUB_ENTERPRISE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/github_enterprise_server/edit`; -export const EDIT_GITHUB_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/github/edit`; -export const EDIT_GMAIL_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/gmail/edit`; -export const EDIT_GOOGLE_DRIVE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/google_drive/edit`; -export const EDIT_JIRA_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/jira_cloud/edit`; -export const EDIT_JIRA_SERVER_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/jira_server/edit`; -export const EDIT_ONEDRIVE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/one_drive/edit`; -export const EDIT_SALESFORCE_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/salesforce/edit`; -export const EDIT_SALESFORCE_SANDBOX_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/salesforce_sandbox/edit`; -export const EDIT_SERVICENOW_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/servicenow/edit`; -export const EDIT_SHAREPOINT_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/share_point/edit`; -export const EDIT_SLACK_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/slack/edit`; -export const EDIT_ZENDESK_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/zendesk/edit`; -export const EDIT_CUSTOM_PATH = `${ORG_SETTINGS_CONNECTORS_PATH}/custom/edit`; export const getContentSourcePath = ( path: string, @@ -118,3 +77,6 @@ export const getReindexJobRoute = ( isOrganization: boolean ) => getSourcesPath(generatePath(REINDEX_JOB_PATH, { sourceId, activeReindexJobId }), isOrganization); +export const getAddPath = (serviceType: string): string => `${SOURCES_PATH}/add/${serviceType}`; +export const getEditPath = (serviceType: string): string => + `${ORG_SETTINGS_CONNECTORS_PATH}/${serviceType}/edit`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts index b01700b8bce340..b3bfebcd6b2950 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -66,23 +66,27 @@ export interface Configuration { needsConfiguration?: boolean; hasOauthRedirect: boolean; baseUrlTitle?: string; - helpText: string; + helpText?: string; documentationUrl: string; applicationPortalUrl?: string; applicationLinkTitle?: string; + githubRepository?: string; } export interface SourceDataItem { name: string; + iconName: string; + categories?: string[]; serviceType: string; configuration: Configuration; configured?: boolean; connected?: boolean; features?: Features; objTypes?: string[]; - addPath: string; - editPath?: string; // undefined for GitHub apps, as they are configured on a source level, and don't use a connector where you can edit the configuration accountContextOnly: boolean; + internalConnectorAvailable?: boolean; + externalConnectorAvailable?: boolean; + customConnectorAvailable?: boolean; } export interface ContentSource { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts new file mode 100644 index 00000000000000..fbfda1ddf8d5ef --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SourceDataItem } from '../types'; + +export const hasMultipleConnectorOptions = ({ + internalConnectorAvailable, + externalConnectorAvailable, + customConnectorAvailable, +}: SourceDataItem) => + [externalConnectorAvailable, internalConnectorAvailable, customConnectorAvailable].filter( + (available) => !!available + ).length > 1; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts index 92f27500d7262a..86d3e4f844bbd3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts @@ -11,3 +11,5 @@ export { mimeType } from './mime_types'; export { readUploadedFileAsBase64 } from './read_uploaded_file_as_base64'; export { readUploadedFileAsText } from './read_uploaded_file_as_text'; export { handlePrivateKeyUpload } from './handle_private_key_upload'; +export { hasMultipleConnectorOptions } from './has_multiple_connector_options'; +export { isNotNullish } from './is_not_nullish'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/is_not_nullish.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/is_not_nullish.ts new file mode 100644 index 00000000000000..d492dad5d52c2c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/is_not_nullish.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function isNotNullish(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.test.tsx new file mode 100644 index 00000000000000..b13cc6583cf2ff --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.test.tsx @@ -0,0 +1,69 @@ +/* + * 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 '../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockValues } from '../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { staticSourceData } from '../../source_data'; + +import { AddCustomSource } from './add_custom_source'; +import { AddCustomSourceSteps } from './add_custom_source_logic'; +import { ConfigureCustom } from './configure_custom'; +import { SaveCustom } from './save_custom'; + +describe('AddCustomSource', () => { + const props = { + sourceData: staticSourceData[0], + initialValues: undefined, + }; + + const values = { + sourceConfigData, + isOrganization: true, + }; + + beforeEach(() => { + setMockValues({ ...values }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(1); + }); + + it('should show correct layout for personal dashboard', () => { + setMockValues({ isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(0); + expect(wrapper.find(PersonalDashboardLayout)).toHaveLength(1); + }); + + it('should show Configure Custom for custom configuration step', () => { + setMockValues({ currentStep: AddCustomSourceSteps.ConfigureCustomStep }); + const wrapper = shallow(); + + expect(wrapper.find(ConfigureCustom)).toHaveLength(1); + }); + + it('should show Save Custom for save custom step', () => { + setMockValues({ currentStep: AddCustomSourceSteps.SaveCustomStep }); + const wrapper = shallow(); + + expect(wrapper.find(SaveCustom)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.tsx new file mode 100644 index 00000000000000..6f7dc2bcdb342e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useValues } from 'kea'; + +import { AppLogic } from '../../../../app_logic'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { NAV } from '../../../../constants'; + +import { SourceDataItem } from '../../../../types'; + +import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic'; +import { ConfigureCustom } from './configure_custom'; +import { SaveCustom } from './save_custom'; + +import './add_source.scss'; + +interface Props { + sourceData: SourceDataItem; + initialValue?: string; +} +export const AddCustomSource: React.FC = ({ sourceData, initialValue = '' }) => { + const addCustomSourceLogic = AddCustomSourceLogic({ sourceData, initialValue }); + const { currentStep } = useValues(addCustomSourceLogic); + const { isOrganization } = useValues(AppLogic); + + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + + return ( + + {currentStep === AddCustomSourceSteps.ConfigureCustomStep && } + {currentStep === AddCustomSourceSteps.SaveCustomStep && } + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.test.ts new file mode 100644 index 00000000000000..93609679858765 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.test.ts @@ -0,0 +1,182 @@ +/* + * 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 { + LogicMounter, + mockFlashMessageHelpers, + mockHttpValues, +} from '../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; + +import { i18n } from '@kbn/i18n'; +import { nextTick } from '@kbn/test-jest-helpers'; + +import { docLinks } from '../../../../../shared/doc_links'; +import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; + +jest.mock('../../../../app_logic', () => ({ + AppLogic: { values: { isOrganization: true } }, +})); +import { AppLogic } from '../../../../app_logic'; + +import { SOURCE_NAMES } from '../../../../constants'; +import { CustomSource, SourceDataItem } from '../../../../types'; + +import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic'; + +const CUSTOM_SOURCE_DATA_ITEM: SourceDataItem = { + name: SOURCE_NAMES.CUSTOM, + iconName: SOURCE_NAMES.CUSTOM, + serviceType: 'custom', + configuration: { + isPublicKey: false, + hasOauthRedirect: false, + needsBaseUrl: false, + helpText: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.helpText.custom', { + defaultMessage: + 'To create a Custom API Source, provide a human-readable and descriptive name. The name will appear as-is in the various search experiences and management interfaces.', + }), + documentationUrl: docLinks.workplaceSearchCustomSources, + applicationPortalUrl: '', + }, + accountContextOnly: false, +}; + +const DEFAULT_VALUES = { + currentStep: AddCustomSourceSteps.ConfigureCustomStep, + buttonLoading: false, + customSourceNameValue: '', + newCustomSource: {} as CustomSource, + sourceData: CUSTOM_SOURCE_DATA_ITEM, +}; + +const MOCK_PROPS = { initialValue: '', sourceData: CUSTOM_SOURCE_DATA_ITEM }; + +const MOCK_NAME = 'name'; + +describe('AddCustomSourceLogic', () => { + const { mount } = new LogicMounter(AddCustomSourceLogic); + const { http } = mockHttpValues; + const { clearFlashMessages } = mockFlashMessageHelpers; + + beforeEach(() => { + jest.clearAllMocks(); + mount({}, MOCK_PROPS); + }); + + it('has expected default values', () => { + expect(AddCustomSourceLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('setButtonNotLoading', () => { + it('turns off the button loading flag', () => { + AddCustomSourceLogic.actions.setButtonNotLoading(); + + expect(AddCustomSourceLogic.values).toEqual({ + ...DEFAULT_VALUES, + buttonLoading: false, + }); + }); + }); + + describe('setCustomSourceNameValue', () => { + it('saves the name', () => { + AddCustomSourceLogic.actions.setCustomSourceNameValue('name'); + + expect(AddCustomSourceLogic.values).toEqual({ + ...DEFAULT_VALUES, + customSourceNameValue: 'name', + }); + }); + }); + + describe('setNewCustomSource', () => { + it('saves the custom source', () => { + const newCustomSource = { + accessToken: 'foo', + key: 'bar', + name: 'source', + id: '123key', + }; + + AddCustomSourceLogic.actions.setNewCustomSource(newCustomSource); + + expect(AddCustomSourceLogic.values).toEqual({ + ...DEFAULT_VALUES, + newCustomSource, + currentStep: AddCustomSourceSteps.SaveCustomStep, + }); + }); + }); + }); + + describe('listeners', () => { + beforeEach(() => { + mount( + { + customSourceNameValue: MOCK_NAME, + }, + MOCK_PROPS + ); + }); + + describe('organization context', () => { + describe('createContentSource', () => { + it('calls API and sets values', async () => { + const setButtonNotLoadingSpy = jest.spyOn( + AddCustomSourceLogic.actions, + 'setButtonNotLoading' + ); + const setNewCustomSourceSpy = jest.spyOn( + AddCustomSourceLogic.actions, + 'setNewCustomSource' + ); + http.post.mockReturnValue(Promise.resolve({ sourceConfigData })); + + AddCustomSourceLogic.actions.createContentSource(); + + expect(clearFlashMessages).toHaveBeenCalled(); + expect(AddCustomSourceLogic.values.buttonLoading).toEqual(true); + expect(http.post).toHaveBeenCalledWith('/internal/workplace_search/org/create_source', { + body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME }), + }); + await nextTick(); + expect(setNewCustomSourceSpy).toHaveBeenCalledWith({ sourceConfigData }); + expect(setButtonNotLoadingSpy).toHaveBeenCalled(); + }); + + itShowsServerErrorAsFlashMessage(http.post, () => { + AddCustomSourceLogic.actions.createContentSource(); + }); + }); + }); + + describe('account context routes', () => { + beforeEach(() => { + AppLogic.values.isOrganization = false; + }); + + describe('createContentSource', () => { + it('sends relevant fields to the API', () => { + AddCustomSourceLogic.actions.createContentSource(); + + expect(http.post).toHaveBeenCalledWith( + '/internal/workplace_search/account/create_source', + { + body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME }), + } + ); + }); + + itShowsServerErrorAsFlashMessage(http.post, () => { + AddCustomSourceLogic.actions.createContentSource(); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.ts new file mode 100644 index 00000000000000..5bf86f6df41c7a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source_logic.ts @@ -0,0 +1,110 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; + +import { flashAPIErrors, clearFlashMessages } from '../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../shared/http'; +import { AppLogic } from '../../../../app_logic'; +import { CustomSource, SourceDataItem } from '../../../../types'; + +export interface AddCustomSourceProps { + sourceData: SourceDataItem; + initialValue: string; +} + +export enum AddCustomSourceSteps { + ConfigureCustomStep = 'Configure Custom', + SaveCustomStep = 'Save Custom', +} + +export interface AddCustomSourceActions { + createContentSource(): void; + setButtonNotLoading(): void; + setCustomSourceNameValue(customSourceNameValue: string): string; + setNewCustomSource(data: CustomSource): CustomSource; +} + +interface AddCustomSourceValues { + buttonLoading: boolean; + currentStep: AddCustomSourceSteps; + customSourceNameValue: string; + newCustomSource: CustomSource; + sourceData: SourceDataItem; +} + +/** + * Workplace Search needs to know the host for the redirect. As of yet, we do not + * have access to this in Kibana. We parse it from the browser and pass it as a param. + */ + +export const AddCustomSourceLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'workplace_search', 'add_custom_source_logic'], + actions: { + createContentSource: true, + setButtonNotLoading: true, + setCustomSourceNameValue: (customSourceNameValue) => customSourceNameValue, + setNewCustomSource: (data) => data, + }, + reducers: ({ props }) => ({ + buttonLoading: [ + false, + { + setButtonNotLoading: () => false, + createContentSource: () => true, + }, + ], + currentStep: [ + AddCustomSourceSteps.ConfigureCustomStep, + { + setNewCustomSource: () => AddCustomSourceSteps.SaveCustomStep, + }, + ], + customSourceNameValue: [ + props.initialValue, + { + setCustomSourceNameValue: (_, customSourceNameValue) => customSourceNameValue, + }, + ], + newCustomSource: [ + {} as CustomSource, + { + setNewCustomSource: (_, newCustomSource) => newCustomSource, + }, + ], + sourceData: [props.sourceData], + }), + listeners: ({ actions, values }) => ({ + createContentSource: async () => { + clearFlashMessages(); + const { isOrganization } = AppLogic.values; + const route = isOrganization + ? '/internal/workplace_search/org/create_source' + : '/internal/workplace_search/account/create_source'; + + const { customSourceNameValue } = values; + + const params = { + service_type: 'custom', + name: customSourceNameValue, + }; + + try { + const response = await HttpLogic.values.http.post(route, { + body: JSON.stringify({ ...params }), + }); + actions.setNewCustomSource(response); + } catch (e) { + flashAPIErrors(e); + } finally { + actions.setButtonNotLoading(); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx index 0501509b3a8ef7..4598ca337f4e2c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx @@ -22,16 +22,16 @@ import { PersonalDashboardLayout, } from '../../../../components/layout'; +import { staticSourceData } from '../../source_data'; + import { AddSource } from './add_source'; import { AddSourceSteps } from './add_source_logic'; import { ConfigCompleted } from './config_completed'; import { ConfigurationIntro } from './configuration_intro'; -import { ConfigureCustom } from './configure_custom'; import { ConfigureOauth } from './configure_oauth'; import { ConnectInstance } from './connect_instance'; import { Reauthenticate } from './reauthenticate'; import { SaveConfig } from './save_config'; -import { SaveCustom } from './save_custom'; describe('AddSourceList', () => { const { navigateToUrl } = mockKibanaValues; @@ -65,7 +65,7 @@ describe('AddSourceList', () => { }); it('renders default state', () => { - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(ConfigurationIntro).prop('advanceStep')(); expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); @@ -74,14 +74,14 @@ describe('AddSourceList', () => { describe('layout', () => { it('renders the default workplace search layout when on an organization view', () => { setMockValues({ ...mockValues, isOrganization: true }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); }); it('renders the personal dashboard layout when not in an organization', () => { setMockValues({ ...mockValues, isOrganization: false }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.type()).toEqual(PersonalDashboardLayout); }); @@ -89,7 +89,7 @@ describe('AddSourceList', () => { it('renders a breadcrumb fallback while data is loading', () => { setMockValues({ ...mockValues, dataLoading: true, sourceConfigData: {} }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.prop('pageChrome')).toEqual(['Sources', 'Add Source', '...']); }); @@ -99,7 +99,7 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.ConfigCompletedStep, }); - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(ConfigCompleted).prop('advanceStep')(); expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); @@ -111,7 +111,7 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.SaveConfigStep, }); - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); saveConfig.prop('advanceStep')(); saveConfig.prop('goBackStep')!(); @@ -126,51 +126,30 @@ describe('AddSourceList', () => { sourceConfigData, addSourceCurrentStep: AddSourceSteps.ConnectInstanceStep, }); - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(ConnectInstance).prop('onFormCreated')('foo'); expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); }); - it('renders Configure Custom step', () => { - setMockValues({ - ...mockValues, - addSourceCurrentStep: AddSourceSteps.ConfigureCustomStep, - }); - const wrapper = shallow(); - wrapper.find(ConfigureCustom).prop('advanceStep')(); - - expect(createContentSource).toHaveBeenCalled(); - }); - it('renders Configure Oauth step', () => { setMockValues({ ...mockValues, addSourceCurrentStep: AddSourceSteps.ConfigureOauthStep, }); - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(ConfigureOauth).prop('onFormCreated')('foo'); expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); }); - it('renders Save Custom step', () => { - setMockValues({ - ...mockValues, - addSourceCurrentStep: AddSourceSteps.SaveCustomStep, - }); - const wrapper = shallow(); - - expect(wrapper.find(SaveCustom)).toHaveLength(1); - }); - it('renders Reauthenticate step', () => { setMockValues({ ...mockValues, addSourceCurrentStep: AddSourceSteps.ReauthenticateStep, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(Reauthenticate)).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx index f575ddb19ebdc8..1e9be74224c5ed 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx @@ -18,49 +18,28 @@ import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, } from '../../../../components/layout'; -import { NAV, CUSTOM_SERVICE_TYPE } from '../../../../constants'; -import { SOURCES_PATH, getSourcesPath } from '../../../../routes'; -import { SourceDataItem } from '../../../../types'; -import { staticSourceData } from '../../source_data'; +import { NAV } from '../../../../constants'; +import { SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes'; import { AddSourceHeader } from './add_source_header'; import { AddSourceLogic, AddSourceProps, AddSourceSteps } from './add_source_logic'; import { ConfigCompleted } from './config_completed'; import { ConfigurationIntro } from './configuration_intro'; -import { ConfigureCustom } from './configure_custom'; import { ConfigureOauth } from './configure_oauth'; import { ConnectInstance } from './connect_instance'; import { Reauthenticate } from './reauthenticate'; import { SaveConfig } from './save_config'; -import { SaveCustom } from './save_custom'; import './add_source.scss'; export const AddSource: React.FC = (props) => { - const { - initializeAddSource, - setAddSourceStep, - saveSourceConfig, - createContentSource, - resetSourceState, - } = useActions(AddSourceLogic); - const { - addSourceCurrentStep, - sourceConfigData: { - name, - categories, - needsPermissions, - accountContextOnly, - privateSourcesEnabled, - }, - dataLoading, - newCustomSource, - } = useValues(AddSourceLogic); - - const { serviceType, configuration, features, objTypes, addPath } = staticSourceData[ - props.sourceIndex - ] as SourceDataItem; - + const { initializeAddSource, setAddSourceStep, saveSourceConfig, resetSourceState } = + useActions(AddSourceLogic); + const { addSourceCurrentStep, sourceConfigData, dataLoading } = useValues(AddSourceLogic); + const { name, categories, needsPermissions, accountContextOnly, privateSourcesEnabled } = + sourceConfigData; + const { serviceType, configuration, features, objTypes } = props.sourceData; + const addPath = getAddPath(serviceType); const { isOrganization } = useValues(AppLogic); useEffect(() => { @@ -85,9 +64,6 @@ export const AddSource: React.FC = (props) => { KibanaLogic.values.navigateToUrl(`${getSourcesPath(addPath, isOrganization)}/connect`); }; - const saveCustomSuccess = () => setAddSourceStep(AddSourceSteps.SaveCustomStep); - const goToSaveCustom = () => createContentSource(CUSTOM_SERVICE_TYPE, saveCustomSuccess); - const goToFormSourceCreated = () => { KibanaLogic.values.navigateToUrl(`${getSourcesPath(SOURCES_PATH, isOrganization)}`); flashSuccessToast(FORM_SOURCE_ADDED_SUCCESS_MESSAGE); @@ -131,24 +107,9 @@ export const AddSource: React.FC = (props) => { header={header} /> )} - {addSourceCurrentStep === AddSourceSteps.ConfigureCustomStep && ( - - )} {addSourceCurrentStep === AddSourceSteps.ConfigureOauthStep && ( )} - {addSourceCurrentStep === AddSourceSteps.SaveCustomStep && ( - - )} {addSourceCurrentStep === AddSourceSteps.ReauthenticateStep && ( )} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx index 08e002ee432a9d..15160abb428095 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_list.tsx @@ -27,7 +27,7 @@ import { } from '../../../../components/layout'; import { ContentSection } from '../../../../components/shared/content_section'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; -import { NAV, CUSTOM_SERVICE_TYPE } from '../../../../constants'; +import { NAV, CUSTOM_SERVICE_TYPE, EXTERNAL_SERVICE_TYPE } from '../../../../constants'; import { SourceDataItem } from '../../../../types'; import { SourcesLogic } from '../../sources_logic'; @@ -90,12 +90,12 @@ export const AddSourceList: React.FC = () => { const filterConfiguredSources = (source: SourceDataItem) => filterSources(source, configuredSources); - const visibleAvailableSources = availableSources.filter( - filterAvailableSources - ) as SourceDataItem[]; - const visibleConfiguredSources = configuredSources.filter( - filterConfiguredSources - ) as SourceDataItem[]; + const visibleAvailableSources = availableSources + .filter(filterAvailableSources) + .filter((source) => source.serviceType !== EXTERNAL_SERVICE_TYPE); + // The API returns available external sources as a separate entry, but we don't want to present them as options to add + + const visibleConfiguredSources = configuredSources.filter(filterConfiguredSources); const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts index 65ccd8d95256e8..80f8a2fc18218d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts @@ -15,6 +15,7 @@ import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; import { nextTick } from '@kbn/test-jest-helpers'; +import { docLinks } from '../../../../../shared/doc_links'; import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; jest.mock('../../../../app_logic', () => ({ @@ -22,13 +23,9 @@ jest.mock('../../../../app_logic', () => ({ })); import { AppLogic } from '../../../../app_logic'; -import { - ADD_GITHUB_PATH, - SOURCES_PATH, - PRIVATE_SOURCES_PATH, - getSourcesPath, -} from '../../../../routes'; -import { CustomSource } from '../../../../types'; +import { SOURCE_NAMES, SOURCE_OBJ_TYPES } from '../../../../constants'; +import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath } from '../../../../routes'; +import { FeatureIds } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; import { SourcesLogic } from '../../sources_logic'; @@ -38,6 +35,8 @@ import { SourceConfigData, SourceConnectData, OrganizationsMap, + AddSourceValues, + AddSourceProps, } from './add_source_logic'; describe('AddSourceLogic', () => { @@ -46,13 +45,12 @@ describe('AddSourceLogic', () => { const { navigateToUrl } = mockKibanaValues; const { clearFlashMessages, flashAPIErrors, setErrorMessage } = mockFlashMessageHelpers; - const DEFAULT_VALUES = { + const DEFAULT_VALUES: AddSourceValues = { addSourceCurrentStep: AddSourceSteps.ConfigIntroStep, - addSourceProps: {}, + addSourceProps: {} as AddSourceProps, dataLoading: true, sectionLoading: true, buttonLoading: false, - customSourceNameValue: '', clientIdValue: '', clientSecretValue: '', baseUrlValue: '', @@ -62,7 +60,6 @@ describe('AddSourceLogic', () => { indexPermissionsValue: false, sourceConfigData: {} as SourceConfigData, sourceConnectData: {} as SourceConnectData, - newCustomSource: {} as CustomSource, oauthConfigCompleted: false, currentServiceType: '', githubOrganizations: [], @@ -81,8 +78,34 @@ describe('AddSourceLogic', () => { serviceType: 'github', githubOrganizations: ['foo', 'bar'], }; - - const CUSTOM_SERVICE_TYPE_INDEX = 17; + const DEFAULT_SERVICE_TYPE = { + name: SOURCE_NAMES.BOX, + iconName: SOURCE_NAMES.BOX, + serviceType: 'box', + configuration: { + isPublicKey: false, + hasOauthRedirect: true, + needsBaseUrl: false, + documentationUrl: docLinks.workplaceSearchBox, + applicationPortalUrl: 'https://app.box.com/developers/console', + }, + objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.ALL_FILES], + features: { + basicOrgContext: [ + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + FeatureIds.GlobalAccessPermissions, + ], + basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], + platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], + platinumPrivateContext: [ + FeatureIds.Private, + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + ], + }, + accountContextOnly: false, + }; beforeEach(() => { jest.clearAllMocks(); @@ -145,15 +168,6 @@ describe('AddSourceLogic', () => { }); }); - it('setCustomSourceNameValue', () => { - AddSourceLogic.actions.setCustomSourceNameValue('name'); - - expect(AddSourceLogic.values).toEqual({ - ...DEFAULT_VALUES, - customSourceNameValue: 'name', - }); - }); - it('setSourceLoginValue', () => { AddSourceLogic.actions.setSourceLoginValue('login'); @@ -190,22 +204,6 @@ describe('AddSourceLogic', () => { }); }); - it('setCustomSourceData', () => { - const newCustomSource = { - accessToken: 'foo', - key: 'bar', - name: 'source', - id: '123key', - }; - - AddSourceLogic.actions.setCustomSourceData(newCustomSource); - - expect(AddSourceLogic.values).toEqual({ - ...DEFAULT_VALUES, - newCustomSource, - }); - }); - it('setPreContentSourceConfigData', () => { AddSourceLogic.actions.setPreContentSourceConfigData(config); @@ -260,13 +258,14 @@ describe('AddSourceLogic', () => { }); it('handles fallback states', () => { - const { publicKey, privateKey, consumerKey } = sourceConfigData.configuredFields; - const sourceConfigDataMock = { + const { publicKey, privateKey, consumerKey, apiKey } = sourceConfigData.configuredFields; + const sourceConfigDataMock: SourceConfigData = { ...sourceConfigData, configuredFields: { publicKey, privateKey, consumerKey, + apiKey, }, }; AddSourceLogic.actions.setSourceConfigData(sourceConfigDataMock); @@ -284,7 +283,7 @@ describe('AddSourceLogic', () => { describe('listeners', () => { it('initializeAddSource', () => { - const addSourceProps = { sourceIndex: 1 }; + const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE }; const getSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'getSourceConfigData'); const setAddSourcePropsSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceProps'); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); @@ -293,21 +292,13 @@ describe('AddSourceLogic', () => { expect(setAddSourcePropsSpy).toHaveBeenCalledWith({ addSourceProps }); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigIntroStep); - expect(getSourceConfigDataSpy).toHaveBeenCalledWith('confluence_cloud'); + expect(getSourceConfigDataSpy).toHaveBeenCalledWith('box'); }); describe('getFirstStep', () => { - it('sets custom as first step', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceIndex: CUSTOM_SERVICE_TYPE_INDEX }; - AddSourceLogic.actions.initializeAddSource(addSourceProps); - - expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigureCustomStep); - }); - it('sets connect as first step', () => { const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceIndex: 1, connect: true }; + const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, connect: true }; AddSourceLogic.actions.initializeAddSource(addSourceProps); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); @@ -315,7 +306,7 @@ describe('AddSourceLogic', () => { it('sets configure as first step', () => { const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceIndex: 1, configure: true }; + const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, configure: true }; AddSourceLogic.actions.initializeAddSource(addSourceProps); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigureOauthStep); @@ -323,7 +314,7 @@ describe('AddSourceLogic', () => { it('sets reAuthenticate as first step', () => { const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceIndex: 1, reAuthenticate: true }; + const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, reAuthenticate: true }; AddSourceLogic.actions.initializeAddSource(addSourceProps); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ReauthenticateStep); @@ -401,7 +392,7 @@ describe('AddSourceLogic', () => { await nextTick(); expect(setPreContentSourceIdSpy).toHaveBeenCalledWith(preContentSourceId); - expect(navigateToUrl).toHaveBeenCalledWith(`${ADD_GITHUB_PATH}/configure${queryString}`); + expect(navigateToUrl).toHaveBeenCalledWith(`/sources/add/github/configure${queryString}`); }); describe('Github error edge case', () => { @@ -635,7 +626,6 @@ describe('AddSourceLogic', () => { const errorCallback = jest.fn(); const serviceType = 'zendesk'; - const name = 'name'; const login = 'login'; const password = 'password'; const indexPermissions = false; @@ -643,7 +633,6 @@ describe('AddSourceLogic', () => { let params: any; beforeEach(() => { - AddSourceLogic.actions.setCustomSourceNameValue(name); AddSourceLogic.actions.setSourceLoginValue(login); AddSourceLogic.actions.setSourcePasswordValue(password); AddSourceLogic.actions.setPreContentSourceConfigData(config); @@ -652,7 +641,6 @@ describe('AddSourceLogic', () => { params = { service_type: serviceType, - name, login, password, organizations: ['foo'], @@ -661,8 +649,7 @@ describe('AddSourceLogic', () => { it('calls API and sets values', async () => { const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); - const setCustomSourceDataSpy = jest.spyOn(AddSourceLogic.actions, 'setCustomSourceData'); - http.post.mockReturnValue(Promise.resolve({ sourceConfigData })); + http.post.mockReturnValue(Promise.resolve()); AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); @@ -672,7 +659,6 @@ describe('AddSourceLogic', () => { body: JSON.stringify({ ...params }), }); await nextTick(); - expect(setCustomSourceDataSpy).toHaveBeenCalledWith({ sourceConfigData }); expect(successCallback).toHaveBeenCalled(); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts index 6dbac2dcd14526..db0c5b97372636 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts @@ -21,20 +21,14 @@ import { import { HttpLogic } from '../../../../../shared/http'; import { KibanaLogic } from '../../../../../shared/kibana'; import { AppLogic } from '../../../../app_logic'; -import { CUSTOM_SERVICE_TYPE, WORKPLACE_SEARCH_URL_PREFIX } from '../../../../constants'; -import { - SOURCES_PATH, - ADD_GITHUB_PATH, - PRIVATE_SOURCES_PATH, - getSourcesPath, -} from '../../../../routes'; -import { CustomSource } from '../../../../types'; +import { WORKPLACE_SEARCH_URL_PREFIX } from '../../../../constants'; +import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes'; +import { SourceDataItem } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; -import { staticSourceData } from '../../source_data'; import { SourcesLogic } from '../../sources_logic'; export interface AddSourceProps { - sourceIndex: number; + sourceData: SourceDataItem; connect?: boolean; configure?: boolean; reAuthenticate?: boolean; @@ -45,9 +39,7 @@ export enum AddSourceSteps { SaveConfigStep = 'Save Config', ConfigCompletedStep = 'Config Completed', ConnectInstanceStep = 'Connect Instance', - ConfigureCustomStep = 'Configure Custom', ConfigureOauthStep = 'Configure Oauth', - SaveCustomStep = 'Save Custom', ReauthenticateStep = 'Reauthenticate', } @@ -71,12 +63,10 @@ export interface AddSourceActions { setClientIdValue(clientIdValue: string): string; setClientSecretValue(clientSecretValue: string): string; setBaseUrlValue(baseUrlValue: string): string; - setCustomSourceNameValue(customSourceNameValue: string): string; setSourceLoginValue(loginValue: string): string; setSourcePasswordValue(passwordValue: string): string; setSourceSubdomainValue(subdomainValue: string): string; setSourceIndexPermissionsValue(indexPermissionsValue: boolean): boolean; - setCustomSourceData(data: CustomSource): CustomSource; setPreContentSourceConfigData(data: PreContentSourceResponse): PreContentSourceResponse; setPreContentSourceId(preContentSourceId: string): string; setSelectedGithubOrganizations(option: string): string; @@ -119,6 +109,8 @@ export interface SourceConfigData { baseUrl?: string; clientId?: string; clientSecret?: string; + url?: string; + apiKey?: string; }; accountContextOnly?: boolean; } @@ -132,13 +124,12 @@ export interface OrganizationsMap { [key: string]: string | boolean; } -interface AddSourceValues { +export interface AddSourceValues { addSourceProps: AddSourceProps; addSourceCurrentStep: AddSourceSteps; dataLoading: boolean; sectionLoading: boolean; buttonLoading: boolean; - customSourceNameValue: string; clientIdValue: string; clientSecretValue: string; baseUrlValue: string; @@ -148,7 +139,6 @@ interface AddSourceValues { indexPermissionsValue: boolean; sourceConfigData: SourceConfigData; sourceConnectData: SourceConnectData; - newCustomSource: CustomSource; currentServiceType: string; githubOrganizations: string[]; selectedGithubOrganizationsMap: OrganizationsMap; @@ -185,12 +175,10 @@ export const AddSourceLogic = kea clientIdValue, setClientSecretValue: (clientSecretValue: string) => clientSecretValue, setBaseUrlValue: (baseUrlValue: string) => baseUrlValue, - setCustomSourceNameValue: (customSourceNameValue: string) => customSourceNameValue, setSourceLoginValue: (loginValue: string) => loginValue, setSourcePasswordValue: (passwordValue: string) => passwordValue, setSourceSubdomainValue: (subdomainValue: string) => subdomainValue, setSourceIndexPermissionsValue: (indexPermissionsValue: boolean) => indexPermissionsValue, - setCustomSourceData: (data: CustomSource) => data, setPreContentSourceConfigData: (data: PreContentSourceResponse) => data, setPreContentSourceId: (preContentSourceId: string) => preContentSourceId, setSelectedGithubOrganizations: (option: string) => option, @@ -322,20 +310,6 @@ export const AddSourceLogic = kea false, }, ], - customSourceNameValue: [ - '', - { - setCustomSourceNameValue: (_, customSourceNameValue) => customSourceNameValue, - resetSourceState: () => '', - }, - ], - newCustomSource: [ - {} as CustomSource, - { - setCustomSourceData: (_, newCustomSource) => newCustomSource, - resetSourceState: () => ({} as CustomSource), - }, - ], currentServiceType: [ '', { @@ -383,7 +357,7 @@ export const AddSourceLogic = kea ({ initializeAddSource: ({ addSourceProps }) => { - const { serviceType } = staticSourceData[addSourceProps.sourceIndex]; + const { serviceType } = addSourceProps.sourceData; actions.setAddSourceProps({ addSourceProps }); actions.setAddSourceStep(getFirstStep(addSourceProps)); actions.getSourceConfigData(serviceType); @@ -540,7 +514,9 @@ export const AddSourceLogic = kea 0 ? githubOrganizations : undefined, @@ -580,10 +554,9 @@ export const AddSourceLogic = kea params[key] === undefined && delete params[key]); try { - const response = await HttpLogic.values.http.post(route, { + await HttpLogic.values.http.post(route, { body: JSON.stringify({ ...params }), }); - actions.setCustomSourceData(response); successCallback(); } catch (e) { flashAPIErrors(e); @@ -596,11 +569,7 @@ export const AddSourceLogic = kea { - const { sourceIndex, connect, configure, reAuthenticate } = props; - const { serviceType } = staticSourceData[sourceIndex]; - const isCustom = serviceType === CUSTOM_SERVICE_TYPE; - - if (isCustom) return AddSourceSteps.ConfigureCustomStep; + const { connect, configure, reAuthenticate } = props; if (connect) return AddSourceSteps.ConnectInstanceStep; if (configure) return AddSourceSteps.ConfigureOauthStep; if (reAuthenticate) return AddSourceSteps.ReauthenticateStep; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx index f168dfbea91ce8..fbcb8685f7ff9f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx @@ -26,7 +26,7 @@ describe('AvailableSourcesList', () => { const wrapper = shallow(); expect(wrapper.find(EuiTitle)).toHaveLength(1); - expect(wrapper.find('[data-test-subj="AvailableSourceListItem"]')).toHaveLength(11); + expect(wrapper.find('[data-test-subj="AvailableSourceListItem"]')).toHaveLength(20); expect(wrapper.find('[data-test-subj="CustomAPISourceLink"]')).toHaveLength(1); }); @@ -34,7 +34,7 @@ describe('AvailableSourcesList', () => { setMockValues({ hasPlatinumLicense: false }); const wrapper = shallow(); - expect(wrapper.find(EuiToolTip)).toHaveLength(1); + expect(wrapper.find(EuiToolTip)).toHaveLength(2); }); it('handles empty state', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx index 13f0f41643e169..7dc9ad9ca0f60e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx @@ -24,9 +24,11 @@ import { i18n } from '@kbn/i18n'; import { LicensingLogic } from '../../../../../shared/licensing'; import { EuiButtonEmptyTo, EuiLinkTo } from '../../../../../shared/react_router_helpers'; import { SourceIcon } from '../../../../components/shared/source_icon'; -import { ADD_CUSTOM_PATH, getSourcesPath } from '../../../../routes'; +import { ADD_CUSTOM_PATH, getAddPath, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; +import { staticCustomSourceData } from '../../source_data'; + import { AVAILABLE_SOURCE_EMPTY_STATE, AVAILABLE_SOURCE_TITLE, @@ -41,7 +43,8 @@ interface AvailableSourcesListProps { export const AvailableSourcesList: React.FC = ({ sources }) => { const { hasPlatinumLicense } = useValues(LicensingLogic); - const getSourceCard = ({ name, serviceType, addPath, accountContextOnly }: SourceDataItem) => { + const getSourceCard = ({ name, serviceType, accountContextOnly }: SourceDataItem) => { + const addPath = getAddPath(serviceType); const disabled = !hasPlatinumLicense && accountContextOnly; const connectButton = () => { @@ -105,6 +108,15 @@ export const AvailableSourcesList: React.FC = ({ sour
))} + + + {getSourceCard(staticCustomSourceData)} + + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx new file mode 100644 index 00000000000000..bfb916847d865e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx @@ -0,0 +1,141 @@ +/* + * 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 { mockKibanaValues, setMockValues } from '../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiText, EuiButton } from '@elastic/eui'; + +import { + PersonalDashboardLayout, + WorkplaceSearchPageTemplate, +} from '../../../../components/layout'; +import { staticSourceData } from '../../source_data'; + +import { ConfigurationChoice } from './configuration_choice'; + +describe('ConfigurationChoice', () => { + const { navigateToUrl } = mockKibanaValues; + const props = { + sourceData: staticSourceData[0], + }; + const mockValues = { + isOrganization: true, + }; + + beforeEach(() => { + setMockValues(mockValues); + jest.clearAllMocks(); + }); + + describe('layout', () => { + it('renders the default workplace search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + }); + + it('renders the personal dashboard layout when not in an organization', () => { + setMockValues({ ...mockValues, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + }); + }); + + it('renders internal connector if available', () => { + const wrapper = shallow(); + + expect(wrapper.find('EuiPanel')).toHaveLength(1); + expect(wrapper.find(EuiText)).toHaveLength(3); + expect(wrapper.find(EuiButton)).toHaveLength(1); + }); + it('should navigate to internal connector on internal connector click', () => { + const wrapper = shallow(); + const button = wrapper.find(EuiButton); + button.simulate('click'); + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/internal/'); + }); + + it('renders external connector if available', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('EuiPanel')).toHaveLength(1); + expect(wrapper.find(EuiText)).toHaveLength(3); + expect(wrapper.find(EuiButton)).toHaveLength(1); + }); + it('should navigate to external connector on external connector click', () => { + const wrapper = shallow( + + ); + const button = wrapper.find(EuiButton); + button.simulate('click'); + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/external/'); + }); + + it('renders custom connector if available', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find('EuiPanel')).toHaveLength(1); + expect(wrapper.find(EuiText)).toHaveLength(3); + expect(wrapper.find(EuiButton)).toHaveLength(1); + }); + it('should navigate to custom connector on internal connector click', () => { + const wrapper = shallow( + + ); + const button = wrapper.find(EuiButton); + button.simulate('click'); + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/custom/'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx new file mode 100644 index 00000000000000..46a8998c9dd10a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx @@ -0,0 +1,236 @@ +/* + * 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 { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { KibanaLogic } from '../../../../../shared/kibana'; +import { AppLogic } from '../../../../app_logic'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { NAV } from '../../../../constants'; +import { getAddPath, getSourcesPath } from '../../../../routes'; +import { SourceDataItem } from '../../../../types'; + +import { AddSourceHeader } from './add_source_header'; + +interface ConfigurationIntroProps { + sourceData: SourceDataItem; +} + +export const ConfigurationChoice: React.FC = ({ + sourceData: { + name, + serviceType, + externalConnectorAvailable, + internalConnectorAvailable, + customConnectorAvailable, + }, +}) => { + const { isOrganization } = useValues(AppLogic); + const goToInternal = () => + KibanaLogic.values.navigateToUrl( + `${getSourcesPath( + `${getSourcesPath(getAddPath(serviceType), isOrganization)}/internal`, + isOrganization + )}/` + ); + const goToExternal = () => + KibanaLogic.values.navigateToUrl( + `${getSourcesPath( + `${getSourcesPath(getAddPath(serviceType), isOrganization)}/external`, + isOrganization + )}/` + ); + const goToCustom = () => + KibanaLogic.values.navigateToUrl( + `${getSourcesPath( + `${getSourcesPath(getAddPath(serviceType), isOrganization)}/custom`, + isOrganization + )}/` + ); + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + + return ( + + + + + {internalConnectorAvailable && ( + + + + + +

{name}

+
+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.title', + { + defaultMessage: 'Default connector', + } + )} +

+
+
+ + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.description', + { + defaultMessage: 'Use our out-of-the-box connector to get started quickly.', + } + )} + + + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.internal.button', + { + defaultMessage: 'Connect', + } + )} + + +
+
+
+ )} + {externalConnectorAvailable && ( + + + + + +

{name}

+
+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.title', + { + defaultMessage: 'Custom connector', + } + )} +

+
+
+ + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.description', + { + defaultMessage: + 'Set up a custom connector for more configurability and control.', + } + )} + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.button', + { + defaultMessage: 'Instructions', + } + )} + + + +
+
+
+ )} + {customConnectorAvailable && ( + + + + + +

{name}

+
+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.custom.title', + { + defaultMessage: 'Custom connector', + } + )} +

+
+
+ + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.custom.description', + { + defaultMessage: + 'Set up a custom connector for more configurability and control.', + } + )} + + +
+ + + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.custom.button', + { + defaultMessage: 'Instructions', + } + )} + + + +
+
+ )} +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.test.tsx index 6c0d87b7696ecd..645226c546f102 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.test.tsx @@ -14,45 +14,45 @@ import { shallow } from 'enzyme'; import { EuiForm, EuiFieldText } from '@elastic/eui'; +import { staticSourceData } from '../../source_data'; + import { ConfigureCustom } from './configure_custom'; describe('ConfigureCustom', () => { - const advanceStep = jest.fn(); const setCustomSourceNameValue = jest.fn(); - - const props = { - header:

Header

, - helpText: 'I bet you could use a hand.', - advanceStep, - }; + const createContentSource = jest.fn(); beforeEach(() => { - setMockActions({ setCustomSourceNameValue }); - setMockValues({ customSourceNameValue: 'name', buttonLoading: false }); + setMockActions({ setCustomSourceNameValue, createContentSource }); + setMockValues({ + customSourceNameValue: 'name', + buttonLoading: false, + sourceData: staticSourceData[1], + }); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiForm)).toHaveLength(1); }); it('handles input change', () => { - const wrapper = shallow(); - const TEXT = 'changed for the better'; + const wrapper = shallow(); + const text = 'changed for the better'; const input = wrapper.find(EuiFieldText); - input.simulate('change', { target: { value: TEXT } }); + input.simulate('change', { target: { value: text } }); - expect(setCustomSourceNameValue).toHaveBeenCalledWith(TEXT); + expect(setCustomSourceNameValue).toHaveBeenCalledWith(text); }); it('handles form submission', () => { - const wrapper = shallow(); + const wrapper = shallow(); const preventDefault = jest.fn(); wrapper.find('form').simulate('submit', { preventDefault }); expect(preventDefault).toHaveBeenCalled(); - expect(advanceStep).toHaveBeenCalled(); + expect(createContentSource).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx index e794323dc169e0..bf5a7fea21333d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_custom.tsx @@ -24,51 +24,64 @@ import { docLinks } from '../../../../../shared/doc_links'; import { SOURCE_NAME_LABEL } from '../../constants'; -import { AddSourceLogic } from './add_source_logic'; +import { AddCustomSourceLogic } from './add_custom_source_logic'; +import { AddSourceHeader } from './add_source_header'; import { CONFIG_CUSTOM_BUTTON, CONFIG_CUSTOM_LINK_TEXT } from './constants'; -interface ConfigureCustomProps { - header: React.ReactNode; - helpText: string; - advanceStep(): void; -} - -export const ConfigureCustom: React.FC = ({ - helpText, - advanceStep, - header, -}) => { - const { setCustomSourceNameValue } = useActions(AddSourceLogic); - const { customSourceNameValue, buttonLoading } = useValues(AddSourceLogic); +export const ConfigureCustom: React.FC = () => { + const { setCustomSourceNameValue, createContentSource } = useActions(AddCustomSourceLogic); + const { customSourceNameValue, buttonLoading, sourceData } = useValues(AddCustomSourceLogic); const handleFormSubmit = (e: FormEvent) => { e.preventDefault(); - advanceStep(); + createContentSource(); }; const handleNameChange = (e: ChangeEvent) => setCustomSourceNameValue(e.target.value); + const { + serviceType, + configuration: { documentationUrl, helpText }, + name, + categories = [], + } = sourceData; + return ( <> - {header} +

{helpText}

- - {CONFIG_CUSTOM_LINK_TEXT} - - ), - }} - /> + {serviceType === 'custom' ? ( + + {CONFIG_CUSTOM_LINK_TEXT} + + ), + }} + /> + ) : ( + + {CONFIG_CUSTOM_LINK_TEXT} + + ), + name, + }} + /> + )}

@@ -90,7 +103,17 @@ export const ConfigureCustom: React.FC = ({ isLoading={buttonLoading} data-test-subj="CreateCustomButton" > - {CONFIG_CUSTOM_BUTTON} + {serviceType === 'custom' ? ( + CONFIG_CUSTOM_BUTTON + ) : ( + + )}
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx index a1169cd582cba7..a13558469cc085 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx @@ -22,9 +22,9 @@ describe('ConfiguredSourcesList', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(5); - expect(wrapper.find('[data-test-subj="AccountOnlyTooltip"]')).toHaveLength(1); - expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(6); + expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(16); + expect(wrapper.find('[data-test-subj="AccountOnlyTooltip"]')).toHaveLength(2); + expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(19); }); it('handles empty state', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx index ac465c43643a44..d4bb62901cdb60 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx @@ -22,8 +22,9 @@ import { import { EuiButtonEmptyTo } from '../../../../../shared/react_router_helpers'; import { SourceIcon } from '../../../../components/shared/source_icon'; -import { getSourcesPath } from '../../../../routes'; +import { getAddPath, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; +import { hasMultipleConnectorOptions } from '../../../../utils'; import { CONFIGURED_SOURCES_LIST_UNCONNECTED_TOOLTIP, @@ -68,54 +69,62 @@ export const ConfiguredSourcesList: React.FC = ({ const visibleSources = ( - {sources.map(({ name, serviceType, addPath, connected, accountContextOnly }, i) => ( - - - - - - - - - - -

- {name} - {!connected && !accountContextOnly && isOrganization && unConnectedTooltip} - {accountContextOnly && isOrganization && accountOnlyTooltip} -

-
-
-
-
- - {((!isOrganization || (isOrganization && !accountContextOnly)) && ( - { + const { connected, accountContextOnly, name, serviceType } = sourceData; + return ( + + + + + - {CONFIGURED_SOURCES_CONNECT_BUTTON} - - )) || ( - - {ADD_SOURCE_ORG_SOURCES_TITLE} - - )} - -
-
-
- ))} + + + + + +

+ {name} + {!connected && + !accountContextOnly && + isOrganization && + unConnectedTooltip} + {accountContextOnly && isOrganization && accountOnlyTooltip} +

+
+
+ + + + {((!isOrganization || (isOrganization && !accountContextOnly)) && ( + + {CONFIGURED_SOURCES_CONNECT_BUTTON} + + )) || ( + + {ADD_SOURCE_ORG_SOURCES_TITLE} + + )} + + + + + ); + })}
); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx index c967b20e0450dc..0ee80019ea720e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx @@ -43,7 +43,7 @@ describe('ConnectInstance', () => { const credentialsSourceData = staticSourceData[13]; const oauthSourceData = staticSourceData[0]; - const subdomainSourceData = staticSourceData[16]; + const subdomainSourceData = staticSourceData[18]; const props = { ...credentialsSourceData, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx new file mode 100644 index 00000000000000..6288a5fc791294 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.test.tsx @@ -0,0 +1,90 @@ +/* + * 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 '../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiSteps } from '@elastic/eui'; + +import { staticSourceData } from '../../source_data'; + +import { ExternalConnectorConfig } from './external_connector_config'; + +describe('ExternalConnectorConfig', () => { + const goBack = jest.fn(); + const onDeleteConfig = jest.fn(); + const setExternalConnectorApiKey = jest.fn(); + const setExternalConnectorUrl = jest.fn(); + const saveExternalConnectorConfig = jest.fn(); + const fetchExternalSource = jest.fn(); + + const props = { + sourceData: staticSourceData[0], + goBack, + onDeleteConfig, + }; + + const values = { + sourceConfigData, + buttonLoading: false, + clientIdValue: 'foo', + clientSecretValue: 'bar', + baseUrlValue: 'http://foo.baz', + hasPlatinumLicense: true, + }; + + beforeEach(() => { + setMockActions({ + setExternalConnectorApiKey, + setExternalConnectorUrl, + saveExternalConnectorConfig, + fetchExternalSource, + }); + setMockValues({ ...values }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiSteps)).toHaveLength(1); + }); + + it('handles form submission', () => { + const wrapper = shallow(); + + const preventDefault = jest.fn(); + wrapper.find('form').simulate('submit', { preventDefault }); + + expect(preventDefault).toHaveBeenCalled(); + expect(saveExternalConnectorConfig).toHaveBeenCalled(); + }); + + describe('external connector configuration', () => { + it('handles url change', () => { + const wrapper = shallow(); + const steps = wrapper.find(EuiSteps); + const input = steps.dive().find('[name="external-connector-url"]'); + input.simulate('change', { target: { value: 'url' } }); + + expect(setExternalConnectorUrl).toHaveBeenCalledWith('url'); + }); + + it('handles Client secret change', () => { + const wrapper = shallow(); + const steps = wrapper.find(EuiSteps); + const input = steps.dive().find('[name="external-connector-api-key"]'); + input.simulate('change', { target: { value: 'api-key' } }); + + expect(setExternalConnectorApiKey).toHaveBeenCalledWith('api-key'); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx new file mode 100644 index 00000000000000..1f0528f492b9d0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_config.tsx @@ -0,0 +1,168 @@ +/* + * 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, { FormEvent, useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiSpacer, + EuiSteps, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { AppLogic } from '../../../../app_logic'; +import { + PersonalDashboardLayout, + WorkplaceSearchPageTemplate, +} from '../../../../components/layout'; +import { NAV, REMOVE_BUTTON } from '../../../../constants'; +import { SourceDataItem } from '../../../../types'; + +import { AddSourceHeader } from './add_source_header'; +import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from './constants'; +import { ExternalConnectorLogic } from './external_connector_logic'; + +interface SaveConfigProps { + sourceData: SourceDataItem; + goBack?: () => void; + onDeleteConfig?: () => void; +} + +export const ExternalConnectorConfig: React.FC = ({ goBack, onDeleteConfig }) => { + const serviceType = 'external'; + const { + fetchExternalSource, + setExternalConnectorApiKey, + setExternalConnectorUrl, + saveExternalConnectorConfig, + } = useActions(ExternalConnectorLogic); + + const { buttonLoading, externalConnectorUrl, externalConnectorApiKey, sourceConfigData } = + useValues(ExternalConnectorLogic); + + useEffect(() => { + fetchExternalSource(); + }, []); + + const handleFormSubmission = (e: FormEvent) => { + e.preventDefault(); + saveExternalConnectorConfig({ url: externalConnectorUrl, apiKey: externalConnectorApiKey }); + }; + + const { name, categories } = sourceConfigData; + const { isOrganization } = useValues(AppLogic); + + const saveButton = ( + + {OAUTH_SAVE_CONFIG_BUTTON} + + ); + + const deleteButton = ( + + {REMOVE_BUTTON} + + ); + + const backButton = {OAUTH_BACK_BUTTON}; + + const formActions = ( + + + {saveButton} + + {goBack && backButton} + {onDeleteConfig && deleteButton} + + + + ); + + const connectorForm = ( + + {/* TODO: get a docs link in here for the external connector + */} + + + + setExternalConnectorUrl(e.target.value)} + name="external-connector-url" + /> + + + setExternalConnectorApiKey(e.target.value)} + name="external-connector-api-key" + /> + + + {formActions} + + + ); + + const configSteps = [ + { + title: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.addSource.externalConnectorConfig.stepTitle', + { + defaultMessage: 'Provide the appropriate configuration information', + } + ), + children: connectorForm, + }, + ]; + + const header = ; + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + + return ( + + {header} + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts new file mode 100644 index 00000000000000..22a36deeeccd5a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.test.ts @@ -0,0 +1,144 @@ +/* + * 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 { LogicMounter, mockHttpValues, mockKibanaValues } from '../../../../../__mocks__/kea_logic'; +import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; + +import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; + +jest.mock('../../../../app_logic', () => ({ + AppLogic: { values: { isOrganization: true } }, +})); + +import { ExternalConnectorLogic, ExternalConnectorValues } from './external_connector_logic'; + +describe('ExternalConnectorLogic', () => { + const { mount } = new LogicMounter(ExternalConnectorLogic); + const { http } = mockHttpValues; + const { navigateToUrl } = mockKibanaValues; + + const DEFAULT_VALUES: ExternalConnectorValues = { + dataLoading: true, + buttonLoading: false, + externalConnectorUrl: '', + externalConnectorApiKey: '', + sourceConfigData: { + name: '', + categories: [], + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('has expected default values', () => { + expect(ExternalConnectorLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('fetchExternalSourceSuccess', () => { + beforeEach(() => { + ExternalConnectorLogic.actions.fetchExternalSourceSuccess(sourceConfigData); + }); + + it('turns off the data loading flag', () => { + expect(ExternalConnectorLogic.values.dataLoading).toEqual(false); + }); + + it('saves the external url', () => { + expect(ExternalConnectorLogic.values.externalConnectorUrl).toEqual( + sourceConfigData.configuredFields.url + ); + }); + + it('saves the source config', () => { + expect(ExternalConnectorLogic.values.sourceConfigData).toEqual(sourceConfigData); + }); + + it('sets undefined url to empty string', () => { + ExternalConnectorLogic.actions.fetchExternalSourceSuccess({ + ...sourceConfigData, + configuredFields: { ...sourceConfigData.configuredFields, url: undefined }, + }); + expect(ExternalConnectorLogic.values.externalConnectorUrl).toEqual(''); + }); + it('sets undefined api key to empty string', () => { + ExternalConnectorLogic.actions.fetchExternalSourceSuccess({ + ...sourceConfigData, + configuredFields: { ...sourceConfigData.configuredFields, apiKey: undefined }, + }); + expect(ExternalConnectorLogic.values.externalConnectorApiKey).toEqual(''); + }); + }); + + describe('saveExternalConnectorConfigSuccess', () => { + it('turns off the button loading flag', () => { + mount({ + buttonLoading: true, + }); + + ExternalConnectorLogic.actions.saveExternalConnectorConfigSuccess('external'); + + expect(ExternalConnectorLogic.values.buttonLoading).toEqual(false); + }); + }); + + describe('setExternalConnectorApiKey', () => { + it('updates the api key', () => { + ExternalConnectorLogic.actions.setExternalConnectorApiKey('abcd1234'); + + expect(ExternalConnectorLogic.values.externalConnectorApiKey).toEqual('abcd1234'); + }); + }); + + describe('setExternalConnectorUrl', () => { + it('updates the url', () => { + ExternalConnectorLogic.actions.setExternalConnectorUrl('https://www.elastic.co'); + + expect(ExternalConnectorLogic.values.externalConnectorUrl).toEqual( + 'https://www.elastic.co' + ); + }); + }); + }); + + describe('listeners', () => { + describe('fetchExternalSource', () => { + it('retrieves config info on the "external" connector', () => { + const promise = Promise.resolve(); + http.get.mockReturnValue(promise); + ExternalConnectorLogic.actions.fetchExternalSource(); + + expect(http.get).toHaveBeenCalledWith( + '/internal/workplace_search/org/settings/connectors/external' + ); + }); + + itShowsServerErrorAsFlashMessage(http.get, () => { + mount(); + ExternalConnectorLogic.actions.fetchExternalSource(); + }); + }); + + describe('saveExternalConnectorConfig', () => { + it('saves the external connector config', () => { + const saveExternalConnectorConfigSuccess = jest.spyOn( + ExternalConnectorLogic.actions, + 'saveExternalConnectorConfigSuccess' + ); + ExternalConnectorLogic.actions.saveExternalConnectorConfig({ + url: 'url', + apiKey: 'apiKey', + }); + expect(saveExternalConnectorConfigSuccess).toHaveBeenCalled(); + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/external'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts new file mode 100644 index 00000000000000..13c0b9167310b1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/external_connector_logic.ts @@ -0,0 +1,138 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { + flashAPIErrors, + flashSuccessToast, + clearFlashMessages, +} from '../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../shared/http'; +import { KibanaLogic } from '../../../../../shared/kibana'; +import { AppLogic } from '../../../../app_logic'; + +import { getAddPath, getSourcesPath } from '../../../../routes'; + +import { SourceConfigData } from './add_source_logic'; + +export interface ExternalConnectorActions { + fetchExternalSource: () => true; + fetchExternalSourceSuccess(sourceConfigData: SourceConfigData): SourceConfigData; + saveExternalConnectorConfigSuccess(externalConnectorId: string): string; + setExternalConnectorApiKey(externalConnectorApiKey: string): string; + saveExternalConnectorConfig(config: ExternalConnectorConfig): ExternalConnectorConfig; + setExternalConnectorUrl(externalConnectorUrl: string): string; + resetSourceState: () => true; +} + +export interface ExternalConnectorConfig { + url: string; + apiKey: string; +} + +export interface ExternalConnectorValues { + buttonLoading: boolean; + dataLoading: boolean; + externalConnectorApiKey: string; + externalConnectorUrl: string; + sourceConfigData: SourceConfigData | Pick; +} + +export const ExternalConnectorLogic = kea< + MakeLogicType +>({ + path: ['enterprise_search', 'workplace_search', 'external_connector_logic'], + actions: { + fetchExternalSource: true, + fetchExternalSourceSuccess: (sourceConfigData) => sourceConfigData, + saveExternalConnectorConfigSuccess: (externalConnectorId) => externalConnectorId, + saveExternalConnectorConfig: (config) => config, + setExternalConnectorApiKey: (externalConnectorApiKey: string) => externalConnectorApiKey, + setExternalConnectorUrl: (externalConnectorUrl: string) => externalConnectorUrl, + }, + reducers: { + dataLoading: [ + true, + { + fetchExternalSourceSuccess: () => false, + }, + ], + buttonLoading: [ + false, + { + saveExternalConnectorConfigSuccess: () => false, + saveExternalConnectorConfig: () => true, + }, + ], + externalConnectorUrl: [ + '', + { + fetchExternalSourceSuccess: (_, { configuredFields: { url } }) => url || '', + setExternalConnectorUrl: (_, url) => url, + }, + ], + externalConnectorApiKey: [ + '', + { + fetchExternalSourceSuccess: (_, { configuredFields: { apiKey } }) => apiKey || '', + setExternalConnectorApiKey: (_, apiKey) => apiKey, + }, + ], + sourceConfigData: [ + { name: '', categories: [] }, + { + fetchExternalSourceSuccess: (_, sourceConfigData) => sourceConfigData, + }, + ], + }, + listeners: ({ actions }) => ({ + fetchExternalSource: async () => { + const route = '/internal/workplace_search/org/settings/connectors/external'; + + try { + const response = await HttpLogic.values.http.get(route); + actions.fetchExternalSourceSuccess(response); + } catch (e) { + flashAPIErrors(e); + } + }, + saveExternalConnectorConfig: async () => { + clearFlashMessages(); + // const route = '/internal/workplace_search/org/settings/connectors'; + // const http = HttpLogic.values.http.post; + // const params = { + // url, + // api_key: apiKey, + // service_type: 'external', + // }; + try { + // const response = await http(route, { + // body: JSON.stringify(params), + // }); + + flashSuccessToast( + i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.flashMessages.externalConnectorCreated', + { + defaultMessage: 'Successfully updated configuration.', + } + ) + ); + // TODO: use response data instead + actions.saveExternalConnectorConfigSuccess('external'); + KibanaLogic.values.navigateToUrl( + getSourcesPath(`${getAddPath('external')}`, AppLogic.values.isOrganization) + ); + } catch (e) { + // flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/github_via_app.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/github_via_app.tsx index b62648348ed805..c0e72d3b7a5a05 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/github_via_app.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/github_via_app.tsx @@ -61,7 +61,8 @@ export const GitHubViaApp: React.FC = ({ isGithubEnterpriseSe const { hasPlatinumLicense } = useValues(LicensingLogic); const name = isGithubEnterpriseServer ? SOURCE_NAMES.GITHUB_ENTERPRISE : SOURCE_NAMES.GITHUB; - const data = staticSourceData.find((source) => source.name === name); + const serviceType = isGithubEnterpriseServer ? 'github_enterprise_server' : 'github'; + const data = staticSourceData.find((source) => source.serviceType === serviceType); const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; const handleSubmit = (e: FormEvent) => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx index 4715c50e4233c8..c05110bd4e6acf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.test.tsx @@ -11,40 +11,45 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiLink, EuiPanel, EuiTitle } from '@elastic/eui'; +import { EuiPanel, EuiTitle } from '@elastic/eui'; import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; import { LicenseBadge } from '../../../../components/shared/license_badge'; +import { staticCustomSourceData } from '../../source_data'; import { SaveCustom } from './save_custom'; describe('SaveCustom', () => { - const props = { - documentationUrl: 'http://string.boolean', + const mockValues = { newCustomSource: { - accessToken: 'dsgfsd', - key: 'sdfs', - name: 'source', - id: '12e1', + id: 'id', + accessToken: 'token', + name: 'name', }, + sourceData: staticCustomSourceData, isOrganization: true, - header:

Header

, + hasPlatinumLicense: true, }; + + beforeEach(() => { + setMockValues(mockValues); + }); + it('renders', () => { - setMockValues({ hasPlatinumLicense: true }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiPanel)).toHaveLength(1); expect(wrapper.find(EuiTitle)).toHaveLength(4); expect(wrapper.find(EuiLinkTo)).toHaveLength(1); + expect(wrapper.find(LicenseBadge)).toHaveLength(0); }); - - it('renders platinum LicenseBadge and link', () => { - setMockValues({ hasPlatinumLicense: false }); - const wrapper = shallow(); + it('renders platinum license badge if license is not present', () => { + setMockValues({ ...mockValues, hasPlatinumLicense: false }); + const wrapper = shallow(); expect(wrapper.find(LicenseBadge)).toHaveLength(1); - expect(wrapper.find(EuiLink)).toHaveLength(1); + expect(wrapper.find(EuiTitle)).toHaveLength(4); + expect(wrapper.find(EuiLinkTo)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx index c136f22d91d3d8..14d088f377f5ed 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx @@ -20,6 +20,7 @@ import { EuiTitle, EuiLink, EuiPanel, + EuiCode, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -27,6 +28,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { docLinks } from '../../../../../shared/doc_links'; import { LicensingLogic } from '../../../../../shared/licensing'; import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; +import { AppLogic } from '../../../../app_logic'; import { LicenseBadge } from '../../../../components/shared/license_badge'; import { SOURCES_PATH, @@ -34,11 +36,12 @@ import { getContentSourcePath, getSourcesPath, } from '../../../../routes'; -import { CustomSource } from '../../../../types'; import { LEARN_CUSTOM_FEATURES_BUTTON } from '../../constants'; import { SourceIdentifier } from '../source_identifier'; +import { AddCustomSourceLogic } from './add_custom_source_logic'; +import { AddSourceHeader } from './add_source_header'; import { SAVE_CUSTOM_BODY1, SAVE_CUSTOM_BODY2, @@ -51,23 +54,20 @@ import { SAVE_CUSTOM_DOC_PERMISSIONS_LINK, } from './constants'; -interface SaveCustomProps { - documentationUrl: string; - newCustomSource: CustomSource; - isOrganization: boolean; - header: React.ReactNode; -} - -export const SaveCustom: React.FC = ({ - documentationUrl, - newCustomSource: { id, name }, - isOrganization, - header, -}) => { +export const SaveCustom: React.FC = () => { + const { newCustomSource, sourceData } = useValues(AddCustomSourceLogic); + const { isOrganization } = useValues(AppLogic); const { hasPlatinumLicense } = useValues(LicensingLogic); + const { + serviceType, + configuration: { githubRepository, documentationUrl }, + name, + categories = [], + } = sourceData; + return ( <> - {header} + @@ -84,7 +84,7 @@ export const SaveCustom: React.FC = ({ 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.heading', { defaultMessage: '{name} Created', - values: { name }, + values: { name: newCustomSource.name }, } )} @@ -93,7 +93,22 @@ export const SaveCustom: React.FC = ({ {SAVE_CUSTOM_BODY1} -
+ + {serviceType !== 'custom' && githubRepository && ( + <> + +
+ + + {githubRepository} + + + + + )} {SAVE_CUSTOM_BODY2}
@@ -105,7 +120,7 @@ export const SaveCustom: React.FC = ({
- +
@@ -119,17 +134,32 @@ export const SaveCustom: React.FC = ({

- - {SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK} - - ), - }} - /> + {serviceType === 'custom' ? ( + + {SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK} + + ), + }} + /> + ) : ( + + {SAVE_CUSTOM_VISUAL_WALKTHROUGH_LINK} + + ), + name, + }} + /> + )}

@@ -149,7 +179,7 @@ export const SaveCustom: React.FC = ({ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index 484a9ca14b4e1d..d57dc496832751 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -41,7 +41,7 @@ import { SAVE_CHANGES_BUTTON, REMOVE_BUTTON, } from '../../../constants'; -import { SourceDataItem } from '../../../types'; +import { getEditPath } from '../../../routes'; import { handlePrivateKeyUpload } from '../../../utils'; import { AddSourceLogic } from '../components/add_source/add_source_logic'; import { @@ -57,7 +57,6 @@ import { SYNC_DIAGNOSTICS_DESCRIPTION, SYNC_DIAGNOSTICS_BUTTON, } from '../constants'; -import { staticSourceData } from '../source_data'; import { SourceLogic } from '../source_logic'; import { DownloadDiagnosticsButton } from './download_diagnostics_button'; @@ -96,8 +95,7 @@ export const SourceSettings: React.FC = () => { const editPath = isGithubApp ? undefined // undefined for GitHub apps, as they are configured source-wide, and don't use a connector where you can edit the configuration - : (staticSourceData.find((source) => source.serviceType === serviceType) as SourceDataItem) - .editPath; + : getEditPath(serviceType); const [inputValue, setValue] = useState(name); const [confirmModalVisible, setModalVisibility] = useState(false); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index 20a0673709b5ac..f99af418364193 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -10,52 +10,13 @@ import { i18n } from '@kbn/i18n'; import { docLinks } from '../../../shared/doc_links'; import { SOURCE_NAMES, SOURCE_OBJ_TYPES, GITHUB_LINK_TITLE } from '../../constants'; -import { - ADD_BOX_PATH, - ADD_CONFLUENCE_PATH, - ADD_CONFLUENCE_SERVER_PATH, - ADD_DROPBOX_PATH, - ADD_GITHUB_ENTERPRISE_PATH, - ADD_GITHUB_PATH, - ADD_GMAIL_PATH, - ADD_GOOGLE_DRIVE_PATH, - ADD_JIRA_PATH, - ADD_JIRA_SERVER_PATH, - ADD_ONEDRIVE_PATH, - ADD_SALESFORCE_PATH, - ADD_SALESFORCE_SANDBOX_PATH, - ADD_SERVICENOW_PATH, - ADD_SHAREPOINT_PATH, - ADD_SLACK_PATH, - ADD_ZENDESK_PATH, - ADD_CUSTOM_PATH, - EDIT_BOX_PATH, - EDIT_CONFLUENCE_PATH, - EDIT_CONFLUENCE_SERVER_PATH, - EDIT_DROPBOX_PATH, - EDIT_GITHUB_ENTERPRISE_PATH, - EDIT_GITHUB_PATH, - EDIT_GMAIL_PATH, - EDIT_GOOGLE_DRIVE_PATH, - EDIT_JIRA_PATH, - EDIT_JIRA_SERVER_PATH, - EDIT_ONEDRIVE_PATH, - EDIT_SALESFORCE_PATH, - EDIT_SALESFORCE_SANDBOX_PATH, - EDIT_SERVICENOW_PATH, - EDIT_SHAREPOINT_PATH, - EDIT_SLACK_PATH, - EDIT_ZENDESK_PATH, - EDIT_CUSTOM_PATH, -} from '../../routes'; import { FeatureIds, SourceDataItem } from '../../types'; -export const staticSourceData = [ +export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.BOX, + iconName: SOURCE_NAMES.BOX, serviceType: 'box', - addPath: ADD_BOX_PATH, - editPath: EDIT_BOX_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -79,12 +40,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.CONFLUENCE, + iconName: SOURCE_NAMES.CONFLUENCE, serviceType: 'confluence_cloud', - addPath: ADD_CONFLUENCE_PATH, - editPath: EDIT_CONFLUENCE_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -113,12 +74,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.CONFLUENCE_SERVER, + iconName: SOURCE_NAMES.CONFLUENCE_SERVER, serviceType: 'confluence_server', - addPath: ADD_CONFLUENCE_SERVER_PATH, - editPath: EDIT_CONFLUENCE_SERVER_PATH, configuration: { isPublicKey: true, hasOauthRedirect: true, @@ -145,12 +106,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.DROPBOX, + iconName: SOURCE_NAMES.DROPBOX, serviceType: 'dropbox', - addPath: ADD_DROPBOX_PATH, - editPath: EDIT_DROPBOX_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -174,12 +135,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GITHUB, + iconName: SOURCE_NAMES.GITHUB, serviceType: 'github', - addPath: ADD_GITHUB_PATH, - editPath: EDIT_GITHUB_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -210,12 +171,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GITHUB_ENTERPRISE, + iconName: SOURCE_NAMES.GITHUB_ENTERPRISE, serviceType: 'github_enterprise_server', - addPath: ADD_GITHUB_ENTERPRISE_PATH, - editPath: EDIT_GITHUB_ENTERPRISE_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -252,12 +213,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GMAIL, + iconName: SOURCE_NAMES.GMAIL, serviceType: 'gmail', - addPath: ADD_GMAIL_PATH, - editPath: EDIT_GMAIL_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -273,9 +234,8 @@ export const staticSourceData = [ }, { name: SOURCE_NAMES.GOOGLE_DRIVE, + iconName: SOURCE_NAMES.GOOGLE_DRIVE, serviceType: 'google_drive', - addPath: ADD_GOOGLE_DRIVE_PATH, - editPath: EDIT_GOOGLE_DRIVE_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -303,12 +263,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.JIRA, + iconName: SOURCE_NAMES.JIRA, serviceType: 'jira_cloud', - addPath: ADD_JIRA_PATH, - editPath: EDIT_JIRA_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -339,12 +299,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.JIRA_SERVER, + iconName: SOURCE_NAMES.JIRA_SERVER, serviceType: 'jira_server', - addPath: ADD_JIRA_SERVER_PATH, - editPath: EDIT_JIRA_SERVER_PATH, configuration: { isPublicKey: true, hasOauthRedirect: true, @@ -374,12 +334,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.ONEDRIVE, + iconName: SOURCE_NAMES.ONEDRIVE, serviceType: 'one_drive', - addPath: ADD_ONEDRIVE_PATH, - editPath: EDIT_ONEDRIVE_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -403,12 +363,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SALESFORCE, + iconName: SOURCE_NAMES.SALESFORCE, serviceType: 'salesforce', - addPath: ADD_SALESFORCE_PATH, - editPath: EDIT_SALESFORCE_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -439,12 +399,13 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, + { name: SOURCE_NAMES.SALESFORCE_SANDBOX, + iconName: SOURCE_NAMES.SALESFORCE_SANDBOX, serviceType: 'salesforce_sandbox', - addPath: ADD_SALESFORCE_SANDBOX_PATH, - editPath: EDIT_SALESFORCE_SANDBOX_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -475,12 +436,12 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SERVICENOW, + iconName: SOURCE_NAMES.SERVICENOW, serviceType: 'service_now', - addPath: ADD_SERVICENOW_PATH, - editPath: EDIT_SERVICENOW_PATH, configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -508,12 +469,44 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SHAREPOINT, + iconName: SOURCE_NAMES.SHAREPOINT, serviceType: 'share_point', - addPath: ADD_SHAREPOINT_PATH, - editPath: EDIT_SHAREPOINT_PATH, + configuration: { + isPublicKey: false, + hasOauthRedirect: true, + needsBaseUrl: false, + documentationUrl: docLinks.workplaceSearchSharePoint, + applicationPortalUrl: 'https://portal.azure.com/', + }, + objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.SITES, SOURCE_OBJ_TYPES.ALL_FILES], + features: { + basicOrgContext: [ + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + FeatureIds.GlobalAccessPermissions, + ], + basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], + platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], + platinumPrivateContext: [ + FeatureIds.Private, + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + ], + }, + + accountContextOnly: false, + internalConnectorAvailable: true, + externalConnectorAvailable: true, + }, + // TODO: temporary hack until backend sends us stuff + { + name: SOURCE_NAMES.SHAREPOINT, + iconName: SOURCE_NAMES.SHAREPOINT, + serviceType: 'external', configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -537,12 +530,54 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, + externalConnectorAvailable: false, + customConnectorAvailable: false, + }, + { + name: SOURCE_NAMES.SHAREPOINT_SERVER, + iconName: SOURCE_NAMES.SHAREPOINT_SERVER, + categories: [ + i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.fileSharing', { + defaultMessage: 'File Sharing', + }), + i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.storage', { + defaultMessage: 'Storage', + }), + i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.cloud', { + defaultMessage: 'Cloud', + }), + i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.microsoft', { + defaultMessage: 'Microsoft', + }), + i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.categories.office', { + defaultMessage: 'Office 365', + }), + ], + serviceType: 'share_point_server', // this doesn't exist on the BE + configuration: { + isPublicKey: false, + hasOauthRedirect: false, + needsBaseUrl: false, + // helpText: i18n.translate( // TODO updatae this + // 'xpack.enterpriseSearch.workplaceSearch.sources.helpText.sharepointServer', + // { + // defaultMessage: + // "Here is some help text. It should probably give the user a heads up that they're going to have to deploy some code.", + // } + // ), + documentationUrl: docLinks.workplaceSearchCustomSources, // TODO update this + applicationPortalUrl: '', + githubRepository: 'elastic/enterprise-search-sharepoint-server-connector', + }, + accountContextOnly: false, + internalConnectorAvailable: false, + customConnectorAvailable: true, }, { name: SOURCE_NAMES.SLACK, + iconName: SOURCE_NAMES.SLACK, serviceType: 'slack', - addPath: ADD_SLACK_PATH, - editPath: EDIT_SLACK_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -559,12 +594,13 @@ export const staticSourceData = [ platinumPrivateContext: [FeatureIds.Remote, FeatureIds.Private, FeatureIds.SearchableContent], }, accountContextOnly: true, + internalConnectorAvailable: true, }, + { name: SOURCE_NAMES.ZENDESK, + iconName: SOURCE_NAMES.ZENDESK, serviceType: 'zendesk', - addPath: ADD_ZENDESK_PATH, - editPath: EDIT_ZENDESK_PATH, configuration: { isPublicKey: false, hasOauthRedirect: true, @@ -588,23 +624,26 @@ export const staticSourceData = [ ], }, accountContextOnly: false, + internalConnectorAvailable: true, }, - { - name: SOURCE_NAMES.CUSTOM, - serviceType: 'custom', - addPath: ADD_CUSTOM_PATH, - editPath: EDIT_CUSTOM_PATH, - configuration: { - isPublicKey: false, - hasOauthRedirect: false, - needsBaseUrl: false, - helpText: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.helpText.custom', { - defaultMessage: - 'To create a Custom API Source, provide a human-readable and descriptive name. The name will appear as-is in the various search experiences and management interfaces.', - }), - documentationUrl: docLinks.workplaceSearchCustomSources, - applicationPortalUrl: '', - }, - accountContextOnly: false, +]; + +export const staticCustomSourceData: SourceDataItem = { + name: SOURCE_NAMES.CUSTOM, + iconName: SOURCE_NAMES.CUSTOM, + categories: ['API', 'Custom'], + serviceType: 'custom', + configuration: { + isPublicKey: false, + hasOauthRedirect: false, + needsBaseUrl: false, + helpText: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.helpText.custom', { + defaultMessage: + 'To create a Custom API Source, provide a human-readable and descriptive name. The name will appear as-is in the various search experiences and management interfaces.', + }), + documentationUrl: docLinks.workplaceSearchCustomSources, + applicationPortalUrl: '', }, -] as SourceDataItem[]; + accountContextOnly: false, + customConnectorAvailable: true, +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts index f7e41f65120174..a007d31ff67cb5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts @@ -18,6 +18,7 @@ jest.mock('../../app_logic', () => ({ import { itShowsServerErrorAsFlashMessage } from '../../../test_helpers'; import { AppLogic } from '../../app_logic'; +import { staticSourceData } from './source_data'; import { SourcesLogic, fetchSourceStatuses, POLLING_INTERVAL } from './sources_logic'; describe('SourcesLogic', () => { @@ -32,8 +33,8 @@ describe('SourcesLogic', () => { const defaultValues = { contentSources: [], privateContentSources: [], - sourceData: [], - availableSources: [], + sourceData: staticSourceData.map((data) => ({ ...data, connected: false })), + availableSources: staticSourceData.map((data) => ({ ...data, connected: false })), configuredSources: [], serviceTypes: [], permissionsModal: null, @@ -316,7 +317,7 @@ describe('SourcesLogic', () => { it('availableSources & configuredSources have correct length', () => { SourcesLogic.actions.onInitializeSources(serverResponse); - expect(SourcesLogic.values.availableSources).toHaveLength(1); + expect(SourcesLogic.values.availableSources).toHaveLength(14); expect(SourcesLogic.values.configuredSources).toHaveLength(5); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts index 90b1f83281e942..b7bdef52fceb00 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts @@ -178,7 +178,7 @@ export const SourcesLogic = kea>( if (isOrganization && !values.serverStatuses) { // We want to get the initial statuses from the server to compare our polling results to. const sourceStatuses = await fetchSourceStatuses(isOrganization, breakpoint); - actions.setServerSourceStatuses(sourceStatuses); + actions.setServerSourceStatuses(sourceStatuses ?? []); } }, // We poll the server and if the status update, we trigger a new fetch of the sources. @@ -190,7 +190,7 @@ export const SourcesLogic = kea>( pollingInterval = window.setInterval(async () => { const sourceStatuses = await fetchSourceStatuses(isOrganization, breakpoint); - sourceStatuses.some((source: ContentSourceStatus) => { + (sourceStatuses ?? []).some((source: ContentSourceStatus) => { if (serverStatuses && serverStatuses[source.id] !== source.status.status) { return actions.initializeSources(); } @@ -249,7 +249,7 @@ export const SourcesLogic = kea>( export const fetchSourceStatuses = async ( isOrganization: boolean, breakpoint: BreakPointFunction -) => { +): Promise => { const route = isOrganization ? '/internal/workplace_search/org/sources/status' : '/internal/workplace_search/account/sources/status'; @@ -267,8 +267,7 @@ export const fetchSourceStatuses = async ( } } - // TODO: remove casting. return type should be ContentSourceStatus[] | undefined - return response as ContentSourceStatus[]; + return response; }; const updateSourcesOnToggle = ( @@ -293,7 +292,7 @@ const updateSourcesOnToggle = ( * The second is the base list of available sources that the server sends back in the collection, * `availableTypes` that is the source of truth for the name and whether the source has been configured. * - * Fnally, also in the collection response is the current set of connected sources. We check for the + * Finally, also in the collection response is the current set of connected sources. We check for the * existence of a `connectedSource` of the type in the loop and set `connected` to true so that the UI * can diplay "Add New" instead of "Connect", the latter of which is displated only when a connector * has been configured but there are no connected sources yet. @@ -304,13 +303,13 @@ export const mergeServerAndStaticData = ( contentSources: ContentSourceDetails[] ) => { const combined = [] as CombinedDataItem[]; - serverData.forEach((serverItem) => { - const type = serverItem.serviceType; - const staticItem = staticData.find(({ serviceType }) => serviceType === type); + staticData.forEach((staticItem) => { + const type = staticItem.serviceType; + const serverItem = serverData.find(({ serviceType }) => serviceType === type); const connectedSource = contentSources.find(({ serviceType }) => serviceType === type); combined.push({ - ...serverItem, ...staticItem, + ...serverItem, connected: !!connectedSource, } as CombinedDataItem); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx index cf5dc48682ae83..49c8ebbbebc08c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx @@ -34,7 +34,7 @@ describe('SourcesRouter', () => { }); it('renders sources routes', () => { - const TOTAL_ROUTES = 63; + const TOTAL_ROUTES = 86; const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); @@ -45,8 +45,8 @@ describe('SourcesRouter', () => { setMockValues({ ...mockValues, hasPlatinumLicense: false }); const wrapper = shallow(); - expect(wrapper.find(Redirect).first().prop('from')).toEqual(ADD_SOURCE_PATH); - expect(wrapper.find(Redirect).first().prop('to')).toEqual(SOURCES_PATH); + expect(wrapper.find(Redirect).last().prop('from')).toEqual(ADD_SOURCE_PATH); + expect(wrapper.find(Redirect).last().prop('to')).toEqual(SOURCES_PATH); }); it('redirects when cannot create sources', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx index 23109506b364ee..c2cd58a90f209d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx @@ -14,19 +14,27 @@ import { useActions, useValues } from 'kea'; import { LicensingLogic } from '../../../shared/licensing'; import { AppLogic } from '../../app_logic'; import { - ADD_GITHUB_VIA_APP_PATH, - ADD_GITHUB_ENTERPRISE_SERVER_VIA_APP_PATH, + GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE, + GITHUB_VIA_APP_SERVICE_TYPE, +} from '../../constants'; +import { ADD_SOURCE_PATH, SOURCE_DETAILS_PATH, PRIVATE_SOURCES_PATH, SOURCES_PATH, getSourcesPath, + getAddPath, + ADD_CUSTOM_PATH, } from '../../routes'; +import { hasMultipleConnectorOptions } from '../../utils'; import { AddSource, AddSourceList, GitHubViaApp } from './components/add_source'; +import { AddCustomSource } from './components/add_source/add_custom_source'; +import { ConfigurationChoice } from './components/add_source/configuration_choice'; +import { ExternalConnectorConfig } from './components/add_source/external_connector_config'; import { OrganizationSources } from './organization_sources'; import { PrivateSources } from './private_sources'; -import { staticSourceData } from './source_data'; +import { staticCustomSourceData, staticSourceData as sources } from './source_data'; import { SourceRouter } from './source_router'; import { SourcesLogic } from './sources_logic'; @@ -68,36 +76,121 @@ export const SourcesRouter: React.FC = () => { - + - + - {staticSourceData.map(({ addPath, accountContextOnly }, i) => ( - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ))} - {staticSourceData.map(({ addPath }, i) => ( - - + {sources.map((sourceData, i) => { + const { serviceType, externalConnectorAvailable, internalConnectorAvailable } = sourceData; + const path = `${getSourcesPath(getAddPath(serviceType), isOrganization)}`; + const defaultOption = internalConnectorAvailable + ? 'internal' + : externalConnectorAvailable + ? 'external' + : 'custom'; + return ( + + {hasMultipleConnectorOptions(sourceData) ? ( + + ) : ( + + )} + + ); + })} + + + + {sources + .filter((sourceData) => sourceData.internalConnectorAvailable) + .map((sourceData, i) => { + const { serviceType, accountContextOnly } = sourceData; + return ( + + {!hasPlatinumLicense && accountContextOnly ? ( + + ) : ( + + )} + + ); + })} + {sources + .filter((sourceData) => sourceData.externalConnectorAvailable) + .map((sourceData, i) => { + const { serviceType, accountContextOnly } = sourceData; + + return ( + + {!hasPlatinumLicense && accountContextOnly ? ( + + ) : ( + + )} + + ); + })} + {sources + .filter((sourceData) => sourceData.customConnectorAvailable) + .map((sourceData, i) => { + const { serviceType, accountContextOnly } = sourceData; + return ( + + {!hasPlatinumLicense && accountContextOnly ? ( + + ) : ( + + )} + + ); + })} + {sources.map((sourceData, i) => ( + + ))} - {staticSourceData.map(({ addPath }, i) => ( - - + {sources.map((sourceData, i) => ( + + ))} - {staticSourceData.map(({ addPath, configuration: { needsConfiguration } }, i) => { - if (needsConfiguration) + {sources.map((sourceData, i) => { + if (sourceData.configuration.needsConfiguration) return ( - - + + ); })} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx index 85f91f769cc77f..be139fd6b38eaf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx @@ -33,9 +33,7 @@ import { PRIVATE_SOURCE, UPDATE_BUTTON, } from '../../../constants'; -import { getSourcesPath } from '../../../routes'; -import { SourceDataItem } from '../../../types'; -import { staticSourceData } from '../../content_sources/source_data'; +import { getAddPath, getEditPath, getSourcesPath } from '../../../routes'; import { SettingsLogic } from '../settings_logic'; export const Connectors: React.FC = () => { @@ -52,9 +50,9 @@ export const Connectors: React.FC = () => { ); const getRowActions = (configured: boolean, serviceType: string, supportedByLicense: boolean) => { - const { addPath, editPath } = staticSourceData.find( - (s) => s.serviceType === serviceType - ) as SourceDataItem; + const addPath = getAddPath(serviceType); + const editPath = getEditPath(serviceType); + const configurePath = getSourcesPath(addPath, true); const updateButtons = ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx index 35619d2b2d560d..af8b8fe461f162 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx @@ -18,6 +18,8 @@ import { EuiConfirmModal } from '@elastic/eui'; import { SaveConfig } from '../../content_sources/components/add_source/save_config'; +import { staticSourceData } from '../../content_sources/source_data'; + import { SourceConfig } from './source_config'; describe('SourceConfig', () => { @@ -31,7 +33,7 @@ describe('SourceConfig', () => { }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -42,13 +44,13 @@ describe('SourceConfig', () => { it('renders a breadcrumb fallback while data is loading', () => { setMockValues({ dataLoading: true, sourceConfigData: {} }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.prop('pageChrome')).toEqual(['Settings', 'Content source connectors', '...']); }); it('handles delete click', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -60,7 +62,7 @@ describe('SourceConfig', () => { }); it('saves source config', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -72,7 +74,7 @@ describe('SourceConfig', () => { }); it('cancels and closes modal', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx index c2a0b60e1eca3d..ea63f3bab77d98 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx @@ -18,16 +18,15 @@ import { SourceDataItem } from '../../../types'; import { AddSourceHeader } from '../../content_sources/components/add_source/add_source_header'; import { AddSourceLogic } from '../../content_sources/components/add_source/add_source_logic'; import { SaveConfig } from '../../content_sources/components/add_source/save_config'; -import { staticSourceData } from '../../content_sources/source_data'; import { SettingsLogic } from '../settings_logic'; interface SourceConfigProps { - sourceIndex: number; + sourceData: SourceDataItem; } -export const SourceConfig: React.FC = ({ sourceIndex }) => { +export const SourceConfig: React.FC = ({ sourceData }) => { const [confirmModalVisible, setConfirmModalVisibility] = useState(false); - const { configuration, serviceType } = staticSourceData[sourceIndex] as SourceDataItem; + const { configuration, serviceType } = sourceData; const { deleteSourceConfig } = useActions(SettingsLogic); const { saveSourceConfig, getSourceConfigData } = useActions(AddSourceLogic); const { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx index d9aeba361d2400..7c5e501d6a2a12 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx @@ -14,6 +14,7 @@ import { ORG_SETTINGS_CUSTOMIZE_PATH, ORG_SETTINGS_CONNECTORS_PATH, ORG_SETTINGS_OAUTH_APPLICATION_PATH, + getEditPath, } from '../../routes'; import { staticSourceData } from '../content_sources/source_data'; @@ -41,9 +42,9 @@ export const SettingsRouter: React.FC = () => { - {staticSourceData.map(({ editPath }, i) => ( - - + {staticSourceData.map((sourceData, i) => ( + + ))} diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index 222288d369fdb6..d1063021282045 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -38,6 +38,14 @@ const oauthConfigSchema = schema.object({ consumer_key: schema.maybe(schema.string()), }); +const externalConnectorSchema = schema.object({ + url: schema.string(), + api_key: schema.string(), + service_type: schema.string(), +}); + +const postConnectorSchema = schema.oneOf([externalConnectorSchema, oauthConfigSchema]); + const displayFieldSchema = schema.object({ fieldName: schema.string(), label: schema.string(), @@ -872,7 +880,7 @@ export function registerOrgSourceOauthConfigurationsRoute({ { path: '/internal/workplace_search/org/settings/connectors', validate: { - body: oauthConfigSchema, + body: postConnectorSchema, }, }, enterpriseSearchRequestHandler.createRequest({ diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index 46cd3e998ea7f4..9c79397e25e103 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -621,6 +621,19 @@ "type" ] } + }, + "_meta": { + "type": "object", + "properties": { + "install_source": { + "type": "string", + "enum": [ + "registry", + "upload", + "bundled" + ] + } + } } }, "required": [ diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index ae8fdb3b87d4d8..1ec0df0e51641b 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -382,6 +382,15 @@ paths: required: - id - type + _meta: + type: object + properties: + install_source: + type: string + enum: + - registry + - upload + - bundled required: - items operationId: install-package diff --git a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml index ef0964b66e045d..6ef61788acd62d 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml @@ -64,6 +64,15 @@ post: required: - id - type + _meta: + type: object + properties: + install_source: + type: string + enum: + - registry + - upload + - bundled required: - items operationId: install-package diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 64ea5665241e13..1c7e09a51c5da7 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -43,7 +43,7 @@ export interface DefaultPackagesInstallationError { } export type InstallType = 'reinstall' | 'reupdate' | 'rollback' | 'update' | 'install' | 'unknown'; -export type InstallSource = 'registry' | 'upload'; +export type InstallSource = 'registry' | 'upload' | 'bundled'; export type EpmPackageInstallStatus = | 'installed' diff --git a/x-pack/plugins/fleet/common/types/rest_spec/epm.ts b/x-pack/plugins/fleet/common/types/rest_spec/epm.ts index 6a72792e780ef5..f1ccaae05487b0 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/epm.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/epm.ts @@ -12,6 +12,7 @@ import type { PackageInfo, PackageUsageStats, InstallType, + InstallSource, } from '../models/epm'; export interface GetCategoriesRequest { @@ -108,6 +109,9 @@ export interface InstallPackageRequest { export interface InstallPackageResponse { items: AssetReference[]; + _meta: { + install_source: InstallSource; + }; // deprecated in 8.0 response?: AssetReference[]; } @@ -123,6 +127,7 @@ export interface InstallResult { status?: 'installed' | 'already_installed'; error?: Error; installType: InstallType; + installSource: InstallSource; } export interface BulkInstallPackageInfo { diff --git a/x-pack/plugins/fleet/server/routes/epm/handlers.ts b/x-pack/plugins/fleet/server/routes/epm/handlers.ts index 9bfcffa04bf35d..7ba2d3f194eeb5 100644 --- a/x-pack/plugins/fleet/server/routes/epm/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/epm/handlers.ts @@ -267,9 +267,13 @@ export const installPackageFromRegistryHandler: FleetRequestHandler< force: request.body?.force, ignoreConstraints: request.body?.ignore_constraints, }); + if (!res.error) { const body: InstallPackageResponse = { items: res.assets || [], + _meta: { + install_source: res.installSource, + }, }; return response.ok({ body }); } else { @@ -342,6 +346,9 @@ export const installPackageByUploadHandler: FleetRequestHandler< const body: InstallPackageResponse = { items: res.assets || [], response: res.assets || [], + _meta: { + install_source: res.installSource, + }, }; return response.ok({ body }); } else { diff --git a/x-pack/plugins/fleet/server/routes/epm/index.ts b/x-pack/plugins/fleet/server/routes/epm/index.ts index 95aadf1b8555ad..544ab8b288cb40 100644 --- a/x-pack/plugins/fleet/server/routes/epm/index.ts +++ b/x-pack/plugins/fleet/server/routes/epm/index.ts @@ -242,7 +242,7 @@ export const registerRoutes = (router: FleetAuthzRouter) => { response ); if (resp.payload?.items) { - return response.ok({ body: { response: resp.payload.items } }); + return response.ok({ body: { ...resp.payload, response: resp.payload.items } }); } return resp; } diff --git a/x-pack/plugins/fleet/server/services/epm/packages/bundled_packages.ts b/x-pack/plugins/fleet/server/services/epm/packages/bundled_packages.ts index 8ccd2006ad846d..77ece9e1d77877 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/bundled_packages.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/bundled_packages.ts @@ -12,7 +12,7 @@ import type { BundledPackage } from '../../../types'; import { appContextService } from '../../app_context'; import { splitPkgKey } from '../registry'; -const BUNDLED_PACKAGE_DIRECTORY = path.join(__dirname, '../../../bundled_packages'); +const BUNDLED_PACKAGE_DIRECTORY = path.join(__dirname, '../../../../target/bundled_packages'); export async function getBundledPackages(): Promise { try { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts index 1a1f1aa617f540..c803b0ff18a44b 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts @@ -83,6 +83,7 @@ describe('install', () => { .mockImplementation(() => Promise.resolve({ packageInfo: { license: 'basic' } } as any)); mockGetBundledPackages.mockReset(); + (install._installPackage as jest.Mock).mockClear(); }); describe('registry', () => { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index 107b906a969c82..23883f90d42480 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -276,6 +276,7 @@ async function installPackageFromRegistry({ ], status: 'already_installed', installType, + installSource: 'registry', }; } } @@ -307,7 +308,7 @@ async function installPackageFromRegistry({ ...telemetryEvent, errorMessage: err.message, }); - return { error: err, installType }; + return { error: err, installType, installSource: 'registry' }; } const savedObjectsImporter = appContextService @@ -338,7 +339,7 @@ async function installPackageFromRegistry({ ...telemetryEvent, status: 'success', }); - return { assets, status: 'installed', installType }; + return { assets, status: 'installed', installType, installSource: 'registry' }; }) .catch(async (err: Error) => { logger.warn(`Failure to install package [${pkgName}]: [${err.toString()}]`); @@ -355,7 +356,7 @@ async function installPackageFromRegistry({ ...telemetryEvent, errorMessage: err.message, }); - return { error: err, installType }; + return { error: err, installType, installSource: 'registry' }; }); } catch (e) { sendEvent({ @@ -365,6 +366,7 @@ async function installPackageFromRegistry({ return { error: e, installType, + installSource: 'registry', }; } } @@ -454,7 +456,7 @@ async function installPackageByUpload({ ...telemetryEvent, errorMessage: e.message, }); - return { error: e, installType }; + return { error: e, installType, installSource: 'upload' }; } } @@ -463,9 +465,10 @@ export type InstallPackageParams = { } & ( | ({ installSource: Extract } & InstallRegistryPackageParams) | ({ installSource: Extract } & InstallUploadedArchiveParams) + | ({ installSource: Extract } & InstallUploadedArchiveParams) ); -export async function installPackage(args: InstallPackageParams) { +export async function installPackage(args: InstallPackageParams): Promise { if (!('installSource' in args)) { throw new Error('installSource is required'); } @@ -487,7 +490,7 @@ export async function installPackage(args: InstallPackageParams) { `found bundled package for requested install of ${pkgkey} - installing from bundled package archive` ); - const response = installPackageByUpload({ + const response = await installPackageByUpload({ savedObjectsClient, esClient, archiveBuffer: matchingBundledPackage.buffer, @@ -495,11 +498,11 @@ export async function installPackage(args: InstallPackageParams) { spaceId, }); - return response; + return { ...response, installSource: 'bundled' }; } logger.debug(`kicking off install of ${pkgkey} from registry`); - const response = installPackageFromRegistry({ + const response = await installPackageFromRegistry({ savedObjectsClient, pkgkey, esClient, @@ -510,7 +513,7 @@ export async function installPackage(args: InstallPackageParams) { return response; } else if (args.installSource === 'upload') { const { archiveBuffer, contentType, spaceId } = args; - const response = installPackageByUpload({ + const response = await installPackageByUpload({ savedObjectsClient, esClient, archiveBuffer, @@ -519,7 +522,6 @@ export async function installPackage(args: InstallPackageParams) { }); return response; } - // @ts-expect-error s/b impossibe b/c `never` by this point, but just in case throw new Error(`Unknown installSource: ${args.installSource}`); } diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index 518b79b9e8547e..2a6e2355808117 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -143,6 +143,7 @@ jest.mock('./epm/packages/install', () => ({ return { error: new Error(installError), installType: 'install', + installSource: 'registry', }; } @@ -157,6 +158,7 @@ jest.mock('./epm/packages/install', () => ({ return { status: 'installed', installType: 'install', + installSource: 'registry', }; } else if (args.installSource === 'upload') { const { archiveBuffer } = args; @@ -168,7 +170,7 @@ jest.mock('./epm/packages/install', () => ({ const packageInstallation = { name: pkgName, version: '1.0.0', title: pkgName }; mockInstalledPackages.set(pkgName, packageInstallation); - return { status: 'installed', installType: 'install' }; + return { status: 'installed', installType: 'install', installSource: 'upload' }; } }, ensurePackagesCompletedInstall() { diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/builder.stories.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/builder.stories.tsx index f0c59cc613c76e..0f842bb53b99e7 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/builder.stories.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/builder.stories.tsx @@ -236,6 +236,7 @@ Default.args = { errorExists: false, exceptionItems: [], exceptionsToDelete: [], + warningExists: false, }), ruleName: 'My awesome rule', }; @@ -288,6 +289,7 @@ SingleExceptionItem.args = { errorExists: false, exceptionItems: [sampleExceptionItem], exceptionsToDelete: [], + warningExists: false, }), ruleName: 'My awesome rule', }; @@ -313,6 +315,7 @@ MultiExceptionItems.args = { errorExists: false, exceptionItems: [sampleExceptionItem, sampleExceptionItem], exceptionsToDelete: [], + warningExists: false, }), ruleName: 'My awesome rule', }; @@ -338,6 +341,7 @@ WithNestedExceptionItem.args = { errorExists: false, exceptionItems: [sampleNestedExceptionItem, sampleExceptionItem], exceptionsToDelete: [], + warningExists: false, }), ruleName: 'My awesome rule', }; diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx index 1ac35608f884a9..aa7071a9074a96 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.test.tsx @@ -18,7 +18,9 @@ import { isNotOperator, isOneOfOperator, isOperator, + matchesOperator, } from '@kbn/securitysolution-list-utils'; +import { validateFilePathInput } from '@kbn/securitysolution-utils'; import { useFindLists } from '@kbn/securitysolution-list-hooks'; import type { FieldSpec } from 'src/plugins/data/common'; @@ -30,6 +32,7 @@ import { getFoundListSchemaMock } from '../../../../common/schemas/response/foun import { BuilderEntryItem } from './entry_renderer'; jest.mock('@kbn/securitysolution-list-hooks'); +jest.mock('@kbn/securitysolution-utils'); const mockKibanaHttpService = coreMock.createStart().http; const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); @@ -74,6 +77,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={true} /> ); @@ -104,6 +108,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -138,6 +143,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -174,6 +180,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -210,6 +217,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -247,6 +255,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={true} /> ); @@ -284,6 +293,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={true} /> ); @@ -320,6 +330,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -357,6 +368,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -414,6 +426,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -456,6 +469,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={mockOnChange} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -496,6 +510,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={mockOnChange} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -536,6 +551,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={mockOnChange} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -576,6 +592,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={mockOnChange} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -616,6 +633,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={mockOnChange} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -662,6 +680,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={mockSetErrorExists} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -701,6 +720,7 @@ describe('BuilderEntryItem', () => { listType="detection" onChange={jest.fn()} setErrorsExist={mockSetErrorExists} + setWarningsExist={jest.fn()} showLabel={false} /> ); @@ -723,6 +743,104 @@ describe('BuilderEntryItem', () => { expect(mockSetErrorExists).toHaveBeenCalledWith(true); }); + test('it invokes "setWarningsExist" when invalid value in field value input', async () => { + const mockSetWarningsExists = jest.fn(); + + (validateFilePathInput as jest.Mock).mockReturnValue('some warning message'); + wrapper = mount( + + ); + + await waitFor(() => { + ( + wrapper.find(EuiComboBox).at(2).props() as unknown as { + onBlur: () => void; + } + ).onBlur(); + + // Invalid input because field is just a string and not a path + ( + wrapper.find(EuiComboBox).at(2).props() as unknown as { + onSearchChange: (arg: string) => void; + } + ).onSearchChange('i243kjhfew'); + }); + + expect(mockSetWarningsExists).toHaveBeenCalledWith(true); + }); + + test('it does not invoke "setWarningsExist" when valid value in field value input', async () => { + const mockSetWarningsExists = jest.fn(); + + (validateFilePathInput as jest.Mock).mockReturnValue(undefined); + wrapper = mount( + + ); + + await waitFor(() => { + ( + wrapper.find(EuiComboBox).at(2).props() as unknown as { + onBlur: () => void; + } + ).onBlur(); + + // valid input as it is a path + ( + wrapper.find(EuiComboBox).at(2).props() as unknown as { + onSearchChange: (arg: string) => void; + } + ).onSearchChange('c:\\path.exe'); + }); + + expect(mockSetWarningsExists).toHaveBeenCalledWith(false); + }); + test('it disabled field inputs correctly when passed "isDisabled=true"', () => { wrapper = mount( { listType="detection" onChange={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} osTypes={['windows']} showLabel={false} isDisabled={true} diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx index 206b1a5dd6f85f..aa24ec6611b975 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx @@ -24,6 +24,7 @@ import { getEntryOnMatchAnyChange, getEntryOnMatchChange, getEntryOnOperatorChange, + getEntryOnWildcardChange, getFilteredIndexPatterns, getOperatorOptions, } from '@kbn/securitysolution-list-utils'; @@ -32,9 +33,11 @@ import { AutocompleteFieldListsComponent, AutocompleteFieldMatchAnyComponent, AutocompleteFieldMatchComponent, + AutocompleteFieldWildcardComponent, FieldComponent, OperatorComponent, } from '@kbn/securitysolution-autocomplete'; +import { OperatingSystem, validateFilePathInput } from '@kbn/securitysolution-utils'; import { DataViewBase, DataViewFieldBase } from '@kbn/es-query'; import type { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; @@ -64,6 +67,7 @@ export interface EntryItemProps { onChange: (arg: BuilderEntry, i: number) => void; onlyShowListOperators?: boolean; setErrorsExist: (arg: boolean) => void; + setWarningsExist: (arg: boolean) => void; isDisabled?: boolean; operatorsList?: OperatorOption[]; } @@ -80,6 +84,7 @@ export const BuilderEntryItem: React.FC = ({ onChange, onlyShowListOperators = false, setErrorsExist, + setWarningsExist, showLabel, isDisabled = false, operatorsList, @@ -90,6 +95,12 @@ export const BuilderEntryItem: React.FC = ({ }, [setErrorsExist] ); + const handleWarning = useCallback( + (warn: boolean): void => { + setWarningsExist(warn); + }, + [setWarningsExist] + ); const handleFieldChange = useCallback( ([newField]: DataViewFieldBase[]): void => { @@ -126,6 +137,15 @@ export const BuilderEntryItem: React.FC = ({ [onChange, entry] ); + const handleFieldWildcardValueChange = useCallback( + (newField: string): void => { + const { updatedEntry, index } = getEntryOnWildcardChange(entry, newField); + + onChange(updatedEntry, index); + }, + [onChange, entry] + ); + const handleFieldListValueChange = useCallback( (newField: ListSchema): void => { const { updatedEntry, index } = getEntryOnListChange(entry, newField); @@ -199,8 +219,17 @@ export const BuilderEntryItem: React.FC = ({ ); const renderOperatorInput = (isFirst: boolean): JSX.Element => { - const operatorOptions = operatorsList - ? operatorsList + // for event filters forms + // show extra operators for wildcards when field is `file.path.text` + const isFilePathTextField = entry.field !== undefined && entry.field.name === 'file.path.text'; + const isEventFilterList = listType === 'endpoint_events'; + const augmentedOperatorsList = + operatorsList && isFilePathTextField && isEventFilterList + ? operatorsList + : operatorsList?.filter((operator) => operator.type !== OperatorTypeEnum.WILDCARD); + + const operatorOptions = augmentedOperatorsList + ? augmentedOperatorsList : onlyShowListOperators ? EXCEPTION_OPERATORS_ONLY_LISTS : getOperatorOptions( @@ -209,6 +238,7 @@ export const BuilderEntryItem: React.FC = ({ entry.field != null && entry.field.type === 'boolean', isFirst && allowLargeValueLists ); + const comboBox = ( = ({ data-test-subj="exceptionBuilderEntryFieldMatchAny" /> ); + case OperatorTypeEnum.WILDCARD: + const wildcardValue = typeof entry.value === 'string' ? entry.value : undefined; + let os: OperatingSystem = OperatingSystem.WINDOWS; + if (osTypes) { + [os] = osTypes as OperatingSystem[]; + } + const warning = validateFilePathInput({ os, value: wildcardValue }); + return ( + + ); case OperatorTypeEnum.LIST: const id = typeof entry.value === 'string' ? entry.value : undefined; return ( diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx index ccda52e2805861..fed24ba428e6c3 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.test.tsx @@ -53,6 +53,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); @@ -84,6 +85,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); @@ -113,6 +115,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); @@ -144,6 +147,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); @@ -182,6 +186,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); @@ -212,6 +217,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); @@ -243,6 +249,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); @@ -272,6 +279,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={jest.fn()} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); @@ -303,6 +311,7 @@ describe('BuilderExceptionListItemComponent', () => { onChangeExceptionItem={jest.fn()} onDeleteExceptionItem={mockOnDeleteExceptionItem} setErrorsExist={jest.fn()} + setWarningsExist={jest.fn()} /> ); diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx index 931a8356e93be3..febfa54a482b2a 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/exception_item_renderer.tsx @@ -59,6 +59,7 @@ interface BuilderExceptionListItemProps { onDeleteExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void; onChangeExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void; setErrorsExist: (arg: boolean) => void; + setWarningsExist: (arg: boolean) => void; onlyShowListOperators?: boolean; isDisabled?: boolean; operatorsList?: OperatorOption[]; @@ -80,6 +81,7 @@ export const BuilderExceptionListItemComponent = React.memo - indexPattern != null && exceptionItem.entries.length > 0 - ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries) - : [], - [exceptionItem.entries, indexPattern] - ); + const entries = useMemo((): FormattedBuilderEntry[] => { + const hasIndexPatternAndEntries = indexPattern != null && exceptionItem.entries.length > 0; + return hasIndexPatternAndEntries + ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries) + : []; + }, [exceptionItem.entries, indexPattern]); return ( @@ -150,6 +150,7 @@ export const BuilderExceptionListItemComponent = React.memo; exceptionsToDelete: ExceptionListItemSchema[]; + warningExists: boolean; } export interface ExceptionBuilderProps { @@ -123,6 +125,7 @@ export const ExceptionBuilderComponent = ({ disableNested, disableOr, errorExists, + warningExists, exceptions, exceptionsToDelete, }, @@ -144,6 +147,16 @@ export const ExceptionBuilderComponent = ({ [dispatch] ); + const setWarningsExist = useCallback( + (hasWarnings: boolean): void => { + dispatch({ + type: 'setWarningsExist', + warningExists: hasWarnings, + }); + }, + [dispatch] + ); + const setUpdateExceptions = useCallback( (items: ExceptionsBuilderExceptionItem[]): void => { dispatch({ @@ -350,8 +363,9 @@ export const ExceptionBuilderComponent = ({ errorExists: errorExists > 0, exceptionItems: filterExceptionItems(exceptions), exceptionsToDelete, + warningExists: warningExists > 0, }); - }, [onChange, exceptionsToDelete, exceptions, errorExists]); + }, [onChange, exceptionsToDelete, exceptions, errorExists, warningExists]); useEffect(() => { setUpdateExceptions([]); @@ -416,6 +430,7 @@ export const ExceptionBuilderComponent = ({ onDeleteExceptionItem={handleDeleteExceptionItem} onlyShowListOperators={containsValueListEntry(exceptions)} setErrorsExist={setErrorsExist} + setWarningsExist={setWarningsExist} osTypes={osTypes} isDisabled={isDisabled} operatorsList={operatorsList} diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/reducer.ts b/x-pack/plugins/lists/public/exceptions/components/builder/reducer.ts index 4ace0c7d31ef8d..ba3b77fb24ed14 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/reducer.ts +++ b/x-pack/plugins/lists/public/exceptions/components/builder/reducer.ts @@ -25,6 +25,7 @@ export interface State { exceptions: ExceptionsBuilderExceptionItem[]; exceptionsToDelete: ExceptionListItemSchema[]; errorExists: number; + warningExists: number; } export type Action = @@ -56,6 +57,10 @@ export type Action = | { type: 'setErrorsExist'; errorExists: boolean; + } + | { + type: 'setWarningsExist'; + warningExists: boolean; }; export const exceptionsBuilderReducer = @@ -128,6 +133,15 @@ export const exceptionsBuilderReducer = errorExists: errTotal < 0 ? 0 : errTotal, }; } + case 'setWarningsExist': { + const { warningExists } = state; + const warnTotal = action.warningExists ? warningExists + 1 : warningExists - 1; + + return { + ...state, + warningExists: warnTotal < 0 ? 0 : warnTotal, + }; + } default: return state; } diff --git a/x-pack/plugins/ml/common/types/annotations.ts b/x-pack/plugins/ml/common/types/annotations.ts index dbc146c1175d88..57b7551c2308a5 100644 --- a/x-pack/plugins/ml/common/types/annotations.ts +++ b/x-pack/plugins/ml/common/types/annotations.ts @@ -6,10 +6,10 @@ */ // The Annotation interface is based on annotation documents stored in the -// `.ml-annotations-6` index, accessed via the `.ml-annotations-[read|write]` aliases. +// `.ml-annotations-*` index, accessed via the `.ml-annotations-[read|write]` aliases. // Annotation document mapping: -// PUT .ml-annotations-6 +// PUT .ml-annotations-000001 // { // "mappings": { // "annotation": { @@ -54,8 +54,8 @@ // POST /_aliases // { // "actions" : [ -// { "add" : { "index" : ".ml-annotations-6", "alias" : ".ml-annotations-read" } }, -// { "add" : { "index" : ".ml-annotations-6", "alias" : ".ml-annotations-write" } } +// { "add" : { "index" : ".ml-annotations-000001", "alias" : ".ml-annotations-read" } }, +// { "add" : { "index" : ".ml-annotations-000001", "alias" : ".ml-annotations-write" } } // ] // } diff --git a/x-pack/plugins/ml/server/models/annotation_service/__mocks__/get_annotations_response.json b/x-pack/plugins/ml/server/models/annotation_service/__mocks__/get_annotations_response.json index a0b8f6b2423196..829b9c6581beaf 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/__mocks__/get_annotations_response.json +++ b/x-pack/plugins/ml/server/models/annotation_service/__mocks__/get_annotations_response.json @@ -15,8 +15,7 @@ "max_score": 0, "hits": [ { - "_index": ".ml-annotations-6", - "_type": "doc", + "_index": ".ml-annotations-000001", "_id": "T-CNvmgBQUJYQVn7TCPA", "_score": 0, "_source": { @@ -32,8 +31,7 @@ } }, { - "_index": ".ml-annotations-6", - "_type": "doc", + "_index": ".ml-annotations-000001", "_id": "3lVpvmgB5xYzd3PM-MSe", "_score": 0, "_source": { diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts index fdeacd148434c8..e6aa31501a557c 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts @@ -41,7 +41,7 @@ describe('annotation_service', () => { const annotationMockId = 'mockId'; const deleteParamsMock: DeleteParams = { - index: '.ml-annotations-6', + index: '.ml-annotations-000001', id: annotationMockId, refresh: 'wait_for', }; diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts index 4717a2ea1ce287..60d633b16097d6 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts @@ -78,6 +78,31 @@ export interface AggByJob { } export function annotationProvider({ asInternalUser }: IScopedClusterClient) { + // Find the index the annotation is stored in. + async function fetchAnnotationIndex(id: string) { + const searchParams: estypes.SearchRequest = { + index: ML_ANNOTATIONS_INDEX_ALIAS_READ, + size: 1, + body: { + query: { + ids: { + values: [id], + }, + }, + }, + }; + + const body = await asInternalUser.search(searchParams); + const totalCount = + typeof body.hits.total === 'number' ? body.hits.total : body.hits.total!.value; + + if (totalCount === 0) { + throw Boom.notFound(`Cannot find annotation with ID ${id}`); + } + + return body.hits.hits[0]._index; + } + async function indexAnnotation(annotation: Annotation, username: string) { if (isAnnotation(annotation) === false) { // No need to translate, this will not be exposed in the UI. @@ -101,6 +126,8 @@ export function annotationProvider({ asInternalUser }: IScopedClusterClient) { if (typeof annotation._id !== 'undefined') { params.id = annotation._id; + params.index = await fetchAnnotationIndex(annotation._id); + params.require_alias = false; delete params.body._id; delete params.body.key; } @@ -387,28 +414,7 @@ export function annotationProvider({ asInternalUser }: IScopedClusterClient) { } async function deleteAnnotation(id: string) { - // Find the index the annotation is stored in. - const searchParams: estypes.SearchRequest = { - index: ML_ANNOTATIONS_INDEX_ALIAS_READ, - size: 1, - body: { - query: { - ids: { - values: [id], - }, - }, - }, - }; - - const body = await asInternalUser.search(searchParams); - const totalCount = - typeof body.hits.total === 'number' ? body.hits.total : body.hits.total!.value; - - if (totalCount === 0) { - throw Boom.notFound(`Cannot find annotation with ID ${id}`); - } - - const index = body.hits.hits[0]._index; + const index = await fetchAnnotationIndex(id); const deleteParams: DeleteParams = { index, diff --git a/x-pack/plugins/monitoring/server/es_client/instantiate_client.ts b/x-pack/plugins/monitoring/server/es_client/instantiate_client.ts index 096c82a9c456ca..605cf463bc7ef6 100644 --- a/x-pack/plugins/monitoring/server/es_client/instantiate_client.ts +++ b/x-pack/plugins/monitoring/server/es_client/instantiate_client.ts @@ -5,7 +5,6 @@ * 2.0. */ import { Logger, ICustomClusterClient, ElasticsearchClientConfig } from 'kibana/server'; -// @ts-ignore import { monitoringBulk } from '../kibana_monitoring/lib/monitoring_bulk'; import { monitoringEndpointDisableWatches } from './monitoring_endpoint_disable_watches'; import { MonitoringElasticsearchConfig } from '../config'; diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/lib/monitoring_bulk.js b/x-pack/plugins/monitoring/server/kibana_monitoring/lib/monitoring_bulk.ts similarity index 91% rename from x-pack/plugins/monitoring/server/kibana_monitoring/lib/monitoring_bulk.js rename to x-pack/plugins/monitoring/server/kibana_monitoring/lib/monitoring_bulk.ts index 6c57da9051b3eb..9e219658439e0c 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/lib/monitoring_bulk.js +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/lib/monitoring_bulk.ts @@ -5,7 +5,9 @@ * 2.0. */ -export function monitoringBulk(Client, _config, components) { +// TODO: Track down where this function is called by the elasticsearch client setup so we can properly type these + +export function monitoringBulk(Client: any, _config: any, components: any) { const ca = components.clientAction.factory; Client.prototype.monitoring = components.clientAction.namespaceFactory(); const monitoring = Client.prototype.monitoring.prototype; diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.ts b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.ts index c4c31f31df6881..ced05dd5ea0201 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.ts +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_from_request.ts @@ -50,7 +50,7 @@ export async function getClustersFromRequest( start, end, codePaths, - }: { clusterUuid: string; start: number; end: number; codePaths: string[] } + }: { clusterUuid?: string; start?: number; end?: number; codePaths: string[] } ) { const { filebeatIndexPattern } = indexPatterns; @@ -96,13 +96,14 @@ export async function getClustersFromRequest( cluster.ml = { jobs: mlJobs }; } - cluster.logs = isInCodePath(codePaths, [CODE_PATH_LOGS]) - ? await getLogTypes(req, filebeatIndexPattern, { - clusterUuid: get(cluster, 'elasticsearch.cluster.id', cluster.cluster_uuid), - start, - end, - }) - : []; + cluster.logs = + start && end && isInCodePath(codePaths, [CODE_PATH_LOGS]) + ? await getLogTypes(req, filebeatIndexPattern, { + clusterUuid: get(cluster, 'elasticsearch.cluster.id', cluster.cluster_uuid), + start, + end, + }) + : []; } else if (!isStandaloneCluster) { // get all clusters if (!clusters || clusters.length === 0) { diff --git a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_stats.ts b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_stats.ts index e9b734d98b70d3..07cb8751d0bd87 100644 --- a/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_stats.ts +++ b/x-pack/plugins/monitoring/server/lib/cluster/get_clusters_stats.ts @@ -26,7 +26,7 @@ import { Globals } from '../../static_globals'; * @param {String} clusterUuid (optional) If not undefined, getClusters will filter for a single cluster * @return {Promise} A promise containing an array of clusters. */ -export function getClustersStats(req: LegacyRequest, clusterUuid: string, ccs?: string) { +export function getClustersStats(req: LegacyRequest, clusterUuid?: string, ccs?: string) { return ( fetchClusterStats(req, clusterUuid, ccs) .then((response) => handleClusterStats(response)) @@ -42,7 +42,7 @@ export function getClustersStats(req: LegacyRequest, clusterUuid: string, ccs?: * @param {String} clusterUuid (optional) - if not undefined, getClusters filters for a single clusterUuid * @return {Promise} Object representing each cluster. */ -function fetchClusterStats(req: LegacyRequest, clusterUuid: string, ccs?: string) { +function fetchClusterStats(req: LegacyRequest, clusterUuid?: string, ccs?: string) { const dataset = 'cluster_stats'; const moduleType = 'elasticsearch'; const indexPattern = getNewIndexPatterns({ diff --git a/x-pack/plugins/monitoring/server/lib/create_query.ts b/x-pack/plugins/monitoring/server/lib/create_query.ts index 051b0ed6b4f9ce..55b96a7d369064 100644 --- a/x-pack/plugins/monitoring/server/lib/create_query.ts +++ b/x-pack/plugins/monitoring/server/lib/create_query.ts @@ -72,7 +72,7 @@ interface CreateQueryOptions { dsDataset?: string; metricset?: string; filters?: any[]; - clusterUuid: string; + clusterUuid?: string; uuid?: string; start?: number; end?: number; diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts index 7673f1b7ff0521..3bd9f6d2265dc1 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_monitoring_auth.ts @@ -17,6 +17,8 @@ import { LegacyRequest } from '../../types'; * * @param req {Object} the server route handler request object */ + +// TODO: replace LegacyRequest with current request object + plugin retrieval export async function verifyMonitoringAuth(req: LegacyRequest) { const xpackInfo = get(req.server.plugins.monitoring, 'info'); @@ -38,6 +40,8 @@ export async function verifyMonitoringAuth(req: LegacyRequest) { * @param req {Object} the server route handler request object * @return {Promise} That either resolves with no response (void) or an exception. */ + +// TODO: replace LegacyRequest with current request object + plugin retrieval async function verifyHasPrivileges(req: LegacyRequest) { const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_ids.ts b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_ids.ts index 7654ed551b63b4..91983186218c9a 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_ids.ts +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_ids.ts @@ -15,7 +15,7 @@ import { Globals } from '../../static_globals'; interface GetLogstashPipelineIdsParams { req: LegacyRequest; - clusterUuid: string; + clusterUuid?: string; size: number; logstashUuid?: string; ccs?: string; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.js b/x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.ts similarity index 100% rename from x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.js rename to x-pack/plugins/monitoring/server/routes/api/v1/alerts/index.ts diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.js b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts similarity index 72% rename from x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.js rename to x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts index 84bea7ba2e8c4b..450872049a3deb 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/check_access.ts @@ -7,17 +7,20 @@ import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; import { handleError } from '../../../../lib/errors'; +import { LegacyRequest, LegacyServer } from '../../../../types'; /* * API for checking read privilege on Monitoring Data * Used for the "Access Denied" page as something to auto-retry with. */ -export function checkAccessRoute(server) { + +// TODO: Replace this LegacyServer call with the "new platform" core Kibana route method +export function checkAccessRoute(server: LegacyServer) { server.route({ method: 'GET', path: '/api/monitoring/v1/check_access', - handler: async (req) => { - const response = {}; + handler: async (req: LegacyRequest) => { + const response: { has_access?: boolean } = {}; try { await verifyMonitoringAuth(req); response.has_access = true; // response data is ignored diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.js b/x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.ts similarity index 100% rename from x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.js rename to x-pack/plugins/monitoring/server/routes/api/v1/check_access/index.ts diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.js b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts similarity index 81% rename from x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.js rename to x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts index 2a1ec03f93db6e..81acd0e53f319f 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/clusters.ts @@ -6,16 +6,19 @@ */ import { schema } from '@kbn/config-schema'; +import { LegacyRequest, LegacyServer } from '../../../../types'; import { getClustersFromRequest } from '../../../../lib/cluster/get_clusters_from_request'; import { verifyMonitoringAuth } from '../../../../lib/elasticsearch/verify_monitoring_auth'; import { handleError } from '../../../../lib/errors'; import { getIndexPatterns } from '../../../../lib/cluster/get_index_patterns'; -export function clustersRoute(server) { +export function clustersRoute(server: LegacyServer) { /* * Monitoring Home * Route Init (for checking license and compatibility for multi-cluster monitoring */ + + // TODO switch from the LegacyServer route() method to the "new platform" route methods server.route({ method: 'POST', path: '/api/monitoring/v1/clusters', @@ -30,7 +33,7 @@ export function clustersRoute(server) { }), }, }, - handler: async (req) => { + handler: async (req: LegacyRequest) => { let clusters = []; const config = server.config; @@ -43,7 +46,7 @@ export function clustersRoute(server) { filebeatIndexPattern: config.ui.logs.index, }); clusters = await getClustersFromRequest(req, indexPatterns, { - codePaths: req.payload.codePaths, + codePaths: req.payload.codePaths as string[], // TODO remove this cast when we can properly type req by using the right route handler }); } catch (err) { throw handleError(err, req); diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.js b/x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.ts similarity index 100% rename from x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.js rename to x-pack/plugins/monitoring/server/routes/api/v1/cluster/index.ts diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.test.ts b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.test.ts new file mode 100644 index 00000000000000..0a700cab50ee61 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.test.ts @@ -0,0 +1,315 @@ +/* + * 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 { left, right } from 'fp-ts/lib/Either'; +import { RuleDataClient, RuleDataClientConstructorOptions, WaitResult } from './rule_data_client'; +import { IndexInfo } from '../rule_data_plugin_service/index_info'; +import { Dataset, RuleDataWriterInitializationError } from '..'; +import { resourceInstallerMock } from '../rule_data_plugin_service/resource_installer.mock'; +import { loggingSystemMock, elasticsearchServiceMock } from 'src/core/server/mocks'; +import { IndexPatternsFetcher } from '../../../../../src/plugins/data/server'; +import { createNoMatchingIndicesError } from '../../../../../src/plugins/data_views/server/fetcher/lib/errors'; + +const mockLogger = loggingSystemMock.create().get(); +const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient().asInternalUser; +const mockResourceInstaller = resourceInstallerMock.create(); + +// Be careful setting this delay too high. Jest tests can time out +const delay = (ms: number = 3000) => new Promise((resolve) => setTimeout(resolve, ms)); + +interface GetRuleDataClientOptionsOpts { + isWriteEnabled?: boolean; + isWriterCacheEnabled?: boolean; + waitUntilReadyForReading?: Promise; + waitUntilReadyForWriting?: Promise; +} +function getRuleDataClientOptions({ + isWriteEnabled, + isWriterCacheEnabled, + waitUntilReadyForReading, + waitUntilReadyForWriting, +}: GetRuleDataClientOptionsOpts): RuleDataClientConstructorOptions { + return { + indexInfo: new IndexInfo({ + indexOptions: { + feature: 'apm', + registrationContext: 'observability.apm', + dataset: 'alerts' as Dataset, + componentTemplateRefs: [], + componentTemplates: [], + }, + kibanaVersion: '8.2.0', + }), + resourceInstaller: mockResourceInstaller, + isWriteEnabled: isWriteEnabled ?? true, + isWriterCacheEnabled: isWriterCacheEnabled ?? true, + waitUntilReadyForReading: + waitUntilReadyForReading ?? Promise.resolve(right(scopedClusterClient) as WaitResult), + waitUntilReadyForWriting: + waitUntilReadyForWriting ?? Promise.resolve(right(scopedClusterClient) as WaitResult), + logger: mockLogger, + }; +} + +describe('RuleDataClient', () => { + const getFieldsForWildcardMock = jest.fn(); + + test('options are set correctly in constructor', () => { + const namespace = 'test'; + const ruleDataClient = new RuleDataClient(getRuleDataClientOptions({})); + expect(ruleDataClient.indexName).toEqual(`.alerts-observability.apm.alerts`); + expect(ruleDataClient.kibanaVersion).toEqual('8.2.0'); + expect(ruleDataClient.indexNameWithNamespace(namespace)).toEqual( + `.alerts-observability.apm.alerts-${namespace}` + ); + expect(ruleDataClient.isWriteEnabled()).toEqual(true); + }); + + describe('getReader()', () => { + beforeAll(() => { + getFieldsForWildcardMock.mockResolvedValue(['foo']); + IndexPatternsFetcher.prototype.getFieldsForWildcard = getFieldsForWildcardMock; + }); + + beforeEach(() => { + getFieldsForWildcardMock.mockClear(); + }); + + afterAll(() => { + getFieldsForWildcardMock.mockRestore(); + }); + + test('waits until cluster client is ready before searching', async () => { + const ruleDataClient = new RuleDataClient( + getRuleDataClientOptions({ + waitUntilReadyForReading: new Promise((resolve) => + setTimeout(resolve, 3000, right(scopedClusterClient)) + ), + }) + ); + + const query = { query: { bool: { filter: { range: { '@timestamp': { gte: 0 } } } } } }; + const reader = ruleDataClient.getReader(); + await reader.search({ + body: query, + }); + + expect(scopedClusterClient.search).toHaveBeenCalledWith({ + body: query, + index: `.alerts-observability.apm.alerts*`, + }); + }); + + test('re-throws error when search throws error', async () => { + scopedClusterClient.search.mockRejectedValueOnce(new Error('something went wrong!')); + const ruleDataClient = new RuleDataClient(getRuleDataClientOptions({})); + const query = { query: { bool: { filter: { range: { '@timestamp': { gte: 0 } } } } } }; + const reader = ruleDataClient.getReader(); + + await expect( + reader.search({ + body: query, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"something went wrong!"`); + }); + + test('waits until cluster client is ready before getDynamicIndexPattern', async () => { + const ruleDataClient = new RuleDataClient( + getRuleDataClientOptions({ + waitUntilReadyForReading: new Promise((resolve) => + setTimeout(resolve, 3000, right(scopedClusterClient)) + ), + }) + ); + + const reader = ruleDataClient.getReader(); + expect(await reader.getDynamicIndexPattern()).toEqual({ + fields: ['foo'], + timeFieldName: '@timestamp', + title: '.alerts-observability.apm.alerts*', + }); + }); + + test('re-throws generic errors from getFieldsForWildcard', async () => { + getFieldsForWildcardMock.mockRejectedValueOnce(new Error('something went wrong!')); + const ruleDataClient = new RuleDataClient(getRuleDataClientOptions({})); + const reader = ruleDataClient.getReader(); + + await expect(reader.getDynamicIndexPattern()).rejects.toThrowErrorMatchingInlineSnapshot( + `"something went wrong!"` + ); + }); + + test('correct handles no_matching_indices errors from getFieldsForWildcard', async () => { + getFieldsForWildcardMock.mockRejectedValueOnce(createNoMatchingIndicesError([])); + const ruleDataClient = new RuleDataClient(getRuleDataClientOptions({})); + const reader = ruleDataClient.getReader(); + + expect(await reader.getDynamicIndexPattern()).toEqual({ + fields: [], + timeFieldName: '@timestamp', + title: '.alerts-observability.apm.alerts*', + }); + }); + + test('handles errors getting cluster client', async () => { + const ruleDataClient = new RuleDataClient( + getRuleDataClientOptions({ + waitUntilReadyForReading: Promise.resolve( + left(new Error('could not get cluster client')) + ), + }) + ); + + const query = { query: { bool: { filter: { range: { '@timestamp': { gte: 0 } } } } } }; + const reader = ruleDataClient.getReader(); + await expect( + reader.search({ + body: query, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"could not get cluster client"`); + + await expect(reader.getDynamicIndexPattern()).rejects.toThrowErrorMatchingInlineSnapshot( + `"could not get cluster client"` + ); + }); + }); + + describe('getWriter()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('bulk()', () => { + test('logs debug and returns undefined if writing is disabled', async () => { + const ruleDataClient = new RuleDataClient( + getRuleDataClientOptions({ isWriteEnabled: false }) + ); + const writer = ruleDataClient.getWriter(); + + // Previously, a delay between calling getWriter() and using a writer function + // would cause an Unhandled promise rejection if there were any errors getting a writer + // Adding this delay in the tests to ensure this does not pop up again. + await delay(); + + expect(await writer.bulk({})).toEqual(undefined); + expect(mockLogger.debug).toHaveBeenCalledWith( + `Writing is disabled, bulk() will not write any data.` + ); + }); + + test('logs error, returns undefined and turns off writing if initialization error', async () => { + const ruleDataClient = new RuleDataClient( + getRuleDataClientOptions({ + waitUntilReadyForWriting: Promise.resolve( + left(new Error('could not get cluster client')) + ), + }) + ); + expect(ruleDataClient.isWriteEnabled()).toBe(true); + const writer = ruleDataClient.getWriter(); + + // Previously, a delay between calling getWriter() and using a writer function + // would cause an Unhandled promise rejection if there were any errors getting a writer + // Adding this delay in the tests to ensure this does not pop up again. + await delay(); + + expect(await writer.bulk({})).toEqual(undefined); + expect(mockLogger.error).toHaveBeenNthCalledWith( + 1, + new RuleDataWriterInitializationError( + 'index', + 'observability.apm', + new Error('could not get cluster client') + ) + ); + expect(mockLogger.error).toHaveBeenNthCalledWith( + 2, + `The writer for the Rule Data Client for the observability.apm registration context was not initialized properly, bulk() cannot continue, and writing will be disabled.` + ); + expect(ruleDataClient.isWriteEnabled()).toBe(false); + }); + + test('logs error, returns undefined and turns off writing if resource installation error', async () => { + const error = new Error('bad resource installation'); + mockResourceInstaller.installAndUpdateNamespaceLevelResources.mockRejectedValueOnce(error); + const ruleDataClient = new RuleDataClient(getRuleDataClientOptions({})); + expect(ruleDataClient.isWriteEnabled()).toBe(true); + const writer = ruleDataClient.getWriter(); + + // Previously, a delay between calling getWriter() and using a writer function + // would cause an Unhandled promise rejection if there were any errors getting a writer + // Adding this delay in the tests to ensure this does not pop up again. + await delay(); + + expect(await writer.bulk({})).toEqual(undefined); + expect(mockLogger.error).toHaveBeenNthCalledWith( + 1, + new RuleDataWriterInitializationError('namespace', 'observability.apm', error) + ); + expect(mockLogger.error).toHaveBeenNthCalledWith( + 2, + `The writer for the Rule Data Client for the observability.apm registration context was not initialized properly, bulk() cannot continue, and writing will be disabled.` + ); + expect(ruleDataClient.isWriteEnabled()).toBe(false); + }); + + test('logs error and returns undefined if bulk function throws error', async () => { + const error = new Error('something went wrong!'); + scopedClusterClient.bulk.mockRejectedValueOnce(error); + const ruleDataClient = new RuleDataClient(getRuleDataClientOptions({})); + expect(ruleDataClient.isWriteEnabled()).toBe(true); + const writer = ruleDataClient.getWriter(); + + // Previously, a delay between calling getWriter() and using a writer function + // would cause an Unhandled promise rejection if there were any errors getting a writer + // Adding this delay in the tests to ensure this does not pop up again. + await delay(); + + expect(await writer.bulk({})).toEqual(undefined); + expect(mockLogger.error).toHaveBeenNthCalledWith(1, error); + expect(ruleDataClient.isWriteEnabled()).toBe(true); + }); + + test('waits until cluster client is ready before calling bulk', async () => { + const ruleDataClient = new RuleDataClient( + getRuleDataClientOptions({ + waitUntilReadyForWriting: new Promise((resolve) => + setTimeout(resolve, 3000, right(scopedClusterClient)) + ), + }) + ); + + const writer = ruleDataClient.getWriter(); + // Previously, a delay between calling getWriter() and using a writer function + // would cause an Unhandled promise rejection if there were any errors getting a writer + // Adding this delay in the tests to ensure this does not pop up again. + await delay(); + + const response = await writer.bulk({}); + + expect(response).toEqual({ + body: {}, + headers: { + 'x-elastic-product': 'Elasticsearch', + }, + meta: {}, + statusCode: 200, + warnings: [], + }); + + expect(scopedClusterClient.bulk).toHaveBeenCalledWith( + { + index: `.alerts-observability.apm.alerts-default`, + require_alias: true, + }, + { meta: true } + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.ts b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.ts index 491c9ff22d21f6..6fe9d43ddbee01 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/rule_data_client.ts @@ -18,12 +18,12 @@ import { RuleDataWriterInitializationError, } from '../rule_data_plugin_service/errors'; import { IndexInfo } from '../rule_data_plugin_service/index_info'; -import { ResourceInstaller } from '../rule_data_plugin_service/resource_installer'; +import { IResourceInstaller } from '../rule_data_plugin_service/resource_installer'; import { IRuleDataClient, IRuleDataReader, IRuleDataWriter } from './types'; -interface ConstructorOptions { +export interface RuleDataClientConstructorOptions { indexInfo: IndexInfo; - resourceInstaller: ResourceInstaller; + resourceInstaller: IResourceInstaller; isWriteEnabled: boolean; isWriterCacheEnabled: boolean; waitUntilReadyForReading: Promise; @@ -40,7 +40,7 @@ export class RuleDataClient implements IRuleDataClient { // Writers cached by namespace private writerCache: Map; - constructor(private readonly options: ConstructorOptions) { + constructor(private readonly options: RuleDataClientConstructorOptions) { this.writeEnabled = this.options.isWriteEnabled; this.writerCacheEnabled = this.options.isWriterCacheEnabled; this.writerCache = new Map(); @@ -181,43 +181,46 @@ export class RuleDataClient implements IRuleDataClient { } }; - const prepareForWritingResult = prepareForWriting(); + const prepareForWritingResult = prepareForWriting().catch((error) => { + if (error instanceof RuleDataWriterInitializationError) { + this.options.logger.error(error); + this.options.logger.error( + `The writer for the Rule Data Client for the ${indexInfo.indexOptions.registrationContext} registration context was not initialized properly, bulk() cannot continue, and writing will be disabled.` + ); + turnOffWrite(); + } else if (error instanceof RuleDataWriteDisabledError) { + this.options.logger.debug(`Writing is disabled, bulk() will not write any data.`); + } + return undefined; + }); return { bulk: async (request: estypes.BulkRequest) => { - return prepareForWritingResult - .then((clusterClient) => { + try { + const clusterClient = await prepareForWritingResult; + if (clusterClient) { const requestWithDefaultParameters = { ...request, require_alias: true, index: alias, }; - return clusterClient - .bulk(requestWithDefaultParameters, { meta: true }) - .then((response) => { - if (response.body.errors) { - const error = new errors.ResponseError(response); - this.options.logger.error(error); - } - return response; - }); - }) - .catch((error) => { - if (error instanceof RuleDataWriterInitializationError) { - this.options.logger.error(error); - this.options.logger.error( - `The writer for the Rule Data Client for the ${indexInfo.indexOptions.registrationContext} registration context was not initialized properly, bulk() cannot continue, and writing will be disabled.` - ); - turnOffWrite(); - } else if (error instanceof RuleDataWriteDisabledError) { - this.options.logger.debug(`Writing is disabled, bulk() will not write any data.`); - } else { + const response = await clusterClient.bulk(requestWithDefaultParameters, { meta: true }); + + if (response.body.errors) { + const error = new errors.ResponseError(response); this.options.logger.error(error); } + return response; + } else { + this.options.logger.debug(`Writing is disabled, bulk() will not write any data.`); + } + return undefined; + } catch (error) { + this.options.logger.error(error); - return undefined; - }); + return undefined; + } }, }; } diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.mock.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.mock.ts index 0b3940b936424e..6e84f569d481c1 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.mock.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.mock.ts @@ -5,11 +5,11 @@ * 2.0. */ import type { PublicMethodsOf } from '@kbn/utility-types'; -import { ResourceInstaller } from './resource_installer'; +import { IResourceInstaller, ResourceInstaller } from './resource_installer'; type Schema = PublicMethodsOf; export type ResourceInstallerMock = jest.Mocked; -const createResourceInstallerMock = () => { +const createResourceInstallerMock = (): jest.Mocked => { return { installCommonResources: jest.fn(), installIndexLevelResources: jest.fn(), diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts index 8e7d13b0dc210d..ab7bc28af8c137 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/resource_installer.ts @@ -10,6 +10,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { ElasticsearchClient, Logger } from 'kibana/server'; +import { PublicMethodsOf } from '@kbn/utility-types'; import { DEFAULT_ILM_POLICY_ID, ECS_COMPONENT_TEMPLATE_NAME, @@ -31,6 +32,7 @@ interface ConstructorOptions { disabledRegistrationContexts: string[]; } +export type IResourceInstaller = PublicMethodsOf; export class ResourceInstaller { constructor(private readonly options: ConstructorOptions) {} diff --git a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts index 126be5c6d2972a..b916091510319c 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_plugin_service/rule_data_plugin_service.ts @@ -14,7 +14,7 @@ import { INDEX_PREFIX } from '../config'; import { IRuleDataClient, RuleDataClient, WaitResult } from '../rule_data_client'; import { IndexInfo } from './index_info'; import { Dataset, IndexOptions } from './index_options'; -import { ResourceInstaller } from './resource_installer'; +import { IResourceInstaller, ResourceInstaller } from './resource_installer'; import { joinWithDash } from './utils'; /** @@ -89,7 +89,7 @@ interface ConstructorOptions { export class RuleDataService implements IRuleDataService { private readonly indicesByBaseName: Map; private readonly indicesByFeatureId: Map; - private readonly resourceInstaller: ResourceInstaller; + private readonly resourceInstaller: IResourceInstaller; private installCommonResources: Promise>; private isInitialized: boolean; diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts index 3593030913ba74..dbd91164987005 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts @@ -112,6 +112,7 @@ function createRule(shouldWriteAlerts: boolean = true) { services: { alertFactory, savedObjectsClient: {} as any, + uiSettingsClient: {} as any, scopedClusterClient: {} as any, shouldWriteAlerts: () => shouldWriteAlerts, shouldStopExecution: () => false, diff --git a/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts b/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts index 3d880988182b12..38843d95d6b730 100644 --- a/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts +++ b/x-pack/plugins/rule_registry/server/utils/rule_executor_test_utils.ts @@ -7,6 +7,7 @@ import { elasticsearchServiceMock, savedObjectsClientMock, + uiSettingsServiceMock, } from '../../../../../src/core/server/mocks'; import { AlertExecutorOptions, @@ -69,6 +70,7 @@ export const createDefaultAlertExecutorOptions = < services: { alertFactory: alertsMock.createAlertServices().alertFactory, savedObjectsClient: savedObjectsClientMock.create(), + uiSettingsClient: uiSettingsServiceMock.createClient(), scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), shouldWriteAlerts: () => shouldWriteAlerts, shouldStopExecution: () => false, diff --git a/x-pack/plugins/screenshotting/server/layouts/preserve_layout.css b/x-pack/plugins/screenshotting/server/layouts/preserve_layout.css index 60513c417165f0..7b692881d5bdee 100644 --- a/x-pack/plugins/screenshotting/server/layouts/preserve_layout.css +++ b/x-pack/plugins/screenshotting/server/layouts/preserve_layout.css @@ -1,12 +1,5 @@ -/* - ****** - ****** This is a collection of CSS overrides that make Kibana look better for - ****** generating PDF reports with headless browser - ****** - */ - /** - * global + * Global utilities */ /* elements can hide themselves when shared */ @@ -14,26 +7,9 @@ display: none !important; } -/* hide unusable controls */ -kbn-top-nav, -filter-bar, -.kbnTopNavMenu__wrapper, -::-webkit-scrollbar, -.euiNavDrawer { - display: none !important; -} - /** - * Discover Tweaks - */ - -/* hide unusable controls */ -discover-app .dscTimechart, -discover-app .dscSidebar__container, -discover-app .dscCollapsibleSidebar__collapseButton, -discover-app .discover-table-footer { - display: none; -} +* Global overrides +*/ /** * The global banner (e.g. "Help us improve Elastic...") should not print. @@ -41,53 +17,3 @@ discover-app .discover-table-footer { #globalBannerList { display: none; } - -/** - * Visualize Editor Tweaks - */ - -/* hide unusable controls -* !important is required to override resizable panel inline display */ -.visEditor__content .visEditor--default > :not(.visEditor__visualization__wrapper) { - display: none !important; -} - -/** THIS IS FOR TSVB UNTIL REFACTOR **/ -.tvbEditorVisualization { - position: static !important; -} -.visualize .tvbVisTimeSeries__legendToggle, -.tvbEditor--hideForReporting { - /* all non-content rows in interface */ - display: none; -} -/** END TSVB BAD BAD HACKS **/ - -/* remove left padding from visualizations so that map lines up with .leaflet-container and -* setting the position to be fixed and to take up the entire screen, because some zoom levels/viewports -* are triggering the media breakpoints that cause the .visEditor__canvas to take up more room than the viewport */ -.visEditor .visEditor__canvas { - padding-left: 0px; - position: fixed; - width: 100%; - height: 100%; - top: 0; - left: 0; -} - -/** - * Visualization tweaks - */ - -/* hide unusable controls */ -.visualize .visLegend__toggle, -.visualize .kbnAggTable__controls/* export raw, export formatted, etc. */ , -.visualize .leaflet-container .leaflet-top.leaflet-left/* tilemap controls */ , -.visualize paginate-controls /* page numbers */ { - display: none; -} - -/* Ensure the min-height of the small breakpoint isn't used */ -.vis-editor visualization { - min-height: 0 !important; -} 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 6fbe54578f4692..6ddb2fc19ef07e 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 @@ -118,7 +118,7 @@ export class BaseDataGenerator { } /** generate random OS family value */ - protected randomOSFamily(): string { + public randomOSFamily(): string { return this.randomChoice(OS_FAMILY); } @@ -133,7 +133,7 @@ export class BaseDataGenerator { } /** Generate a random number up to the max provided */ - protected randomN(max: number): number { + public randomN(max: number): number { return Math.floor(this.random() * max); } diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/event_filter_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/event_filter_generator.ts index daf96a3149649a..99683bcd11868c 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/event_filter_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/event_filter_generator.ts @@ -5,7 +5,10 @@ * 2.0. */ -import type { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { + CreateExceptionListItemSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; import { ENDPOINT_EVENT_FILTERS_LIST_ID } from '@kbn/securitysolution-list-constants'; import { BaseDataGenerator } from './base_data_generator'; import { ExceptionsListItemGenerator } from './exceptions_list_item_generator'; @@ -13,7 +16,7 @@ import { BY_POLICY_ARTIFACT_TAG_PREFIX, GLOBAL_ARTIFACT_TAG } from '../service/a const EFFECT_SCOPE_TYPES = [BY_POLICY_ARTIFACT_TAG_PREFIX, GLOBAL_ARTIFACT_TAG]; export class EventFilterGenerator extends BaseDataGenerator { - generate(): CreateExceptionListItemSchema { + generate(overrides: Partial = {}): CreateExceptionListItemSchema { const eventFilterGenerator = new ExceptionsListItemGenerator(); const eventFilterData: CreateExceptionListItemSchema = eventFilterGenerator.generateEventFilter( { @@ -29,6 +32,7 @@ export class EventFilterGenerator extends BaseDataGenerator { describe('for GET List', () => { diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index 4b04f15682777a..88ac65768e163b 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -6,7 +6,8 @@ */ import { schema } from '@kbn/config-schema'; -import { ConditionEntry, ConditionEntryField, OperatingSystem } from '../types'; +import { ConditionEntryField, OperatingSystem } from '@kbn/securitysolution-utils'; +import { ConditionEntry } from '../types'; import { getDuplicateFields, isValidHash } from '../service/trusted_apps/validations'; export const DeleteTrustedAppsRequestSchema = { diff --git a/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts b/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts index 13054a231a4502..a818e4d56d5b6f 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/artifacts/constants.ts @@ -9,6 +9,7 @@ import { ENDPOINT_TRUSTED_APPS_LIST_ID, ENDPOINT_EVENT_FILTERS_LIST_ID, ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + ENDPOINT_BLOCKLISTS_LIST_ID, } from '@kbn/securitysolution-list-constants'; export const BY_POLICY_ARTIFACT_TAG_PREFIX = 'policy:'; @@ -19,6 +20,7 @@ export const ALL_ENDPOINT_ARTIFACT_LIST_IDS: readonly string[] = [ ENDPOINT_TRUSTED_APPS_LIST_ID, ENDPOINT_EVENT_FILTERS_LIST_ID, ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + ENDPOINT_BLOCKLISTS_LIST_ID, ]; export const DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS: Readonly = [ diff --git a/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts index 0e6f2a5a7df418..2d2c50572a8bc7 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/trusted_apps/validations.ts @@ -5,12 +5,8 @@ * 2.0. */ -import { - ConditionEntry, - ConditionEntryField, - OperatingSystem, - TrustedAppEntryTypes, -} from '../../types'; +import { ConditionEntryField } from '@kbn/securitysolution-utils'; +import { ConditionEntry } from '../../types'; const HASH_LENGTHS: readonly number[] = [ 32, // MD5 @@ -37,118 +33,3 @@ export const getDuplicateFields = (entries: ConditionEntry[]) => { .filter((entry) => entry[1].length > 1) .map((entry) => entry[0]); }; - -/* - * regex to match executable names - * starts matching from the eol of the path - * file names with a single or multiple spaces (for spaced names) - * and hyphens and combinations of these that produce complex names - * such as: - * c:\home\lib\dmp.dmp - * c:\home\lib\my-binary-app-+/ some/ x/ dmp.dmp - * /home/lib/dmp.dmp - * /home/lib/my-binary-app+-\ some\ x\ dmp.dmp - */ -const WIN_EXEC_PATH = /\\(\w+|\w*[\w+|-]+\/ +)+\w+[\w+|-]+\.*\w+$/i; -const UNIX_EXEC_PATH = /(\/|\w*[\w+|-]+\\ +)+\w+[\w+|-]+\.*\w*$/i; - -export const hasSimpleExecutableName = ({ - os, - type, - value, -}: { - os: OperatingSystem; - type: TrustedAppEntryTypes; - value: string; -}): boolean => { - if (type === 'wildcard') { - return os === OperatingSystem.WINDOWS ? WIN_EXEC_PATH.test(value) : UNIX_EXEC_PATH.test(value); - } - return true; -}; - -export const isPathValid = ({ - os, - field, - type, - value, -}: { - os: OperatingSystem; - field: ConditionEntryField; - type: TrustedAppEntryTypes; - value: string; -}): boolean => { - if (field === ConditionEntryField.PATH) { - if (type === 'wildcard') { - return os === OperatingSystem.WINDOWS - ? isWindowsWildcardPathValid(value) - : isLinuxMacWildcardPathValid(value); - } - return doesPathMatchRegex({ value, os }); - } - return true; -}; - -const doesPathMatchRegex = ({ os, value }: { os: OperatingSystem; value: string }): boolean => { - if (os === OperatingSystem.WINDOWS) { - const filePathRegex = - /^[a-z]:(?:|\\\\[^<>:"'/\\|?*]+\\[^<>:"'/\\|?*]+|%\w+%|)[\\](?:[^<>:"'/\\|?*]+[\\/])*([^<>:"'/\\|?*])+$/i; - return filePathRegex.test(value); - } - return /^(\/|(\/[\w\-]+)+|\/[\w\-]+\.[\w]+|(\/[\w-]+)+\/[\w\-]+\.[\w]+)$/i.test(value); -}; - -const isWindowsWildcardPathValid = (path: string): boolean => { - const firstCharacter = path[0]; - const lastCharacter = path.slice(-1); - const trimmedValue = path.trim(); - const hasSlash = /\//.test(trimmedValue); - if (path.length === 0) { - return false; - } else if ( - hasSlash || - trimmedValue.length !== path.length || - firstCharacter === '^' || - lastCharacter === '\\' || - !hasWildcard({ path, isWindowsPath: true }) - ) { - return false; - } else { - return true; - } -}; - -const isLinuxMacWildcardPathValid = (path: string): boolean => { - const firstCharacter = path[0]; - const lastCharacter = path.slice(-1); - const trimmedValue = path.trim(); - if (path.length === 0) { - return false; - } else if ( - trimmedValue.length !== path.length || - firstCharacter !== '/' || - lastCharacter === '/' || - path.length > 1024 === true || - path.includes('//') === true || - !hasWildcard({ path, isWindowsPath: false }) - ) { - return false; - } else { - return true; - } -}; - -const hasWildcard = ({ - path, - isWindowsPath, -}: { - path: string; - isWindowsPath: boolean; -}): boolean => { - for (const pathComponent of path.split(isWindowsPath ? '\\' : '/')) { - if (/[\*|\?]+/.test(pathComponent) === true) { - return true; - } - } - return false; -}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/os.ts b/x-pack/plugins/security_solution/common/endpoint/types/os.ts index f892d077a9ed88..af73dcd91ae8d4 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/os.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/os.ts @@ -5,12 +5,6 @@ * 2.0. */ -export enum OperatingSystem { - LINUX = 'linux', - MAC = 'macos', - WINDOWS = 'windows', -} - // PolicyConfig uses mac instead of macos export enum PolicyOperatingSystem { windows = 'windows', diff --git a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts index 9815bc3535de46..3872df8d102474 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -6,7 +6,11 @@ */ import { TypeOf } from '@kbn/config-schema'; - +import { + ConditionEntryField, + OperatingSystem, + TrustedAppEntryTypes, +} from '@kbn/securitysolution-utils'; import { DeleteTrustedAppsRequestSchema, GetOneTrustedAppRequestSchema, @@ -15,7 +19,6 @@ import { PutTrustedAppUpdateRequestSchema, GetTrustedAppsSummaryRequestSchema, } from '../schema/trusted_apps'; -import { OperatingSystem } from './os'; /** API request params for deleting Trusted App entry */ export type DeleteTrustedAppsRequestParams = TypeOf; @@ -67,18 +70,11 @@ export interface GetTrustedAppsSummaryResponse { linux: number; } -export enum ConditionEntryField { - HASH = 'process.hash.*', - PATH = 'process.executable.caseless', - SIGNER = 'process.Ext.code_signature', -} - export enum OperatorFieldIds { is = 'is', matches = 'matches', } -export type TrustedAppEntryTypes = 'match' | 'wildcard'; export interface ConditionEntry { field: T; type: TrustedAppEntryTypes; diff --git a/x-pack/plugins/security_solution/common/utils/path_placeholder.test.ts b/x-pack/plugins/security_solution/common/utils/path_placeholder.test.ts index 9618440c105dc4..58f9fa70f73955 100644 --- a/x-pack/plugins/security_solution/common/utils/path_placeholder.test.ts +++ b/x-pack/plugins/security_solution/common/utils/path_placeholder.test.ts @@ -6,7 +6,11 @@ */ import { getPlaceholderTextByOSType, getPlaceholderText } from './path_placeholder'; -import { ConditionEntryField, OperatingSystem, TrustedAppEntryTypes } from '../endpoint/types'; +import { + ConditionEntryField, + OperatingSystem, + TrustedAppEntryTypes, +} from '@kbn/securitysolution-utils'; const trustedAppEntry = { os: OperatingSystem.LINUX, diff --git a/x-pack/plugins/security_solution/common/utils/path_placeholder.ts b/x-pack/plugins/security_solution/common/utils/path_placeholder.ts index baa9b71cd4483b..328df398dd5760 100644 --- a/x-pack/plugins/security_solution/common/utils/path_placeholder.ts +++ b/x-pack/plugins/security_solution/common/utils/path_placeholder.ts @@ -4,8 +4,11 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { ConditionEntryField, OperatingSystem, TrustedAppEntryTypes } from '../endpoint/types'; +import { + ConditionEntryField, + OperatingSystem, + TrustedAppEntryTypes, +} from '@kbn/securitysolution-utils'; export const getPlaceholderText = () => ({ windows: { diff --git a/x-pack/plugins/security_solution/public/management/common/translations.ts b/x-pack/plugins/security_solution/public/management/common/translations.ts index 8e6fcd4cc951e1..e79c1c0b344967 100644 --- a/x-pack/plugins/security_solution/public/management/common/translations.ts +++ b/x-pack/plugins/security_solution/public/management/common/translations.ts @@ -6,10 +6,9 @@ */ import { i18n } from '@kbn/i18n'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { ServerApiError } from '../../common/types'; -import { OperatingSystem } from '../../../common/endpoint/types'; - export const ENDPOINTS_TAB = i18n.translate('xpack.securitySolution.endpointsTab', { defaultMessage: 'Endpoints', }); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/criteria_conditions.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/criteria_conditions.tsx index 238fe87c058904..9b656a97a94a0b 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/criteria_conditions.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/criteria_conditions.tsx @@ -22,7 +22,7 @@ import { OS_MAC, OS_WINDOWS, CONDITION_AND, - CONDITION_OPERATOR_TYPE_WILDCARD, + CONDITION_OPERATOR_TYPE_WILDCARD_MATCHES, CONDITION_OPERATOR_TYPE_NESTED, CONDITION_OPERATOR_TYPE_MATCH, CONDITION_OPERATOR_TYPE_MATCH_ANY, @@ -45,7 +45,7 @@ const OPERATOR_TYPE_LABELS_INCLUDED = Object.freeze({ [ListOperatorTypeEnum.NESTED]: CONDITION_OPERATOR_TYPE_NESTED, [ListOperatorTypeEnum.MATCH_ANY]: CONDITION_OPERATOR_TYPE_MATCH_ANY, [ListOperatorTypeEnum.MATCH]: CONDITION_OPERATOR_TYPE_MATCH, - [ListOperatorTypeEnum.WILDCARD]: CONDITION_OPERATOR_TYPE_WILDCARD, + [ListOperatorTypeEnum.WILDCARD]: CONDITION_OPERATOR_TYPE_WILDCARD_MATCHES, [ListOperatorTypeEnum.EXISTS]: CONDITION_OPERATOR_TYPE_EXISTS, [ListOperatorTypeEnum.LIST]: CONDITION_OPERATOR_TYPE_LIST, }); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/translations.ts b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/translations.ts index 3290a52c1c37d4..273cda46aa7210 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/translations.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/translations.ts @@ -61,8 +61,8 @@ export const CONDITION_OPERATOR_TYPE_NOT_MATCH = i18n.translate( } ); -export const CONDITION_OPERATOR_TYPE_WILDCARD = i18n.translate( - 'xpack.securitySolution.artifactCard.conditions.wildcardOperator', +export const CONDITION_OPERATOR_TYPE_WILDCARD_MATCHES = i18n.translate( + 'xpack.securitySolution.artifactCard.conditions.wildcardMatchesOperator', { defaultMessage: 'MATCHES', } diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/services/index.ts b/x-pack/plugins/security_solution/public/management/pages/blocklist/services/index.ts index 002093007329d6..fab5be9f7ab3b6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/services/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/services/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './blocklists_api_client'; +export { BlocklistsApiClient } from './blocklists_api_client'; diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx index 8d98b401102f18..c016c10ad319f4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/view/blocklist.tsx @@ -39,7 +39,7 @@ const BLOCKLIST_PAGE_LABELS: ArtifactListPageProps['labels'] = { defaultMessage: 'Blocklist', }), pageAboutInfo: i18n.translate('xpack.securitySolution.blocklist.pageAboutInfo', { - defaultMessage: 'Add a blocklist to block applications or files from running.', + defaultMessage: 'Add a blocklist to block applications or files from running on the endpoint.', }), pageAddButtonTitle: i18n.translate('xpack.securitySolution.blocklist.pageAddButtonTitle', { defaultMessage: 'Add blocklist entry', diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx index fa68215cc768ba..6d24b9558ea532 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx @@ -25,8 +25,9 @@ import { FormattedMessage } from '@kbn/i18n-react'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { EVENT_FILTERS_OPERATORS } from '@kbn/securitysolution-list-utils'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; -import { OperatingSystem, PolicyData } from '../../../../../../../common/endpoint/types'; +import { PolicyData } from '../../../../../../../common/endpoint/types'; import { AddExceptionComments } from '../../../../../../common/components/exceptions/add_exception_comments'; import { filterIndexPatterns } from '../../../../../../common/components/exceptions/helpers'; import { Loader } from '../../../../../../common/components/loader'; @@ -225,6 +226,7 @@ export const EventFiltersForm: React.FC = memo( onChange: handleOnBuilderChange, listTypeSpecificIndexPatternFilter: filterIndexPatterns, operatorsList: EVENT_FILTERS_OPERATORS, + osTypes: exception?.os_types, }), [data, handleOnBuilderChange, http, indexPatterns, exception] ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/antivirus_registration_form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/antivirus_registration_form/index.tsx index 45aad6c3d1432c..c02969993e62d1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/antivirus_registration_form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/antivirus_registration_form/index.tsx @@ -10,7 +10,7 @@ import { useDispatch } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiSwitch, EuiText } from '@elastic/eui'; -import { OperatingSystem } from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { isAntivirusRegistrationEnabled } from '../../../store/policy_details/selectors'; import { usePolicyDetailsSelector } from '../../policy_hooks'; import { ConfigForm } from '../config_form'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.stories.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.stories.tsx index 89fe46445b20eb..79e32cf2e36720 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.stories.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.stories.tsx @@ -11,7 +11,7 @@ import { addDecorator, storiesOf } from '@storybook/react'; import { euiLightVars } from '@kbn/ui-theme'; import { EuiCheckbox, EuiSpacer, EuiSwitch, EuiText } from '@elastic/eui'; -import { OperatingSystem } from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { ConfigForm } from '.'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.tsx index 9d753749dabed7..6a5f7d187478d9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.tsx @@ -21,7 +21,7 @@ import { } from '@elastic/eui'; import { ThemeContext } from 'styled-components'; -import { OperatingSystem } from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { OS_TITLES } from '../../../../../common/translations'; const TITLES = { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/events_form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/events_form/index.tsx index 4364d921412403..a3f4b2fdc7fb1d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/events_form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/events_form/index.tsx @@ -8,11 +8,8 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiCheckbox, EuiSpacer, EuiText, htmlIdGenerator } from '@elastic/eui'; -import { - OperatingSystem, - PolicyOperatingSystem, - UIPolicyConfig, -} from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { PolicyOperatingSystem, UIPolicyConfig } from '../../../../../../../common/endpoint/types'; import { ConfigForm, ConfigFormHeading } from '../../components/config_form'; const OPERATING_SYSTEM_TO_TEST_SUBJ: { [K in OperatingSystem]: string } = { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx index 7f7163b68e7c3e..1980877eea95d7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx @@ -8,7 +8,7 @@ import React, { memo } from 'react'; import { i18n } from '@kbn/i18n'; import { useDispatch } from 'react-redux'; -import { OperatingSystem } from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { policyConfig } from '../../../store/policy_details/selectors'; import { setIn } from '../../../models/policy_details_config'; import { usePolicyDetailsSelector } from '../../policy_hooks'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx index 65b4ce9964d86e..8bc1f0fcaf17c2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx @@ -8,7 +8,7 @@ import React, { memo } from 'react'; import { i18n } from '@kbn/i18n'; import { useDispatch } from 'react-redux'; -import { OperatingSystem } from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { policyConfig } from '../../../store/policy_details/selectors'; import { setIn } from '../../../models/policy_details_config'; import { usePolicyDetailsSelector } from '../../policy_hooks'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx index eb48fc3ffa28b1..4ca72da6abfdf2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx @@ -8,7 +8,7 @@ import React, { memo } from 'react'; import { i18n } from '@kbn/i18n'; import { useDispatch } from 'react-redux'; -import { OperatingSystem } from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { policyConfig } from '../../../store/policy_details/selectors'; import { setIn } from '../../../models/policy_details_config'; import { usePolicyDetailsSelector } from '../../policy_hooks'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/behavior.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/behavior.tsx index 4c358bc3e3a46a..4d177c5cf6d30a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/behavior.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/behavior.tsx @@ -9,11 +9,8 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - Immutable, - OperatingSystem, - PolicyOperatingSystem, -} from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { Immutable, PolicyOperatingSystem } from '../../../../../../../common/endpoint/types'; import { BehaviorProtectionOSes } from '../../../types'; import { ConfigForm } from '../../components/config_form'; import { RadioButtons } from '../components/radio_buttons'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx index e348b1b8022292..9f9ac475d41866 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx @@ -9,13 +9,10 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { APP_UI_ID } from '../../../../../../../common/constants'; import { SecurityPageName } from '../../../../../../app/types'; -import { - Immutable, - OperatingSystem, - PolicyOperatingSystem, -} from '../../../../../../../common/endpoint/types'; +import { Immutable, PolicyOperatingSystem } from '../../../../../../../common/endpoint/types'; import { MalwareProtectionOSes } from '../../../types'; import { ConfigForm } from '../../components/config_form'; import { LinkToApp } from '../../../../../../common/components/endpoint/link_to_app'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/memory.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/memory.tsx index 82a14e4fa98084..ae3b2f7a1abc65 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/memory.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/memory.tsx @@ -9,13 +9,10 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { APP_UI_ID } from '../../../../../../../common/constants'; import { SecurityPageName } from '../../../../../../app/types'; -import { - Immutable, - OperatingSystem, - PolicyOperatingSystem, -} from '../../../../../../../common/endpoint/types'; +import { Immutable, PolicyOperatingSystem } from '../../../../../../../common/endpoint/types'; import { MemoryProtectionOSes } from '../../../types'; import { ConfigForm } from '../../components/config_form'; import { LinkToApp } from '../../../../../../common/components/endpoint/link_to_app'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/ransomware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/ransomware.tsx index 22266ef7351a04..da1b2e06b3a092 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/ransomware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/ransomware.tsx @@ -9,13 +9,10 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { APP_UI_ID } from '../../../../../../../common/constants'; import { SecurityPageName } from '../../../../../../app/types'; -import { - Immutable, - OperatingSystem, - PolicyOperatingSystem, -} from '../../../../../../../common/endpoint/types'; +import { Immutable, PolicyOperatingSystem } from '../../../../../../../common/endpoint/types'; import { RansomwareProtectionOSes } from '../../../types'; import { ConfigForm } from '../../components/config_form'; import { LinkToApp } from '../../../../../../common/components/endpoint/link_to_app'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/mappers.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/mappers.ts index 8069d18169dd1c..f440a0a394631c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/mappers.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/mappers.ts @@ -18,13 +18,15 @@ import { } from '@kbn/securitysolution-io-ts-list-types'; import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '@kbn/securitysolution-list-constants'; import { - ConditionEntry, ConditionEntryField, + OperatingSystem, + TrustedAppEntryTypes, +} from '@kbn/securitysolution-utils'; +import { + ConditionEntry, EffectScope, NewTrustedApp, - OperatingSystem, TrustedApp, - TrustedAppEntryTypes, UpdateTrustedApp, } from '../../../../../common/endpoint/types'; import { tagsToEffectScope } from '../../../../../common/endpoint/service/trusted_apps/mapping'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts index 3f9e9d53f69e47..22aeedca7312c7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts @@ -5,9 +5,9 @@ * 2.0. */ +import { ConditionEntryField } from '@kbn/securitysolution-utils'; import { ConditionEntry, - ConditionEntryField, EffectScope, GlobalEffectScope, MacosLinuxConditionEntry, diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts index 363da5cd273907..431894274ee009 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/builders.ts @@ -5,12 +5,8 @@ * 2.0. */ -import { - ConditionEntry, - ConditionEntryField, - NewTrustedApp, - OperatingSystem, -} from '../../../../../common/endpoint/types'; +import { ConditionEntryField, OperatingSystem } from '@kbn/securitysolution-utils'; +import { ConditionEntry, NewTrustedApp } from '../../../../../common/endpoint/types'; import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../../common/constants'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts index 3c2f177520271b..32e1867db567c7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts @@ -6,7 +6,8 @@ */ import { combineReducers, createStore } from 'redux'; -import { TrustedApp, OperatingSystem } from '../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { TrustedApp } from '../../../../../common/endpoint/types'; import { RoutingAction } from '../../../../common/store/routing'; import { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.test.tsx index 9d6c35d64b2d5e..4ea42c896847c8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.test.tsx @@ -8,11 +8,8 @@ import { shallow, mount } from 'enzyme'; import React from 'react'; import { keys } from 'lodash'; -import { - ConditionEntry, - ConditionEntryField, - OperatingSystem, -} from '../../../../../../../common/endpoint/types'; +import { ConditionEntryField, OperatingSystem } from '@kbn/securitysolution-utils'; +import { ConditionEntry } from '../../../../../../../common/endpoint/types'; import { ConditionEntryInput } from '.'; import { EuiSuperSelectProps } from '@elastic/eui'; @@ -53,6 +50,7 @@ describe('Condition entry input', () => { /> ); + // @ts-ignore it.each(keys(ConditionEntryField).map((k) => [k]))( 'should call on change for field input with value %s', (field) => { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.tsx index f487a38401ef03..4f4f89b80f28dc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_entry_input/index.tsx @@ -16,13 +16,8 @@ import { EuiSuperSelectOption, EuiText, } from '@elastic/eui'; - -import { - ConditionEntry, - ConditionEntryField, - OperatorFieldIds, - OperatingSystem, -} from '../../../../../../../common/endpoint/types'; +import { ConditionEntryField, OperatingSystem } from '@kbn/securitysolution-utils'; +import { ConditionEntry, OperatorFieldIds } from '../../../../../../../common/endpoint/types'; import { CONDITION_FIELD_DESCRIPTION, diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_group/index.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_group/index.tsx index fb7135b1173e0f..aed69128847f60 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_group/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/condition_group/index.tsx @@ -9,7 +9,8 @@ import React, { memo } from 'react'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiHideFor, EuiSpacer } from '@elastic/eui'; import styled from 'styled-components'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ConditionEntry, OperatingSystem } from '../../../../../../../common/endpoint/types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { ConditionEntry } from '../../../../../../../common/endpoint/types'; import { AndOrBadge } from '../../../../../../common/components/and_or_badge'; import { ConditionEntryInput, ConditionEntryInputProps } from '../condition_entry_input'; import { useTestIdGenerator } from '../../../../../components/hooks/use_test_id_generator'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx index cc2c51c5f4c405..68dd43fa411521 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx @@ -9,11 +9,8 @@ import React from 'react'; import * as reactTestingLibrary from '@testing-library/react'; import { fireEvent, getByTestId } from '@testing-library/dom'; -import { - ConditionEntryField, - NewTrustedApp, - OperatingSystem, -} from '../../../../../../common/endpoint/types'; +import { ConditionEntryField, OperatingSystem } from '@kbn/securitysolution-utils'; +import { NewTrustedApp } from '../../../../../../common/endpoint/types'; import { AppContextTestRender, createAppRootMockRenderer, diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx index 7cff989f008a0a..2812bdc9c3c0ba 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx @@ -18,20 +18,23 @@ import { EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { + hasSimpleExecutableName, + isPathValid, + ConditionEntryField, + OperatingSystem, +} from '@kbn/securitysolution-utils'; import { EuiFormProps } from '@elastic/eui/src/components/form/form'; + import { ConditionEntry, - ConditionEntryField, EffectScope, MacosLinuxConditionEntry, MaybeImmutable, NewTrustedApp, - OperatingSystem, } from '../../../../../../common/endpoint/types'; import { isValidHash, - isPathValid, - hasSimpleExecutableName, getDuplicateFields, } from '../../../../../../common/endpoint/service/trusted_apps/validations'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts index dd9b8fe4324c13..3d8a56ad743155 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/translations.ts @@ -6,10 +6,10 @@ */ import { i18n } from '@kbn/i18n'; +import { ConditionEntryField } from '@kbn/securitysolution-utils'; import { MacosLinuxConditionEntry, WindowsConditionEntry, - ConditionEntryField, OperatorFieldIds, } from '../../../../../common/endpoint/types'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index 82169fcd19c104..3666164676ee3e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -11,11 +11,8 @@ import { TrustedAppsPage } from './trusted_apps_page'; import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { fireEvent } from '@testing-library/dom'; import { MiddlewareActionSpyHelper } from '../../../../common/store/test_utils'; -import { - ConditionEntryField, - OperatingSystem, - TrustedApp, -} from '../../../../../common/endpoint/types'; +import { ConditionEntryField, OperatingSystem } from '@kbn/securitysolution-utils'; +import { TrustedApp } from '../../../../../common/endpoint/types'; import { HttpFetchOptions, HttpFetchOptionsWithPath } from 'kibana/public'; import { isFailedResourceState, isLoadedResourceState } from '../state'; import { forceHTMLElementOffsetWidth } from '../../../components/effected_policy_select/test_utils'; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts index 7f18c0b40fed7c..05baa6d4ade041 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/event_filters/index.ts @@ -9,7 +9,11 @@ import { run, RunFn, createFailError } from '@kbn/dev-utils'; import { KbnClient } from '@kbn/test'; import { AxiosError } from 'axios'; import pMap from 'p-map'; -import type { CreateExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; +import type { + CreateExceptionListItemSchema, + CreateExceptionListSchema, + ExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; import { ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION, ENDPOINT_EVENT_FILTERS_LIST_ID, @@ -41,8 +45,8 @@ export const cli = () => { kibana: 'http://elastic:changeme@localhost:5601', }, help: ` - --count Number of event filters to create. Default: 10 - --kibana The URL to kibana including credentials. Default: http://elastic:changeme@localhost:5601 + --count Number of event filters to create. Default: 10 + --kibana The URL to kibana including credentials. Default: http://elastic:changeme@localhost:5601 `, }, } @@ -77,7 +81,25 @@ const createEventFilters: RunFn = async ({ flags, log }) => { await pMap( Array.from({ length: flags.count as unknown as number }), () => { - const body = eventGenerator.generateEventFilterForCreate(); + let options: Partial = {}; + const listSize = (flags.count ?? 10) as number; + const randomN = eventGenerator.randomN(listSize); + if (randomN > Math.floor(listSize / 2)) { + const os = eventGenerator.randomOSFamily() as ExceptionListItemSchema['os_types'][number]; + options = { + os_types: [os], + entries: [ + { + field: 'file.path.text', + operator: 'included', + type: 'wildcard', + value: os === 'windows' ? 'C:\\Fol*\\file.*' : '/usr/*/*.dmg', + }, + ], + }; + } + + const body = eventGenerator.generateEventFilterForCreate(options); if (isArtifactByPolicy(body)) { const nmExceptions = Math.floor(Math.random() * 3) || 1; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index d4a486539855bf..b23c2fe08bf103 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -13,6 +13,7 @@ import type { ExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { validate } from '@kbn/securitysolution-io-ts-utils'; +import { hasSimpleExecutableName, OperatingSystem } from '@kbn/securitysolution-utils'; import { ENDPOINT_EVENT_FILTERS_LIST_ID, @@ -20,7 +21,6 @@ import { ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID, } from '@kbn/securitysolution-list-constants'; -import { OperatingSystem } from '../../../../common/endpoint/types'; import { ExceptionListClient } from '../../../../../lists/server'; import { InternalArtifactCompleteSchema, @@ -40,7 +40,6 @@ import { WrappedTranslatedExceptionList, wrappedTranslatedExceptionList, } from '../../schemas'; -import { hasSimpleExecutableName } from '../../../../common/endpoint/service/trusted_apps/validations'; export async function buildArtifact( exceptions: WrappedTranslatedExceptionList, @@ -227,7 +226,7 @@ function getMatcherWildcardFunction({ field: string; os: ExceptionListItemSchema['os_types'][number]; }): TranslatedEntryMatchWildcardMatcher { - return field.endsWith('.caseless') + return field.endsWith('.caseless') || field.endsWith('.text') ? os === 'linux' ? 'wildcard_cased' : 'wildcard_caseless' diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts index 95d0c8b607cb66..c878c02df2a081 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts @@ -449,6 +449,7 @@ describe('ManifestManager', () => { } }); + // test('Builds manifest with policy specific exception list items for trusted apps', async () => { const exceptionListItem = getExceptionListItemSchemaMock({ os_types: ['macos'] }); const trustedAppListItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); diff --git a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts index db39d4c5108e22..b5e787bd90c984 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts @@ -41,8 +41,8 @@ import { Manifest } from '../endpoint/lib/artifacts'; import { NewPackagePolicy } from '../../../fleet/common/types/models'; import { ManifestSchema } from '../../common/endpoint/schema/manifest'; import { DeletePackagePoliciesResponse } from '../../../fleet/common'; -import { ARTIFACT_LISTS_IDS_TO_REMOVE } from './handlers/remove_policy_from_artifacts'; import { createMockPolicyData } from '../endpoint/services/feature_usage'; +import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../common/endpoint/service/artifacts/constants'; describe('ingest_integration tests ', () => { let endpointAppContextMock: EndpointAppContextServiceStartContract; @@ -334,11 +334,11 @@ describe('ingest_integration tests ', () => { await invokeDeleteCallback(); expect(exceptionListClient.findExceptionListsItem).toHaveBeenCalledWith({ - listId: ARTIFACT_LISTS_IDS_TO_REMOVE, - filter: ARTIFACT_LISTS_IDS_TO_REMOVE.map( + listId: ALL_ENDPOINT_ARTIFACT_LIST_IDS, + filter: ALL_ENDPOINT_ARTIFACT_LIST_IDS.map( () => `exception-list-agnostic.attributes.tags:"policy:${policyId}"` ), - namespaceType: ARTIFACT_LISTS_IDS_TO_REMOVE.map(() => 'agnostic'), + namespaceType: ALL_ENDPOINT_ARTIFACT_LIST_IDS.map(() => 'agnostic'), page: 1, perPage: 50, sortField: undefined, diff --git a/x-pack/plugins/security_solution/server/fleet_integration/handlers/remove_policy_from_artifacts.ts b/x-pack/plugins/security_solution/server/fleet_integration/handlers/remove_policy_from_artifacts.ts index 57a23d677e014d..28ee9d5ad81dad 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/handlers/remove_policy_from_artifacts.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/handlers/remove_policy_from_artifacts.ts @@ -7,19 +7,9 @@ import pMap from 'p-map'; -import { - ENDPOINT_TRUSTED_APPS_LIST_ID, - ENDPOINT_EVENT_FILTERS_LIST_ID, - ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, -} from '@kbn/securitysolution-list-constants'; import { ExceptionListClient } from '../../../../lists/server'; import { PostPackagePolicyDeleteCallback } from '../../../../fleet/server'; - -export const ARTIFACT_LISTS_IDS_TO_REMOVE = [ - ENDPOINT_TRUSTED_APPS_LIST_ID, - ENDPOINT_EVENT_FILTERS_LIST_ID, - ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, -]; +import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../../common/endpoint/service/artifacts/constants'; /** * Removes policy from artifacts @@ -32,11 +22,11 @@ export const removePolicyFromArtifacts = async ( const findArtifactsByPolicy = (currentPage: number) => { return exceptionsClient.findExceptionListsItem({ - listId: ARTIFACT_LISTS_IDS_TO_REMOVE, - filter: ARTIFACT_LISTS_IDS_TO_REMOVE.map( + listId: ALL_ENDPOINT_ARTIFACT_LIST_IDS as string[], + filter: ALL_ENDPOINT_ARTIFACT_LIST_IDS.map( () => `exception-list-agnostic.attributes.tags:"policy:${policy.id}"` ), - namespaceType: ARTIFACT_LISTS_IDS_TO_REMOVE.map(() => 'agnostic'), + namespaceType: ALL_ENDPOINT_ARTIFACT_LIST_IDS.map(() => 'agnostic'), page: currentPage, perPage: 50, sortField: undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts index 11396864d802d7..95dcb918cd1658 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts @@ -195,6 +195,7 @@ export const previewRulesRoute = async ( }), savedObjectsClient: context.core.savedObjects.client, scopedClusterClient: context.core.elasticsearch.client, + uiSettingsClient: context.core.uiSettings.client, }, spaceId, startedAt: startedAt.toDate(), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index f8270c53b07ae2..99230627cb6b82 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -176,7 +176,13 @@ export const searchAfterAndBulkCreate = async ({ buildRuleMessage(`enrichedEvents.hits.hits: ${enrichedEvents.hits.hits.length}`) ); - sendAlertTelemetryEvents(logger, eventsTelemetry, enrichedEvents, buildRuleMessage); + sendAlertTelemetryEvents( + logger, + eventsTelemetry, + enrichedEvents, + createdItems, + buildRuleMessage + ); } if (!hasSortId) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.test.ts index 991378983e1b28..36bb90936620bc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { selectEvents } from './send_telemetry_events'; +import { selectEvents, enrichEndpointAlertsSignalID } from './send_telemetry_events'; describe('sendAlertTelemetry', () => { it('selectEvents', () => { @@ -33,6 +33,9 @@ describe('sendAlertTelemetry', () => { data_stream: { dataset: 'endpoint.events', }, + event: { + id: 'foo', + }, }, }, { @@ -47,6 +50,9 @@ describe('sendAlertTelemetry', () => { dataset: 'endpoint.alerts', other: 'x', }, + event: { + id: 'bar', + }, }, }, { @@ -58,13 +64,52 @@ describe('sendAlertTelemetry', () => { '@timestamp': 'x', key3: 'hello', data_stream: {}, + event: { + id: 'baz', + }, + }, + }, + { + _index: 'y', + _type: 'y', + _id: 'y', + _score: 0, + _source: { + '@timestamp': 'y', + key3: 'hello', + data_stream: { + dataset: 'endpoint.alerts', + other: 'y', + }, + event: { + id: 'not-in-map', + }, + }, + }, + { + _index: 'z', + _type: 'z', + _id: 'z', + _score: 0, + _source: { + '@timestamp': 'z', + key3: 'no-event-id', + data_stream: { + dataset: 'endpoint.alerts', + other: 'z', + }, }, }, ], }, }; - - const sources = selectEvents(filteredEvents); + const joinMap = new Map([ + ['foo', '1234'], + ['bar', 'abcd'], + ['baz', '4567'], + ]); + const subsetEvents = selectEvents(filteredEvents); + const sources = enrichEndpointAlertsSignalID(subsetEvents, joinMap); expect(sources).toStrictEqual([ { '@timestamp': 'x', @@ -73,6 +118,31 @@ describe('sendAlertTelemetry', () => { dataset: 'endpoint.alerts', other: 'x', }, + event: { + id: 'bar', + }, + signal_id: 'abcd', + }, + { + '@timestamp': 'y', + key3: 'hello', + data_stream: { + dataset: 'endpoint.alerts', + other: 'y', + }, + event: { + id: 'not-in-map', + }, + signal_id: undefined, + }, + { + '@timestamp': 'z', + key3: 'no-event-id', + data_stream: { + dataset: 'endpoint.alerts', + other: 'z', + }, + signal_id: undefined, }, ]); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.ts index 5904f943183c39..fc3aed36939cde 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/send_telemetry_events.ts @@ -11,14 +11,17 @@ import { BuildRuleMessage } from './rule_messages'; import { SignalSearchResponse, SignalSource } from './types'; import { Logger } from '../../../../../../../src/core/server'; -export interface SearchResultWithSource { +interface SearchResultSource { _source: SignalSource; } +type CreatedSignalId = string; +type AlertId = string; + export function selectEvents(filteredEvents: SignalSearchResponse): TelemetryEvent[] { // @ts-expect-error @elastic/elasticsearch _source is optional const sources: TelemetryEvent[] = filteredEvents.hits.hits.map(function ( - obj: SearchResultWithSource + obj: SearchResultSource ): TelemetryEvent { return obj._source; }); @@ -27,20 +30,49 @@ export function selectEvents(filteredEvents: SignalSearchResponse): TelemetryEve return sources.filter((obj: TelemetryEvent) => obj.data_stream?.dataset === 'endpoint.alerts'); } +export function enrichEndpointAlertsSignalID( + events: TelemetryEvent[], + signalIdMap: Map +): TelemetryEvent[] { + return events.map(function (obj: TelemetryEvent): TelemetryEvent { + obj.signal_id = undefined; + if (obj?.event?.id !== undefined) { + obj.signal_id = signalIdMap.get(obj.event.id); + } + return obj; + }); +} + export function sendAlertTelemetryEvents( logger: Logger, eventsTelemetry: ITelemetryEventsSender | undefined, filteredEvents: SignalSearchResponse, + createdEvents: SignalSource[], buildRuleMessage: BuildRuleMessage ) { if (eventsTelemetry === undefined) { return; } - const sources = selectEvents(filteredEvents); + let selectedEvents = selectEvents(filteredEvents); + if (selectedEvents.length > 0) { + // Create map of ancenstor_id -> alert_id + let signalIdMap = new Map(); + /* eslint-disable no-param-reassign */ + signalIdMap = createdEvents.reduce((signalMap, obj) => { + const ancestorId = obj['kibana.alert.original_event.id']?.toString(); + const alertId = obj._id?.toString(); + if (ancestorId !== null && ancestorId !== undefined && alertId !== undefined) { + signalMap = signalIdMap.set(ancestorId, alertId); + } + + return signalMap; + }, new Map()); + selectedEvents = enrichEndpointAlertsSignalID(selectedEvents, signalIdMap); + } try { - eventsTelemetry.queueTelemetryEvents(sources); + eventsTelemetry.queueTelemetryEvents(selectedEvents); } catch (exc) { logger.error(buildRuleMessage(`[-] queing telemetry events failed ${exc}`)); } diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts index 452717f1efb4f7..bd41bc454e8760 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts @@ -108,6 +108,7 @@ const allowlistBaseEventFields: AllowlistFields = { export const allowlistEventFields: AllowlistFields = { _id: true, '@timestamp': true, + signal_id: true, agent: true, Endpoint: true, /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts index 70852aa3093c67..d055f3843d479a 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.test.ts @@ -35,6 +35,7 @@ describe('TelemetryEventsSender', () => { { event: { kind: 'alert', + id: 'test', }, dns: { question: { @@ -108,6 +109,7 @@ describe('TelemetryEventsSender', () => { { event: { kind: 'alert', + id: 'test', }, dns: { question: { diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts index 35b701552b6ba4..35b531ae6941c3 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts @@ -58,6 +58,10 @@ export interface TelemetryEvent { }; }; license?: ESLicense; + event?: { + id?: string; + kind?: string; + }; } // EP Policy Response diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts index 3f53e59c348ab8..293066a3a18242 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts @@ -9,6 +9,7 @@ import { KibanaRequest } from 'kibana/server'; import { schema } from '@kbn/config-schema'; import { isEqual } from 'lodash/fp'; import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; import { ExceptionItemLikeOptions } from '../types'; import { getEndpointAuthzInitialState } from '../../../../common/endpoint/service/authz'; @@ -16,7 +17,6 @@ import { getPolicyIdsFromArtifact, isArtifactByPolicy, } from '../../../../common/endpoint/service/artifacts'; -import { OperatingSystem } from '../../../../common/endpoint/types'; import { EndpointArtifactExceptionValidationError } from './errors'; import type { FeatureKeys } from '../../../endpoint/services/feature_usage/service'; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts index 28bc408165d4bc..64545847838631 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/host_isolation_exceptions_validator.ts @@ -7,6 +7,7 @@ import { schema } from '@kbn/config-schema'; import { ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; import { BaseValidator, BasicEndpointExceptionDataSchema } from './base_validator'; import { EndpointArtifactExceptionValidationError } from './errors'; import { ExceptionItemLikeOptions } from '../types'; @@ -16,7 +17,6 @@ import { UpdateExceptionListItemOptions, } from '../../../../../lists/server'; import { isValidIPv4OrCIDR } from '../../../../common/endpoint/utils/is_valid_ip'; -import { OperatingSystem } from '../../../../common/endpoint/types'; function validateIp(value: string) { if (!isValidIPv4OrCIDR(value)) { diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts index d7ca2c0f05672b..fc69153f0b21b5 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/trusted_app_validator.ts @@ -8,17 +8,14 @@ import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '@kbn/securitysolution-list-constants'; import { schema, TypeOf } from '@kbn/config-schema'; import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { OperatingSystem, TrustedAppEntryTypes } from '@kbn/securitysolution-utils'; import { BaseValidator } from './base_validator'; import { ExceptionItemLikeOptions } from '../types'; import { CreateExceptionListItemOptions, UpdateExceptionListItemOptions, } from '../../../../../lists/server'; -import { - ConditionEntry, - OperatingSystem, - TrustedAppEntryTypes, -} from '../../../../common/endpoint/types'; +import { ConditionEntry } from '../../../../common/endpoint/types'; import { getDuplicateFields, isValidHash, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 3a5bc5edfbb798..487fe67b85a00d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -22715,7 +22715,6 @@ "xpack.securitySolution.artifactCard.conditions.matchOperator.not": "IS NOT", "xpack.securitySolution.artifactCard.conditions.nestedOperator": "がある", "xpack.securitySolution.artifactCard.conditions.os": "OS", - "xpack.securitySolution.artifactCard.conditions.wildcardOperator": "一致", "xpack.securitySolution.artifactCard.conditions.windows": "Windows", "xpack.securitySolution.artifactCard.created": "作成済み", "xpack.securitySolution.artifactCard.createdBy": "作成者", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index cd88c2b4b6a521..b515f1416eb990 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -22744,7 +22744,6 @@ "xpack.securitySolution.artifactCard.conditions.matchOperator.not": "不是", "xpack.securitySolution.artifactCard.conditions.nestedOperator": "具有", "xpack.securitySolution.artifactCard.conditions.os": "OS", - "xpack.securitySolution.artifactCard.conditions.wildcardOperator": "匹配", "xpack.securitySolution.artifactCard.conditions.windows": "Windows", "xpack.securitySolution.artifactCard.created": "创建时间", "xpack.securitySolution.artifactCard.createdBy": "创建者", diff --git a/x-pack/test/fleet_api_integration/apis/epm/index.js b/x-pack/test/fleet_api_integration/apis/epm/index.js index 70c8748932f6e7..4247be10e0792c 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/index.js +++ b/x-pack/test/fleet_api_integration/apis/epm/index.js @@ -14,6 +14,7 @@ export default function loadTests({ loadTestFile }) { loadTestFile(require.resolve('./file')); loadTestFile(require.resolve('./template')); loadTestFile(require.resolve('./ilm')); + // loadTestFile(require.resolve('./install_bundled')); loadTestFile(require.resolve('./install_by_upload')); loadTestFile(require.resolve('./install_endpoint')); loadTestFile(require.resolve('./install_overrides')); diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_bundled.ts b/x-pack/test/fleet_api_integration/apis/epm/install_bundled.ts new file mode 100644 index 00000000000000..c70495ea7809d9 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/epm/install_bundled.ts @@ -0,0 +1,126 @@ +/* + * 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 fs from 'fs/promises'; +import path from 'path'; + +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { skipIfNoDockerRegistry } from '../../helpers'; +import { setupFleetAndAgents } from '../agents/services'; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const supertest = getService('supertest'); + const log = getService('log'); + + const BUNDLED_PACKAGE_FIXTURES_DIR = path.join( + path.dirname(__filename), + '../fixtures/bundled_packages' + ); + const BUNDLED_PACKAGES_DIR = path.join( + path.dirname(__filename), + '../../../../plugins/fleet/target/bundled_packages' + ); + + const bundlePackage = async (name: string) => { + try { + await fs.access(BUNDLED_PACKAGES_DIR); + } catch (error) { + await fs.mkdir(BUNDLED_PACKAGES_DIR); + } + + await fs.copyFile( + path.join(BUNDLED_PACKAGE_FIXTURES_DIR, `${name}.zip`), + path.join(BUNDLED_PACKAGES_DIR, `${name}.zip`) + ); + }; + + const removeBundledPackages = async () => { + try { + const files = await fs.readdir(BUNDLED_PACKAGES_DIR); + + for (const file of files) { + await fs.unlink(path.join(BUNDLED_PACKAGES_DIR, file)); + } + } catch (error) { + log.error('Error removing bundled packages'); + log.error(error); + } + }; + + describe('installing bundled packages', async () => { + skipIfNoDockerRegistry(providerContext); + setupFleetAndAgents(providerContext); + + afterEach(async () => { + await removeBundledPackages(); + }); + + describe('without registry', () => { + it('installs from bundled source via api', async () => { + await bundlePackage('elastic_agent-1.2.0'); + + const response = await supertest + .post(`/api/fleet/epm/packages/elastic_agent/1.2.0`) + .set('kbn-xsrf', 'xxxx') + .type('application/json') + .send({ force: true }) + .expect(200); + + expect(response.body._meta.install_source).to.be('bundled'); + }); + + it('allows for upgrading from newer bundled source when outdated package was installed from bundled source', async () => { + await bundlePackage('elastic_agent-1.0.0'); + await bundlePackage('elastic_agent-1.2.0'); + + const installResponse = await supertest + .post(`/api/fleet/epm/packages/elastic_agent/1.0.0`) + .set('kbn-xsrf', 'xxxx') + .type('application/json') + .send({ force: true }) + .expect(200); + + expect(installResponse.body._meta.install_source).to.be('bundled'); + + const updateResponse = await supertest + .post(`/api/fleet/epm/packages/elastic_agent/1.2.0`) + .set('kbn-xsrf', 'xxxx') + .type('application/json') + .send({ force: true }) + .expect(200); + + expect(updateResponse.body._meta.install_source).to.be('bundled'); + }); + }); + + describe('with registry', () => { + it('allows for updating from registry when outdated package is installed from bundled source', async () => { + await bundlePackage('elastic_agent-1.2.0'); + + const bundledInstallResponse = await supertest + .post(`/api/fleet/epm/packages/elastic_agent/1.2.0`) + .set('kbn-xsrf', 'xxxx') + .type('application/json') + .send({ force: true }) + .expect(200); + + expect(bundledInstallResponse.body._meta.install_source).to.be('bundled'); + + const registryUpdateResponse = await supertest + .post(`/api/fleet/epm/packages/elastic_agent/1.3.0`) + .set('kbn-xsrf', 'xxxx') + .type('application/json') + .send({ force: true }) + .expect(200); + + expect(registryUpdateResponse.body._meta.install_source).to.be('registry'); + }); + }); + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/bundled_packages/elastic_agent-1.0.0.zip b/x-pack/test/fleet_api_integration/apis/fixtures/bundled_packages/elastic_agent-1.0.0.zip new file mode 100644 index 00000000000000..77961d6b14c53d Binary files /dev/null and b/x-pack/test/fleet_api_integration/apis/fixtures/bundled_packages/elastic_agent-1.0.0.zip differ diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/bundled_packages/elastic_agent-1.2.0.zip b/x-pack/test/fleet_api_integration/apis/fixtures/bundled_packages/elastic_agent-1.2.0.zip new file mode 100644 index 00000000000000..a41061b7212ae3 Binary files /dev/null and b/x-pack/test/fleet_api_integration/apis/fixtures/bundled_packages/elastic_agent-1.2.0.zip differ diff --git a/yarn.lock b/yarn.lock index 1994a2f42e4a52..ee914de59b4b9d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10754,10 +10754,10 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -chromedriver@^98.0.0: - version "98.0.0" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-98.0.0.tgz#b2c3c1941fad4cdfadad5d4c46923e02f089fd30" - integrity sha512-Oi6Th5teK+VI4nti+423/dFkENYHEMOdUvqwJHzOaNwXqLwZ8FuSaKBybgALCctGapwJbd+tmPv3qSd6tUUIHQ== +chromedriver@^98.0.1: + version "98.0.1" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-98.0.1.tgz#ccb1e36a003b4c6af0b184caa00fca8370d88f2a" + integrity sha512-/04KkHHE/K/lfwdPTQr5fxi1dWvM83p8T/IkYbyGK2PBlH7K49Dd71A9jrS+aWgXlZYkuHhbwiy2PA2QqZ5qQw== dependencies: "@testim/chrome-version" "^1.1.2" axios "^0.24.0"