diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6df4136ef74af..42bf7662ff2e1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -158,7 +158,6 @@ /x-pack/legacy/plugins/security/ @elastic/kibana-security /x-pack/legacy/plugins/spaces/ @elastic/kibana-security /x-pack/plugins/spaces/ @elastic/kibana-security -/x-pack/legacy/plugins/encrypted_saved_objects/ @elastic/kibana-security /x-pack/plugins/encrypted_saved_objects/ @elastic/kibana-security /x-pack/plugins/security/ @elastic/kibana-security /x-pack/test/api_integration/apis/security/ @elastic/kibana-security diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1053cc2f65396..4bf659345d387 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,26 +13,44 @@ A high level overview of our contributing guidelines. - ["My issue isn't getting enough attention"](#my-issue-isnt-getting-enough-attention) - ["I want to help!"](#i-want-to-help) - [How We Use Git and GitHub](#how-we-use-git-and-github) + - [Forking](#forking) - [Branching](#branching) - [Commits and Merging](#commits-and-merging) + - [Rebasing and fixing merge conflicts](#rebasing-and-fixing-merge-conflicts) - [What Goes Into a Pull Request](#what-goes-into-a-pull-request) - [Contributing Code](#contributing-code) - [Setting Up Your Development Environment](#setting-up-your-development-environment) + - [Increase node.js heap size](#increase-nodejs-heap-size) + - [Running Elasticsearch Locally](#running-elasticsearch-locally) + - [Nightly snapshot (recommended)](#nightly-snapshot-recommended) + - [Keeping data between snapshots](#keeping-data-between-snapshots) + - [Source](#source) + - [Archive](#archive) + - [Sample Data](#sample-data) + - [Running Elasticsearch Remotely](#running-elasticsearch-remotely) + - [Running remote clusters](#running-remote-clusters) + - [Running Kibana](#running-kibana) + - [Running Kibana in Open-Source mode](#running-kibana-in-open-source-mode) + - [Unsupported URL Type](#unsupported-url-type) - [Customizing `config/kibana.dev.yml`](#customizing-configkibanadevyml) + - [Potential Optimization Pitfalls](#potential-optimization-pitfalls) - [Setting Up SSL](#setting-up-ssl) - [Linting](#linting) + - [Setup Guide for VS Code Users](#setup-guide-for-vs-code-users) - [Internationalization](#internationalization) - [Localization](#localization) + - [Styling with SASS](#styling-with-sass) - [Testing and Building](#testing-and-building) - [Debugging server code](#debugging-server-code) - [Instrumenting with Elastic APM](#instrumenting-with-elastic-apm) - - [Debugging Unit Tests](#debugging-unit-tests) - - [Unit Testing Plugins](#unit-testing-plugins) - - [Automated Accessibility Testing](#automated-accessibility-testing) - - [Cross-browser compatibility](#cross-browser-compatibility) - - [Testing compatibility locally](#testing-compatibility-locally) - - [Running Browser Automation Tests](#running-browser-automation-tests) - - [Browser Automation Notes](#browser-automation-notes) + - [Unit testing frameworks](#unit-testing-frameworks) + - [Running specific Kibana tests](#running-specific-kibana-tests) + - [Debugging Unit Tests](#debugging-unit-tests) + - [Unit Testing Plugins](#unit-testing-plugins) + - [Automated Accessibility Testing](#automated-accessibility-testing) + - [Cross-browser compatibility](#cross-browser-compatibility) + - [Testing compatibility locally](#testing-compatibility-locally) + - [Running Browser Automation Tests](#running-browser-automation-tests) - [Building OS packages](#building-os-packages) - [Writing documentation](#writing-documentation) - [Release Notes Process](#release-notes-process) @@ -414,7 +432,7 @@ extract them to a `JSON` file or integrate translations back to Kibana. To know We cannot support accepting contributions to the translations from any source other than the translators we have engaged to do the work. We are still to develop a proper process to accept any contributed translations. We certainly appreciate that people care enough about the localization effort to want to help improve the quality. We aim to build out a more comprehensive localization process for the future and will notify you once contributions can be supported, but for the time being, we are not able to incorporate suggestions. -### Syling with SASS +### Styling with SASS When writing a new component, create a sibling SASS file of the same name and import directly into the JS/TS component file. Doing so ensures the styles are never separated or lost on import and allows for better modularization (smaller individual plugin asset footprint). @@ -467,10 +485,10 @@ macOS users on a machine with a discrete graphics card may see significant speed - Uncheck the "Prefer integrated to discrete GPU" option - Restart iTerm -### Debugging Server Code +#### Debugging Server Code `yarn debug` will start the server with Node's inspect flag. Kibana's development mode will start three processes on ports `9229`, `9230`, and `9231`. Chrome's developer tools need to be configured to connect to all three connections. Add `localhost:` for each Kibana process in Chrome's developer tools connection tab. -### Instrumenting with Elastic APM +#### Instrumenting with Elastic APM Kibana ships with the [Elastic APM Node.js Agent](https://github.com/elastic/apm-agent-nodejs) built-in for debugging purposes. Its default configuration is meant to be used by core Kibana developers only, but it can easily be re-configured to your needs. @@ -501,13 +519,13 @@ ELASTIC_APM_ACTIVE=true yarn start Once the agent is active, it will trace all incoming HTTP requests to Kibana, monitor for errors, and collect process-level metrics. The collected data will be sent to the APM Server and is viewable in the APM UI in Kibana. -### Unit testing frameworks +#### Unit testing frameworks Kibana is migrating unit testing from Mocha to Jest. Legacy unit tests still exist in Mocha but all new unit tests should be written in Jest. Mocha tests are contained in `__tests__` directories. Whereas Jest tests are stored in the same directory as source code files with the `.test.js` suffix. -### Running specific Kibana tests +#### Running specific Kibana tests The following table outlines possible test file locations and how to invoke them: @@ -540,7 +558,7 @@ Test runner arguments: yarn test:ftr:runner --config test/api_integration/config.js --grep='should return 404 if id does not match any sample data sets' ``` -### Debugging Unit Tests +#### Debugging Unit Tests The standard `yarn test` task runs several sub tasks and can take several minutes to complete, making debugging failures pretty painful. In order to ease the pain specialized tasks provide alternate methods for running the tests. @@ -567,7 +585,7 @@ In the screenshot below, you'll notice the URL is `localhost:9876/debug.html`. Y ![Browser test debugging](http://i.imgur.com/DwHxgfq.png) -### Unit Testing Plugins +#### Unit Testing Plugins This should work super if you're using the [Kibana plugin generator](https://github.com/elastic/kibana/tree/master/packages/kbn-plugin-generator). If you're not using the generator, well, you're on your own. We suggest you look at how the generator works. @@ -578,7 +596,7 @@ yarn test:mocha yarn test:karma:debug # remove the debug flag to run them once and close ``` -### Automated Accessibility Testing +#### Automated Accessibility Testing To run the tests locally: @@ -595,11 +613,11 @@ can be run locally using their browser plugins: - [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US) - [Firefox](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/) -### Cross-browser Compatibility +#### Cross-browser Compatibility -#### Testing Compatibility Locally +##### Testing Compatibility Locally -##### Testing IE on OS X +###### Testing IE on OS X * [Download VMWare Fusion](http://www.vmware.com/products/fusion/fusion-evaluation.html). * [Download IE virtual machines](https://developer.microsoft.com/en-us/microsoft-edge/tools/vms/#downloads) for VMWare. @@ -610,7 +628,7 @@ can be run locally using their browser plugins: * Now you can run your VM, open the browser, and navigate to `http://computer.local:5601` to test Kibana. * Alternatively you can use browserstack -#### Running Browser Automation Tests +##### Running Browser Automation Tests [Read about the `FunctionalTestRunner`](https://www.elastic.co/guide/en/kibana/current/development-functional-tests.html) to learn more about how you can run and develop functional tests for Kibana core and plugins. diff --git a/docs/development/core/public/kibana-plugin-core-public.chromenavlink.euiicontype.md b/docs/development/core/public/kibana-plugin-core-public.chromenavlink.euiicontype.md index fe95cb38cd97c..e30e8262f40b2 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromenavlink.euiicontype.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromenavlink.euiicontype.md @@ -4,7 +4,7 @@ ## ChromeNavLink.euiIconType property -A EUI iconType that will be used for the app's icon. This icon takes precendence over the `icon` property. +A EUI iconType that will be used for the app's icon. This icon takes precedence over the `icon` property. Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.chromenavlink.href.md b/docs/development/core/public/kibana-plugin-core-public.chromenavlink.href.md new file mode 100644 index 0000000000000..a8af0c997ca78 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.chromenavlink.href.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ChromeNavLink](./kibana-plugin-core-public.chromenavlink.md) > [href](./kibana-plugin-core-public.chromenavlink.href.md) + +## ChromeNavLink.href property + +Settled state between `url`, `baseUrl`, and `active` + +Signature: + +```typescript +readonly href?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.chromenavlink.md b/docs/development/core/public/kibana-plugin-core-public.chromenavlink.md index a9fabb38df869..0349e865bff97 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromenavlink.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromenavlink.md @@ -20,8 +20,9 @@ export interface ChromeNavLink | [category](./kibana-plugin-core-public.chromenavlink.category.md) | AppCategory | The category the app lives in | | [disabled](./kibana-plugin-core-public.chromenavlink.disabled.md) | boolean | Disables a link from being clickable. | | [disableSubUrlTracking](./kibana-plugin-core-public.chromenavlink.disablesuburltracking.md) | boolean | A flag that tells legacy chrome to ignore the link when tracking sub-urls | -| [euiIconType](./kibana-plugin-core-public.chromenavlink.euiicontype.md) | string | A EUI iconType that will be used for the app's icon. This icon takes precendence over the icon property. | +| [euiIconType](./kibana-plugin-core-public.chromenavlink.euiicontype.md) | string | A EUI iconType that will be used for the app's icon. This icon takes precedence over the icon property. | | [hidden](./kibana-plugin-core-public.chromenavlink.hidden.md) | boolean | Hides a link from the navigation. | +| [href](./kibana-plugin-core-public.chromenavlink.href.md) | string | Settled state between url, baseUrl, and active | | [icon](./kibana-plugin-core-public.chromenavlink.icon.md) | string | A URL to an image file used as an icon. Used as a fallback if euiIconType is not provided. | | [id](./kibana-plugin-core-public.chromenavlink.id.md) | string | A unique identifier for looking up links. | | [linkToLastSubUrl](./kibana-plugin-core-public.chromenavlink.linktolastsuburl.md) | boolean | Whether or not the subUrl feature should be enabled. | diff --git a/docs/development/core/public/kibana-plugin-core-public.chromenavlinkupdateablefields.md b/docs/development/core/public/kibana-plugin-core-public.chromenavlinkupdateablefields.md index 7f6dc7e0d5640..bd5a1399cded7 100644 --- a/docs/development/core/public/kibana-plugin-core-public.chromenavlinkupdateablefields.md +++ b/docs/development/core/public/kibana-plugin-core-public.chromenavlinkupdateablefields.md @@ -8,5 +8,5 @@ Signature: ```typescript -export declare type ChromeNavLinkUpdateableFields = Partial>; +export declare type ChromeNavLinkUpdateableFields = Partial>; ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md index 58690300b3bd6..85eb4825bc2e3 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystringinput.md @@ -7,5 +7,5 @@ Signature: ```typescript -QueryStringInput: React.FC> +QueryStringInput: React.FC> ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md index b015ebfcbaada..fc141b8c89c18 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md @@ -7,7 +7,7 @@ Signature: ```typescript -SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "customSubmitButton" | "screenTitle" | "dataTestSubj" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; } ``` diff --git a/docs/images/lens_viz_types.png b/docs/images/lens_viz_types.png new file mode 100644 index 0000000000000..fb3961ad8bb28 Binary files /dev/null and b/docs/images/lens_viz_types.png differ diff --git a/docs/user/dashboard.asciidoc b/docs/user/dashboard.asciidoc index 301efb2dfe2c0..1614f00f37ac7 100644 --- a/docs/user/dashboard.asciidoc +++ b/docs/user/dashboard.asciidoc @@ -160,7 +160,7 @@ When you're finished adding and arranging the panels, save the dashboard. . Enter the dashboard *Title* and optional *Description*, then *Save* the dashboard. [[sharing-dashboards]] -=== Share the dashboard +== Share the dashboard [[embedding-dashboards]] Share your dashboard outside of {kib}. diff --git a/docs/visualize/lens.asciidoc b/docs/visualize/lens.asciidoc index 422afbb201183..38ccb7878a92b 100644 --- a/docs/visualize/lens.asciidoc +++ b/docs/visualize/lens.asciidoc @@ -4,60 +4,51 @@ beta[] -*Lens* provides you with a simple and fast way to create visualizations from your {es} data. With Lens, you can: +*Lens* is a simple and fast way to create visualizations of your {es} data. With *Lens*, +you drag and drop your data fields onto the visualization builder pane, and *Lens* automatically generates +a visualization that best displays your data. -* Quickly build visualizations by dragging and dropping data fields. +With Lens, you can: -* Understand your data with a summary view on each field. +* Explore your data in just a few clicks. -* Easily change the visualization type by selecting the automatically generated visualization suggestions. +* Create visualizations with multiple layers and indices. -* Save your visualization for use in a dashboard. +* Use the automatically generated visualization suggestions to change the visualization type. -[float] -[[drag-drop]] -=== Drag and drop - -The panel shows the data fields for the selected time period. When -you drag a field from the data panel, Lens highlights where you can drop that field. The first time you drag a data field, -you'll see two places highlighted in green: - -* The visualization builder pane +* Add your visualizations to dashboards and Canvas workpads. -* The *X-axis* or *Y-axis* fields - -You can incorporate many fields into your visualization, and Lens uses heuristics to decide how -to apply each one to the visualization. +To get started with *Lens*, click a field in the data panel, then drag and drop the field on a highlighted area. [role="screenshot"] image::images/lens_drag_drop.gif[] -TIP: Drag-and-drop capabilities are available only when Lens knows how to use the data. You can still customize -your visualization if Lens is unable to make a suggestion. +You can incorporate many fields into your visualization, and Lens uses heuristics to decide how to apply each one to the visualization. + +TIP: Drag-and-drop capabilities are available only when Lens knows how to use the data. If *Lens* is unable to automatically generate a visualization, +you can still configure the customization options for your visualization. [float] [[apply-lens-filters]] -==== Find the right data +==== Filter the data panel fields -Lens shows you fields based on the <> you have defined in -{kib}, and the current time range. When you change the index pattern or time filter, -the list of fields are updated. +The fields in the data panel based on your selected <>, and the <>. -To narrow the list of fields, you can: +To change the index pattern, click it, then select a new one. The fields in the data panel automatically update. -* Enter the field name in *Search field names*. +To filter the fields in the data panel: -* Click *Filter by type*, then select the filter. You can also select *Only show fields with data* -to show the full list of fields from the index pattern. +* Enter the name in *Search field names*. + +* Click *Filter by type*, then select the filter. To show all of the fields in the index pattern, deselect *Only show fields with data*. [float] [[view-data-summaries]] ==== Data summaries -To help you decide exactly the data you want to display, get a quick summary of each data field. -The summary shows the distribution of values in the time range. +To help you decide exactly the data you want to display, get a quick summary of each field. The summary shows the distribution of values in the time range. -To view the data information, navigate to a data field, then click *i*. +To view the field summary information, navigate to the field, then click *i*. [role="screenshot"] image::images/lens_data_info.png[] @@ -66,46 +57,40 @@ image::images/lens_data_info.png[] [[change-the-visualization-type]] ==== Change the visualization type -With Lens, you are no longer required to build each visualization from scratch. Lens allows -you to switch between any supported chart type at any time. Lens also provides -suggestions, which are shortcuts to alternate visualizations based on the data you have. +*Lens* enables you to switch between any supported visualization type at any time. -You can switch between suggestions without losing your previous state: +*Suggestions* are shortcuts to alternate visualizations that *Lens* generates for you. [role="screenshot"] image::images/lens_suggestions.gif[] -If you want to switch to a chart type that is not suggested, click the chart type, -then select a chart type. When there is an exclamation point (!) -next to a chart type, Lens is unable to transfer your current data, but +If you'd like to use a visualization type that is not suggested, click the visualization type, +then select a new one. + +[role="screenshot"] +image::images/lens_viz_types.png[] + +When there is an exclamation point (!) +next to a visualization type, Lens is unable to transfer your data, but still allows you to make the change. [float] [[customize-operation]] -==== Customize the data for your visualization +==== Change the aggregation and labels Lens allows some customizations of the data for each visualization. -. Click the index pattern name, then select the new index pattern. -+ -If there is a match, Lens displays the new data. All fields that do not match the index pattern are removed. - -. Change the data field options, such as the aggregation or label. - -.. Click *Drop a field here* or the field name in the column. +. Click *Drop a field here* or the field name in the column. -.. Change the options that appear depending on the type of field. +. Change the options that appear depending on the type of field. [float] [[layers]] -==== Layers in bar, line, and area charts +==== Add layers and indices -The bar, line, and area charts allow you to layer two different series. To add a layer, click *+*. +Bar, line, and area charts allow you to visualize multiple data layers and indices so that you can compare and analyze data from multiple sources. -To remove a layer, click the chart icon next to the index name: - -[role="screenshot"] -image::images/lens_remove_layer.png[] +To add a layer, click *+*, then drag and drop the fields for the new layer. To view a different index, click it, then select a new one. [float] [[lens-tutorial]] @@ -125,50 +110,48 @@ To start, you'll need to add the <>. Drag and drop your data onto the visualization builder pane. -. Open *Visualize*, then click *Create visualization*. +. From the menu, click *Visualize*, then click *Create visualization*. . On the *New Visualization* window, click *Lens*. -. Select the *kibana_sample_data_ecommerce* index. +. Select the *kibana_sample_data_ecommerce* index pattern. -. Click image:images/time-filter-calendar.png[], then click *Last 7 days*. The list of data fields are updated. +. Click image:images/time-filter-calendar.png[], then click *Last 7 days*. ++ +The fields in the data panel update. . Drag and drop the *taxful_total_price* data field to the visualization builder pane. + [role="screenshot"] image::images/lens_tutorial_1.png[Lens tutorial] -Lens has taken your intent to see *taxful_total_price* and added in the *order_date* field to show -average order prices over time. +To display the average order prices over time, *Lens* automatically added in *order_date* field. To break down your data, drag the *category.keyword* field to the visualization builder pane. Lens -understands that you want to show the top categories and compare them across the dates, -and creates a chart that compares the sales for each of the top 3 categories: +knows that you want to show the top categories and compare them across the dates, +and creates a chart that compares the sales for each of the top three categories: [role="screenshot"] image::images/lens_tutorial_2.png[Lens tutorial] [float] [[customize-lens-visualization]] -==== Further customization +==== Customize your visualization -Customize your visualization to look exactly how you want. +Make your visualization look exactly how you want with the customization options. . Click *Average of taxful_total_price*. -.. Change the *Label* to `Sales`, or a name that you prefer for the data. +.. Change the *Label* to `Sales`. . Click *Top values of category.keyword*. -.. Increase *Number of values* to `10`. The visualization updates in the background to show there are only +.. Change *Number of values* to `10`. The visualization updates to show there are only six available categories. ++ +Look at the *Suggestions*. An area chart is not an option, but for sales data, a stacked area chart might be the best option. -. Look at the suggestions. None of them show an area chart, but for sales data, a stacked area chart -might make sense. To switch the chart type: - -.. Click *Stacked bar chart* in the column. - -.. Click *Stacked area*. +. To switch the chart type, click *Stacked bar chart* in the column, then click *Stacked area* from the *Select a visualizations* window. + [role="screenshot"] image::images/lens_tutorial_3.png[Lens tutorial] @@ -177,6 +160,6 @@ image::images/lens_tutorial_3.png[Lens tutorial] [[lens-tutorial-next-steps]] ==== Next steps -Now that you've created your visualization in Lens, you can add it to a Dashboard. +Now that you've created your visualization, you can add it to a dashboard or Canvas workpad. -For more information, see <>. +For more information, refer to <> or <>. diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index fc7e78f209022..67cd43f0647e4 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -36,7 +36,7 @@ import { ChromeDocTitle, DocTitleService } from './doc_title'; import { ChromeNavControls, NavControlsService } from './nav_controls'; import { ChromeNavLinks, NavLinksService } from './nav_links'; import { ChromeRecentlyAccessed, RecentlyAccessedService } from './recently_accessed'; -import { Header, LoadingIndicator } from './ui'; +import { Header } from './ui'; import { NavType } from './ui/header'; import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu'; export { ChromeNavControls, ChromeRecentlyAccessed, ChromeDocTitle }; @@ -214,31 +214,29 @@ export class ChromeService { docTitle, getHeaderComponent: () => ( - - -
- +
), setAppTitle: (appTitle: string) => appTitle$.next(appTitle), diff --git a/src/core/public/chrome/nav_links/nav_link.ts b/src/core/public/chrome/nav_links/nav_link.ts index fb2972735c2b7..55b5c80526bab 100644 --- a/src/core/public/chrome/nav_links/nav_link.ts +++ b/src/core/public/chrome/nav_links/nav_link.ts @@ -62,7 +62,7 @@ export interface ChromeNavLink { /** * A EUI iconType that will be used for the app's icon. This icon - * takes precendence over the `icon` property. + * takes precedence over the `icon` property. */ readonly euiIconType?: string; @@ -72,6 +72,14 @@ export interface ChromeNavLink { */ readonly icon?: string; + /** + * Settled state between `url`, `baseUrl`, and `active` + * + * @internalRemarks + * This should be required once legacy apps are gone. + */ + readonly href?: string; + /** LEGACY FIELDS */ /** @@ -144,7 +152,7 @@ export interface ChromeNavLink { /** @public */ export type ChromeNavLinkUpdateableFields = Partial< - Pick + Pick >; export class NavLinkWrapper { @@ -162,7 +170,7 @@ export class NavLinkWrapper { public update(newProps: ChromeNavLinkUpdateableFields) { // Enforce limited properties at runtime for JS code - newProps = pick(newProps, ['active', 'disabled', 'hidden', 'url', 'subUrlBase']); + newProps = pick(newProps, ['active', 'disabled', 'hidden', 'url', 'subUrlBase', 'href']); return new NavLinkWrapper({ ...this.properties, ...newProps }); } } diff --git a/src/core/public/chrome/nav_links/to_nav_link.ts b/src/core/public/chrome/nav_links/to_nav_link.ts index f79b1df77f8e1..24744fe53c82c 100644 --- a/src/core/public/chrome/nav_links/to_nav_link.ts +++ b/src/core/public/chrome/nav_links/to_nav_link.ts @@ -24,7 +24,12 @@ import { appendAppPath } from '../../application/utils'; export function toNavLink(app: App | LegacyApp, basePath: IBasePath): NavLinkWrapper { const useAppStatus = app.navLinkStatus === AppNavLinkStatus.default; - const baseUrl = isLegacyApp(app) ? basePath.prepend(app.appUrl) : basePath.prepend(app.appRoute!); + const relativeBaseUrl = isLegacyApp(app) + ? basePath.prepend(app.appUrl) + : basePath.prepend(app.appRoute!); + const url = relativeToAbsolute(appendAppPath(relativeBaseUrl, app.defaultPath)); + const baseUrl = relativeToAbsolute(relativeBaseUrl); + return new NavLinkWrapper({ ...app, hidden: useAppStatus @@ -32,17 +37,27 @@ export function toNavLink(app: App | LegacyApp, basePath: IBasePath): NavLinkWra : app.navLinkStatus === AppNavLinkStatus.hidden, disabled: useAppStatus ? false : app.navLinkStatus === AppNavLinkStatus.disabled, legacy: isLegacyApp(app), - baseUrl: relativeToAbsolute(baseUrl), + baseUrl, ...(isLegacyApp(app) - ? {} + ? { + href: url && !url.startsWith(app.subUrlBase!) ? url : baseUrl, + } : { - url: relativeToAbsolute(appendAppPath(baseUrl, app.defaultPath)), + href: url, + url, }), }); } -function relativeToAbsolute(url: string) { - // convert all link urls to absolute urls +/** + * @param {string} url - a relative or root relative url. If a relative path is given then the + * absolute url returned will depend on the current page where this function is called from. For example + * if you are on page "http://www.mysite.com/shopping/kids" and you pass this function "adults", you would get + * back "http://www.mysite.com/shopping/adults". If you passed this function a root relative path, or one that + * starts with a "/", for example "/account/cart", you would get back "http://www.mysite.com/account/cart". + * @return {string} the relative url transformed into an absolute url + */ +export function relativeToAbsolute(url: string) { const a = document.createElement('a'); a.setAttribute('href', url); return a.href; diff --git a/src/core/public/chrome/recently_accessed/recently_accessed_service.ts b/src/core/public/chrome/recently_accessed/recently_accessed_service.ts index 27dbc288d18cb..86c7f3a1ef765 100644 --- a/src/core/public/chrome/recently_accessed/recently_accessed_service.ts +++ b/src/core/public/chrome/recently_accessed/recently_accessed_service.ts @@ -76,7 +76,7 @@ export interface ChromeRecentlyAccessed { * * @param link a relative URL to the resource (not including the {@link HttpStart.basePath | `http.basePath`}) * @param label the label to display in the UI - * @param id a unique string used to de-duplicate the recently accessed llist. + * @param id a unique string used to de-duplicate the recently accessed list. */ add(link: string, label: string, id: string): void; diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 866ea5f45d986..f5b17f8d214e9 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -2,140 +2,295 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
@@ -376,7 +531,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` aria-label="recent 2" class="euiListGroupItem__button" data-test-subj="collapsibleNavAppLink--recent" - href="recent 2" + href="http://localhost/recent%202" rel="noreferrer" title="recent 2" > @@ -465,7 +620,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` style="max-width: none;" >
  • @@ -1023,7 +1179,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` aria-label="recent 2" class="euiListGroupItem__button" data-test-subj="collapsibleNavAppLink--recent" - href="recent 2" + href="http://localhost/recent%202" rel="noreferrer" title="recent 2" > @@ -1112,7 +1268,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` style="max-width: none;" >
  • + +
    +
    +
    + +
  • +`; + +exports[`Header renders 2`] = ` +
    + +
    +
    +
    + +
    + +
    + +
    + +
    + + + +
    +
    + +
    + + + + + + + + + + +
    + +
    + + + + + + } + closePopover={[Function]} + data-test-subj="helpMenuButton" + display="inlineBlock" + hasArrow={true} + id="headerHelpMenu" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + repositionOnScroll={true} + > + +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    + + + + +
    +
    +`; + +exports[`Header renders 3`] = ` +
    + +
    +
    +
    + +
    + +
    + +
    + +
    + + + +
    +
    + +
    + + + + +
    + + + + +
    + + +
    + + + + + + + + + + +
    + +
    + + + + + + } + closePopover={[Function]} + data-test-subj="helpMenuButton" + display="inlineBlock" + hasArrow={true} + id="headerHelpMenu" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + repositionOnScroll={true} + > + +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    + + + + + + + +
    + +
    +
    +
    + + +
    + } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + > + + +
    + } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + /> + + +
    +
    + +
    + + + + + +
    +
    +`; + +exports[`Header renders 4`] = ` +
    + +
    +
    +
    + +
    + +
    + +
    + + +
    + + + +
    +
    +
    + +
    + + + + +
    + + + + +
    + + +
    + + + + + + + + + + +
    + +
    + + + + + + } + closePopover={[Function]} + data-test-subj="helpMenuButton" + display="inlineBlock" + hasArrow={true} + id="headerHelpMenu" + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + repositionOnScroll={true} + > + +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    + + + + + + + + +
    +
    +`; diff --git a/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap index d089019915686..fdaa17c279a10 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap @@ -5,6 +5,7 @@ exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable 1`] = aria-current="page" className="euiBreadcrumb euiBreadcrumb--last" data-test-subj="breadcrumb first last" + title="First" > First @@ -39,6 +40,7 @@ Array [ aria-current="page" className="euiBreadcrumb euiBreadcrumb--last" data-test-subj="breadcrumb last" + title="Second" > Second , diff --git a/src/core/public/chrome/ui/header/_index.scss b/src/core/public/chrome/ui/header/_index.scss index 1b0438d748ff0..5c5e7f18b60a4 100644 --- a/src/core/public/chrome/ui/header/_index.scss +++ b/src/core/public/chrome/ui/header/_index.scss @@ -1,5 +1,3 @@ -@import './collapsible_nav'; - // TODO #64541 // Delete this block .chrHeaderWrapper:not(.headerWrapper) { diff --git a/src/core/public/chrome/ui/header/_collapsible_nav.scss b/src/core/public/chrome/ui/header/collapsible_nav.scss similarity index 100% rename from src/core/public/chrome/ui/header/_collapsible_nav.scss rename to src/core/public/chrome/ui/header/collapsible_nav.scss diff --git a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx index 527f0df598c7c..5a734d55445a2 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx @@ -19,11 +19,13 @@ import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; +import { BehaviorSubject } from 'rxjs'; import sinon from 'sinon'; -import { CollapsibleNav } from './collapsible_nav'; -import { DEFAULT_APP_CATEGORIES } from '../../..'; import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; -import { NavLink, RecentNavLink } from './nav_link'; +import { ChromeNavLink, DEFAULT_APP_CATEGORIES } from '../../..'; +import { httpServiceMock } from '../../../http/http_service.mock'; +import { ChromeRecentlyAccessedHistoryItem } from '../../recently_accessed'; +import { CollapsibleNav } from './collapsible_nav'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', @@ -31,40 +33,42 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ const { kibana, observability, security, management } = DEFAULT_APP_CATEGORIES; -function mockLink({ label = 'discover', category, onClick }: Partial) { +function mockLink({ title = 'discover', category }: Partial) { return { - key: label, - label, - href: label, - isActive: true, - onClick: onClick || (() => {}), + title, category, - 'data-test-subj': label, + id: title, + href: title, + baseUrl: '/', + legacy: false, + isActive: true, + 'data-test-subj': title, }; } -function mockRecentNavLink({ label = 'recent', onClick }: Partial) { +function mockRecentNavLink({ label = 'recent' }: Partial) { return { - href: label, label, - title: label, - 'aria-label': label, - onClick, + link: label, + id: label, }; } function mockProps() { return { - id: 'collapsible-nav', - homeHref: '/', + appId$: new BehaviorSubject('test'), + basePath: httpServiceMock.createSetupContract({ basePath: '/test' }).basePath, + id: 'collapsibe-nav', isLocked: false, isOpen: false, - navLinks: [], - recentNavLinks: [], + homeHref: '/', + legacyMode: false, + navLinks$: new BehaviorSubject([]), + recentlyAccessed$: new BehaviorSubject([]), storage: new StubBrowserStorage(), - onIsOpenUpdate: () => {}, onIsLockedUpdate: () => {}, - navigateToApp: () => {}, + closeNav: () => {}, + navigateToApp: () => Promise.resolve(), }; } @@ -103,14 +107,14 @@ describe('CollapsibleNav', () => { it('renders links grouped by category', () => { // just a test of category functionality, categories are not accurate const navLinks = [ - mockLink({ label: 'discover', category: kibana }), - mockLink({ label: 'siem', category: security }), - mockLink({ label: 'metrics', category: observability }), - mockLink({ label: 'monitoring', category: management }), - mockLink({ label: 'visualize', category: kibana }), - mockLink({ label: 'dashboard', category: kibana }), - mockLink({ label: 'canvas' }), // links should be able to be rendered top level as well - mockLink({ label: 'logs', category: observability }), + mockLink({ title: 'discover', category: kibana }), + mockLink({ title: 'siem', category: security }), + mockLink({ title: 'metrics', category: observability }), + mockLink({ title: 'monitoring', category: management }), + mockLink({ title: 'visualize', category: kibana }), + mockLink({ title: 'dashboard', category: kibana }), + mockLink({ title: 'canvas' }), // links should be able to be rendered top level as well + mockLink({ title: 'logs', category: observability }), ]; const recentNavLinks = [ mockRecentNavLink({ label: 'recent 1' }), @@ -120,8 +124,8 @@ describe('CollapsibleNav', () => { ); expect(component).toMatchSnapshot(); @@ -134,8 +138,8 @@ describe('CollapsibleNav', () => { ); expectShownNavLinksCount(component, 3); @@ -149,32 +153,34 @@ describe('CollapsibleNav', () => { }); it('closes the nav after clicking a link', () => { - const onClick = sinon.spy(); - const onIsOpenUpdate = sinon.spy(); - const navLinks = [mockLink({ category: kibana, onClick })]; - const recentNavLinks = [mockRecentNavLink({ onClick })]; + const onClose = sinon.spy(); + const navLinks = [mockLink({ category: kibana }), mockLink({ title: 'categoryless' })]; + const recentNavLinks = [mockRecentNavLink({})]; const component = mount( ); component.setProps({ - onIsOpenUpdate: (isOpen: boolean) => { - component.setProps({ isOpen }); - onIsOpenUpdate(); + closeNav: () => { + component.setProps({ isOpen: false }); + onClose(); }, }); component.find('[data-test-subj="collapsibleNavGroup-recentlyViewed"] a').simulate('click'); - expect(onClick.callCount).toEqual(1); - expect(onIsOpenUpdate.callCount).toEqual(1); + expect(onClose.callCount).toEqual(1); expectNavIsClosed(component); component.setProps({ isOpen: true }); component.find('[data-test-subj="collapsibleNavGroup-kibana"] a').simulate('click'); - expect(onClick.callCount).toEqual(2); - expect(onIsOpenUpdate.callCount).toEqual(2); + expect(onClose.callCount).toEqual(2); + expectNavIsClosed(component); + component.setProps({ isOpen: true }); + component.find('[data-test-subj="collapsibleNavGroup-noCategory"] a').simulate('click'); + expect(onClose.callCount).toEqual(3); + expectNavIsClosed(component); }); }); diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 8bca42db23517..9494e22920de8 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -17,6 +17,7 @@ * under the License. */ +import './collapsible_nav.scss'; import { EuiCollapsibleNav, EuiCollapsibleNavGroup, @@ -30,11 +31,16 @@ import { import { i18n } from '@kbn/i18n'; import { groupBy, sortBy } from 'lodash'; import React, { useRef } from 'react'; +import { useObservable } from 'react-use'; +import * as Rx from 'rxjs'; +import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem } from '../..'; import { AppCategory } from '../../../../types'; +import { InternalApplicationStart } from '../../../application/types'; +import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; -import { NavLink, RecentNavLink } from './nav_link'; +import { createEuiListItem, createRecentNavLink } from './nav_link'; -function getAllCategories(allCategorizedLinks: Record) { +function getAllCategories(allCategorizedLinks: Record) { const allCategories = {} as Record; for (const [key, value] of Object.entries(allCategorizedLinks)) { @@ -45,7 +51,7 @@ function getAllCategories(allCategorizedLinks: Record) { } function getOrderedCategories( - mainCategories: Record, + mainCategories: Record, categoryDictionary: ReturnType ) { return sortBy( @@ -69,35 +75,53 @@ function setIsCategoryOpen(id: string, isOpen: boolean, storage: Storage) { } interface Props { + appId$: InternalApplicationStart['currentAppId$']; + basePath: HttpStart['basePath']; + id: string; isLocked: boolean; isOpen: boolean; - navLinks: NavLink[]; - recentNavLinks: RecentNavLink[]; homeHref: string; - id: string; + legacyMode: boolean; + navLinks$: Rx.Observable; + recentlyAccessed$: Rx.Observable; storage?: Storage; onIsLockedUpdate: OnIsLockedUpdate; - onIsOpenUpdate: (isOpen?: boolean) => void; - navigateToApp: (appId: string) => void; + closeNav: () => void; + navigateToApp: InternalApplicationStart['navigateToApp']; } export function CollapsibleNav({ + basePath, + id, isLocked, isOpen, - navLinks, - recentNavLinks, - onIsLockedUpdate, - onIsOpenUpdate, homeHref, - id, - navigateToApp, + legacyMode, storage = window.localStorage, + onIsLockedUpdate, + closeNav, + navigateToApp, + ...observables }: Props) { + const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); + const recentlyAccessed = useObservable(observables.recentlyAccessed$, []); + const appId = useObservable(observables.appId$, ''); const lockRef = useRef(null); const groupedNavLinks = groupBy(navLinks, (link) => link?.category?.id); const { undefined: unknowns = [], ...allCategorizedLinks } = groupedNavLinks; const categoryDictionary = getAllCategories(allCategorizedLinks); const orderedCategories = getOrderedCategories(allCategorizedLinks, categoryDictionary); + const readyForEUI = (link: ChromeNavLink, needsIcon: boolean = false) => { + return createEuiListItem({ + link, + legacyMode, + appId, + dataTestSubj: 'collapsibleNavAppLink', + navigateToApp, + onClick: closeNav, + ...(needsIcon && { basePath }), + }); + }; return ( {/* Pinned items */} @@ -127,7 +151,7 @@ export function CollapsibleNav({ iconType: 'home', href: homeHref, onClick: (event: React.MouseEvent) => { - onIsOpenUpdate(false); + closeNav(); if ( event.isDefaultPrevented() || event.altKey || @@ -159,21 +183,22 @@ export function CollapsibleNav({ onToggle={(isCategoryOpen) => setIsCategoryOpen('recentlyViewed', isCategoryOpen, storage)} data-test-subj="collapsibleNavGroup-recentlyViewed" > - {recentNavLinks.length > 0 ? ( + {recentlyAccessed.length > 0 ? ( {}, ...link }) => ({ - 'data-test-subj': 'collapsibleNavAppLink--recent', - onClick: (e: React.MouseEvent) => { - onIsOpenUpdate(false); - onClick(e); - }, - ...link, - }))} + listItems={recentlyAccessed.map((link) => { + // TODO #64541 + // Can remove icon from recent links completely + const { iconType, ...hydratedLink } = createRecentNavLink(link, navLinks, basePath); + + return { + ...hydratedLink, + 'data-test-subj': 'collapsibleNavAppLink--recent', + onClick: closeNav, + }; + })} maxWidth="none" color="subdued" gutterSize="none" @@ -195,21 +220,8 @@ export function CollapsibleNav({ {/* Kibana, Observability, Security, and Management sections */} - {orderedCategories.map((categoryName, i) => { + {orderedCategories.map((categoryName) => { const category = categoryDictionary[categoryName]!; - const links = allCategorizedLinks[categoryName].map( - ({ label, href, isActive, isDisabled, onClick }) => ({ - label, - href, - isActive, - isDisabled, - 'data-test-subj': 'collapsibleNavAppLink', - onClick: (e: React.MouseEvent) => { - onIsOpenUpdate(false); - onClick(e); - }, - }) - ); return ( readyForEUI(link))} maxWidth="none" color="subdued" gutterSize="none" @@ -237,23 +249,10 @@ export function CollapsibleNav({ })} {/* Things with no category (largely for custom plugins) */} - {unknowns.map(({ label, href, icon, isActive, isDisabled, onClick }, i) => ( - + {unknowns.map((link, i) => ( + - ) => { - onIsOpenUpdate(false); - onClick(e); - }} - /> + ))} diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx new file mode 100644 index 0000000000000..13e1f6f086ae2 --- /dev/null +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -0,0 +1,106 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { BehaviorSubject } from 'rxjs'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { NavType } from '.'; +import { httpServiceMock } from '../../../http/http_service.mock'; +import { applicationServiceMock } from '../../../mocks'; +import { Header } from './header'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; + +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => 'mockId', +})); + +function mockProps() { + const http = httpServiceMock.createSetupContract({ basePath: '/test' }); + const application = applicationServiceMock.createInternalStartContract(); + + return { + application, + kibanaVersion: '1.0.0', + appTitle$: new BehaviorSubject('test'), + badge$: new BehaviorSubject(undefined), + breadcrumbs$: new BehaviorSubject([]), + homeHref: '/', + isVisible$: new BehaviorSubject(true), + kibanaDocLink: '/docs', + navLinks$: new BehaviorSubject([]), + recentlyAccessed$: new BehaviorSubject([]), + forceAppSwitcherNavigation$: new BehaviorSubject(false), + helpExtension$: new BehaviorSubject(undefined), + helpSupportUrl$: new BehaviorSubject(''), + legacyMode: false, + navControlsLeft$: new BehaviorSubject([]), + navControlsRight$: new BehaviorSubject([]), + basePath: http.basePath, + isLocked$: new BehaviorSubject(false), + navType$: new BehaviorSubject('modern' as NavType), + loadingCount$: new BehaviorSubject(0), + onIsLockedUpdate: () => {}, + }; +} + +describe('Header', () => { + beforeAll(() => { + Object.defineProperty(window, 'localStorage', { + value: new StubBrowserStorage(), + }); + }); + + it('renders', () => { + const isVisible$ = new BehaviorSubject(false); + const breadcrumbs$ = new BehaviorSubject([{ text: 'test' }]); + const isLocked$ = new BehaviorSubject(false); + const navType$ = new BehaviorSubject('modern' as NavType); + const navLinks$ = new BehaviorSubject([ + { id: 'kibana', title: 'kibana', baseUrl: '', legacy: false }, + ]); + const recentlyAccessed$ = new BehaviorSubject([ + { link: '', label: 'dashboard', id: 'dashboard' }, + ]); + const component = mountWithIntl( +
    + ); + expect(component).toMatchSnapshot(); + + act(() => isVisible$.next(true)); + component.update(); + expect(component).toMatchSnapshot(); + + act(() => isLocked$.next(true)); + component.update(); + expect(component).toMatchSnapshot(); + + act(() => navType$.next('legacy' as NavType)); + component.update(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 6280d68587355..d24b342e0386b 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -28,9 +28,11 @@ import { htmlIdGenerator, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { Component, createRef } from 'react'; import classnames from 'classnames'; -import * as Rx from 'rxjs'; +import React, { createRef, useState } from 'react'; +import { useObservable } from 'react-use'; +import { Observable } from 'rxjs'; +import { LoadingIndicator } from '../'; import { ChromeBadge, ChromeBreadcrumb, @@ -41,192 +43,101 @@ import { import { InternalApplicationStart } from '../../../application/types'; import { HttpStart } from '../../../http'; import { ChromeHelpExtension } from '../../chrome_service'; -import { HeaderBadge } from './header_badge'; import { NavType, OnIsLockedUpdate } from './'; +import { CollapsibleNav } from './collapsible_nav'; +import { HeaderBadge } from './header_badge'; import { HeaderBreadcrumbs } from './header_breadcrumbs'; import { HeaderHelpMenu } from './header_help_menu'; -import { HeaderNavControls } from './header_nav_controls'; -import { createNavLink, createRecentNavLink } from './nav_link'; import { HeaderLogo } from './header_logo'; +import { HeaderNavControls } from './header_nav_controls'; import { NavDrawer } from './nav_drawer'; -import { CollapsibleNav } from './collapsible_nav'; export interface HeaderProps { kibanaVersion: string; application: InternalApplicationStart; - appTitle$: Rx.Observable; - badge$: Rx.Observable; - breadcrumbs$: Rx.Observable; + appTitle$: Observable; + badge$: Observable; + breadcrumbs$: Observable; homeHref: string; - isVisible$: Rx.Observable; + isVisible$: Observable; kibanaDocLink: string; - navLinks$: Rx.Observable; - recentlyAccessed$: Rx.Observable; - forceAppSwitcherNavigation$: Rx.Observable; - helpExtension$: Rx.Observable; - helpSupportUrl$: Rx.Observable; + navLinks$: Observable; + recentlyAccessed$: Observable; + forceAppSwitcherNavigation$: Observable; + helpExtension$: Observable; + helpSupportUrl$: Observable; legacyMode: boolean; - navControlsLeft$: Rx.Observable; - navControlsRight$: Rx.Observable; + navControlsLeft$: Observable; + navControlsRight$: Observable; basePath: HttpStart['basePath']; - isLocked$: Rx.Observable; - navType$: Rx.Observable; + isLocked$: Observable; + navType$: Observable; + loadingCount$: ReturnType; onIsLockedUpdate: OnIsLockedUpdate; } -interface State { - appTitle: string; - isVisible: boolean; - navLinks: ChromeNavLink[]; - recentlyAccessed: ChromeRecentlyAccessedHistoryItem[]; - forceNavigation: boolean; - navControlsLeft: readonly ChromeNavControl[]; - navControlsRight: readonly ChromeNavControl[]; - currentAppId: string | undefined; - isLocked: boolean; - navType: NavType; - isOpen: boolean; +function renderMenuTrigger(toggleOpen: () => void) { + return ( + + + + ); } -export class Header extends Component { - private subscription?: Rx.Subscription; - private navDrawerRef = createRef(); - private toggleCollapsibleNavRef = createRef(); - - constructor(props: HeaderProps) { - super(props); - - let isLocked = false; - props.isLocked$.subscribe((initialIsLocked) => (isLocked = initialIsLocked)); - - this.state = { - appTitle: 'Kibana', - isVisible: true, - navLinks: [], - recentlyAccessed: [], - forceNavigation: false, - navControlsLeft: [], - navControlsRight: [], - currentAppId: '', - isLocked, - navType: 'modern', - isOpen: false, - }; +export function Header({ + kibanaVersion, + kibanaDocLink, + legacyMode, + application, + basePath, + onIsLockedUpdate, + homeHref, + ...observables +}: HeaderProps) { + const isVisible = useObservable(observables.isVisible$, true); + const navType = useObservable(observables.navType$, 'modern'); + const isLocked = useObservable(observables.isLocked$, false); + const [isOpen, setIsOpen] = useState(false); + + if (!isVisible) { + return ; } - public componentDidMount() { - this.subscription = Rx.combineLatest( - this.props.appTitle$, - this.props.isVisible$, - this.props.forceAppSwitcherNavigation$, - this.props.navLinks$, - this.props.recentlyAccessed$, - // Types for combineLatest only handle up to 6 inferred types so we combine these separately. - Rx.combineLatest( - this.props.navControlsLeft$, - this.props.navControlsRight$, - this.props.application.currentAppId$, - this.props.isLocked$, - this.props.navType$ - ) - ).subscribe({ - next: ([ - appTitle, - isVisible, - forceNavigation, - navLinks, - recentlyAccessed, - [navControlsLeft, navControlsRight, currentAppId, isLocked, navType], - ]) => { - this.setState({ - appTitle, - isVisible, - forceNavigation, - navLinks: navLinks.filter((navLink) => !navLink.hidden), - recentlyAccessed, - navControlsLeft, - navControlsRight, - currentAppId, - isLocked, - navType, - }); - }, - }); - } - - public componentWillUnmount() { - if (this.subscription) { - this.subscription.unsubscribe(); + const navDrawerRef = createRef(); + const toggleCollapsibleNavRef = createRef(); + const navId = htmlIdGenerator()(); + const className = classnames( + 'chrHeaderWrapper', // TODO #64541 - delete this + 'hide-for-sharing', + { + 'chrHeaderWrapper--navIsLocked': isLocked, + headerWrapper: navType === 'modern', } - } - - public renderMenuTrigger() { - return ( - this.navDrawerRef.current?.toggleOpen()} - > - - - ); - } - - public render() { - const { appTitle, isVisible, navControlsLeft, navControlsRight } = this.state; - const { - badge$, - breadcrumbs$, - helpExtension$, - helpSupportUrl$, - kibanaDocLink, - kibanaVersion, - } = this.props; - const navLinks = this.state.navLinks.map((link) => - createNavLink( - link, - this.props.legacyMode, - this.state.currentAppId, - this.props.basePath, - this.props.application.navigateToApp - ) - ); - const recentNavLinks = this.state.recentlyAccessed.map((link) => - createRecentNavLink(link, this.state.navLinks, this.props.basePath) - ); + ); - if (!isVisible) { - return null; - } - - const className = classnames( - 'chrHeaderWrapper', // TODO #64541 - delete this - 'hide-for-sharing', - { - 'chrHeaderWrapper--navIsLocked': this.state.isLocked, - headerWrapper: this.state.navType === 'modern', - } - ); - const navId = htmlIdGenerator()(); - return ( + return ( + <> +
    - {this.state.navType === 'modern' ? ( + {navType === 'modern' ? ( { - this.setState({ isOpen: !this.state.isOpen }); - }} - aria-expanded={this.state.isOpen} - aria-pressed={this.state.isOpen} + onClick={() => setIsOpen(!isOpen)} + aria-expanded={isOpen} + aria-pressed={isOpen} aria-controls={navId} - ref={this.toggleCollapsibleNavRef} + ref={toggleCollapsibleNavRef} > @@ -236,71 +147,79 @@ export class Header extends Component { // Delete this block - {this.renderMenuTrigger()} + {renderMenuTrigger(() => navDrawerRef.current?.toggleOpen())} )} - + - + - + - + - {this.state.navType === 'modern' ? ( + {navType === 'modern' ? ( { - this.setState({ isOpen }); - if (this.toggleCollapsibleNavRef.current) { - this.toggleCollapsibleNavRef.current.focus(); + isLocked={isLocked} + navLinks$={observables.navLinks$} + recentlyAccessed$={observables.recentlyAccessed$} + isOpen={isOpen} + homeHref={homeHref} + basePath={basePath} + legacyMode={legacyMode} + navigateToApp={application.navigateToApp} + onIsLockedUpdate={onIsLockedUpdate} + closeNav={() => { + setIsOpen(false); + if (toggleCollapsibleNavRef.current) { + toggleCollapsibleNavRef.current.focus(); } }} - navigateToApp={this.props.application.navigateToApp} /> ) : ( // TODO #64541 // Delete this block )}
    - ); - } + + ); } diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx b/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx index 0398f162f9af9..7fe2c91087090 100644 --- a/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx @@ -19,26 +19,23 @@ import { mount } from 'enzyme'; import React from 'react'; -import * as Rx from 'rxjs'; - -import { ChromeBreadcrumb } from '../../chrome_service'; +import { act } from 'react-dom/test-utils'; +import { BehaviorSubject } from 'rxjs'; import { HeaderBreadcrumbs } from './header_breadcrumbs'; describe('HeaderBreadcrumbs', () => { it('renders updates to the breadcrumbs$ observable', () => { - const breadcrumbs$ = new Rx.Subject(); - const wrapper = mount(); - - breadcrumbs$.next([{ text: 'First' }]); - // Unfortunately, enzyme won't update the wrapper until we call update. - wrapper.update(); + const breadcrumbs$ = new BehaviorSubject([{ text: 'First' }]); + const wrapper = mount( + + ); expect(wrapper.find('.euiBreadcrumb')).toMatchSnapshot(); - breadcrumbs$.next([{ text: 'First' }, { text: 'Second' }]); + act(() => breadcrumbs$.next([{ text: 'First' }, { text: 'Second' }])); wrapper.update(); expect(wrapper.find('.euiBreadcrumb')).toMatchSnapshot(); - breadcrumbs$.next([]); + act(() => breadcrumbs$.next([])); wrapper.update(); expect(wrapper.find('.euiBreadcrumb')).toMatchSnapshot(); }); diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx index 54cfc7131cb2b..174c46981db53 100644 --- a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx @@ -17,88 +17,36 @@ * under the License. */ -import classNames from 'classnames'; -import React, { Component } from 'react'; -import * as Rx from 'rxjs'; - import { EuiHeaderBreadcrumbs } from '@elastic/eui'; +import classNames from 'classnames'; +import React from 'react'; +import { useObservable } from 'react-use'; +import { Observable } from 'rxjs'; import { ChromeBreadcrumb } from '../../chrome_service'; interface Props { - appTitle?: string; - breadcrumbs$: Rx.Observable; + appTitle$: Observable; + breadcrumbs$: Observable; } -interface State { - breadcrumbs: ChromeBreadcrumb[]; -} - -export class HeaderBreadcrumbs extends Component { - private subscription?: Rx.Subscription; - - constructor(props: Props) { - super(props); +export function HeaderBreadcrumbs({ appTitle$, breadcrumbs$ }: Props) { + const appTitle = useObservable(appTitle$, 'Kibana'); + const breadcrumbs = useObservable(breadcrumbs$, []); + let crumbs = breadcrumbs; - this.state = { breadcrumbs: [] }; + if (breadcrumbs.length === 0 && appTitle) { + crumbs = [{ text: appTitle }]; } - public componentDidMount() { - this.subscribe(); - } - - public componentDidUpdate(prevProps: Props) { - if (prevProps.breadcrumbs$ === this.props.breadcrumbs$) { - return; - } + crumbs = crumbs.map((breadcrumb, i) => ({ + ...breadcrumb, + 'data-test-subj': classNames( + 'breadcrumb', + breadcrumb['data-test-subj'], + i === 0 && 'first', + i === breadcrumbs.length - 1 && 'last' + ), + })); - this.unsubscribe(); - this.subscribe(); - } - - public componentWillUnmount() { - this.unsubscribe(); - } - - public render() { - return ( - - ); - } - - private subscribe() { - this.subscription = this.props.breadcrumbs$.subscribe((breadcrumbs) => { - this.setState({ - breadcrumbs, - }); - }); - } - - private unsubscribe() { - if (this.subscription) { - this.subscription.unsubscribe(); - delete this.subscription; - } - } - - private getBreadcrumbs() { - let breadcrumbs = this.state.breadcrumbs; - - if (breadcrumbs.length === 0 && this.props.appTitle) { - breadcrumbs = [{ text: this.props.appTitle }]; - } - - return breadcrumbs.map((breadcrumb, i) => ({ - ...breadcrumb, - 'data-test-subj': classNames( - 'breadcrumb', - breadcrumb['data-test-subj'], - i === 0 && 'first', - i === breadcrumbs.length - 1 && 'last' - ), - })); - } + return ; } diff --git a/src/core/public/chrome/ui/header/header_logo.tsx b/src/core/public/chrome/ui/header/header_logo.tsx index 147c7cf5dc4b1..9bec946b6b76e 100644 --- a/src/core/public/chrome/ui/header/header_logo.tsx +++ b/src/core/public/chrome/ui/header/header_logo.tsx @@ -17,11 +17,13 @@ * under the License. */ -import Url from 'url'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; import { EuiHeaderLogo } from '@elastic/eui'; -import { NavLink } from './nav_link'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useObservable } from 'react-use'; +import { Observable } from 'rxjs'; +import Url from 'url'; +import { ChromeNavLink } from '../..'; function findClosestAnchor(element: HTMLElement): HTMLAnchorElement | void { let current = element; @@ -41,7 +43,7 @@ function findClosestAnchor(element: HTMLElement): HTMLAnchorElement | void { function onClick( event: React.MouseEvent, forceNavigation: boolean, - navLinks: NavLink[], + navLinks: ChromeNavLink[], navigateToApp: (appId: string) => void ) { const anchor = findClosestAnchor((event as any).nativeEvent.target); @@ -50,7 +52,7 @@ function onClick( } const navLink = navLinks.find((item) => item.href === anchor.href); - if (navLink && navLink.isDisabled) { + if (navLink && navLink.disabled) { event.preventDefault(); return; } @@ -85,12 +87,15 @@ function onClick( interface Props { href: string; - navLinks: NavLink[]; - forceNavigation: boolean; + navLinks$: Observable; + forceNavigation$: Observable; navigateToApp: (appId: string) => void; } -export function HeaderLogo({ href, forceNavigation, navLinks, navigateToApp }: Props) { +export function HeaderLogo({ href, navigateToApp, ...observables }: Props) { + const forceNavigation = useObservable(observables.forceNavigation$, false); + const navLinks = useObservable(observables.navLinks$, []); + return ( ; side: 'left' | 'right'; } -export class HeaderNavControls extends Component { - public render() { - const { navControls } = this.props; - - if (!navControls) { - return null; - } +export function HeaderNavControls({ navControls$, side }: Props) { + const navControls = useObservable(navControls$, []); - return navControls.map(this.renderNavControl); + if (!navControls) { + return null; } // It should be performant to use the index as the key since these are unlikely // to change while Kibana is running. - private renderNavControl = (navControl: ChromeNavControl, index: number) => ( - - - + return ( + <> + {navControls.map((navControl: ChromeNavControl, index: number) => ( + + + + ))} + ); } diff --git a/src/core/public/chrome/ui/header/nav_drawer.tsx b/src/core/public/chrome/ui/header/nav_drawer.tsx index 17df8569f6307..ee4bff6cc0ac4 100644 --- a/src/core/public/chrome/ui/header/nav_drawer.tsx +++ b/src/core/public/chrome/ui/header/nav_drawer.tsx @@ -17,24 +17,39 @@ * under the License. */ -import React from 'react'; +import { EuiHorizontalRule, EuiNavDrawer, EuiNavDrawerGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { EuiNavDrawer, EuiHorizontalRule, EuiNavDrawerGroup } from '@elastic/eui'; +import React from 'react'; +import { useObservable } from 'react-use'; +import { Observable } from 'rxjs'; +import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; +import { InternalApplicationStart } from '../../../application/types'; +import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; -import { NavLink, RecentNavLink } from './nav_link'; +import { createEuiListItem, createRecentNavLink } from './nav_link'; import { RecentLinks } from './recent_links'; export interface Props { + appId$: InternalApplicationStart['currentAppId$']; + basePath: HttpStart['basePath']; isLocked?: boolean; + legacyMode: boolean; + navLinks$: Observable; + recentlyAccessed$: Observable; + navigateToApp: CoreStart['application']['navigateToApp']; onIsLockedUpdate?: OnIsLockedUpdate; - navLinks: NavLink[]; - recentNavLinks: RecentNavLink[]; } -function navDrawerRenderer( - { isLocked, onIsLockedUpdate, navLinks, recentNavLinks }: Props, +function NavDrawerRenderer( + { isLocked, onIsLockedUpdate, basePath, legacyMode, navigateToApp, ...observables }: Props, ref: React.Ref ) { + const appId = useObservable(observables.appId$, ''); + const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); + const recentNavLinks = useObservable(observables.recentlyAccessed$, []).map((link) => + createRecentNavLink(link, navLinks, basePath) + ); + return ( + createEuiListItem({ + link, + legacyMode, + appId, + basePath, + navigateToApp, + dataTestSubj: 'navDrawerAppsMenuLink', + }) + )} aria-label={i18n.translate('core.ui.primaryNavList.screenReaderLabel', { defaultMessage: 'Primary navigation links', })} @@ -58,4 +82,4 @@ function navDrawerRenderer( ); } -export const NavDrawer = React.forwardRef(navDrawerRenderer); +export const NavDrawer = React.forwardRef(NavDrawerRenderer); diff --git a/src/core/public/chrome/ui/header/nav_link.tsx b/src/core/public/chrome/ui/header/nav_link.tsx index c979bb8271e1b..c09b15fac9bdb 100644 --- a/src/core/public/chrome/ui/header/nav_link.tsx +++ b/src/core/public/chrome/ui/header/nav_link.tsx @@ -17,12 +17,12 @@ * under the License. */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; import { EuiImage } from '@elastic/eui'; -import { AppCategory } from 'src/core/types'; -import { ChromeNavLink, CoreStart, ChromeRecentlyAccessedHistoryItem } from '../../../'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; import { HttpStart } from '../../../http'; +import { relativeToAbsolute } from '../../nav_links/to_nav_link'; function isModifiedEvent(event: React.MouseEvent) { return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); @@ -32,62 +32,37 @@ function LinkIcon({ url }: { url: string }) { return ; } -export interface NavLink { - key: string; - label: string; - href: string; - isActive: boolean; - onClick(event: React.MouseEvent): void; - category?: AppCategory; - isDisabled?: boolean; - iconType?: string; - icon?: JSX.Element; - order?: number; - 'data-test-subj': string; +interface Props { + link: ChromeNavLink; + legacyMode: boolean; + appId: string | undefined; + basePath?: HttpStart['basePath']; + dataTestSubj: string; + onClick?: Function; + navigateToApp: CoreStart['application']['navigateToApp']; } -/** - * Create a link that's actually ready to be passed into EUI - * - * @param navLink - * @param legacyMode - * @param currentAppId - * @param basePath - * @param navigateToApp - */ -export function createNavLink( - navLink: ChromeNavLink, - legacyMode: boolean, - currentAppId: string | undefined, - basePath: HttpStart['basePath'], - navigateToApp: CoreStart['application']['navigateToApp'] -): NavLink { - const { - legacy, - url, - active, - baseUrl, - id, - title, - disabled, - euiIconType, - icon, - category, - order, - tooltip, - } = navLink; - let href = navLink.url ?? navLink.baseUrl; - - if (legacy) { - href = url && !active ? url : baseUrl; - } +// TODO #64541 +// Set return type to EuiListGroupItemProps +// Currently it's a subset of EuiListGroupItemProps+FlyoutMenuItem for CollapsibleNav and NavDrawer +// But FlyoutMenuItem isn't exported from EUI +export function createEuiListItem({ + link, + legacyMode, + appId, + basePath, + onClick = () => {}, + navigateToApp, + dataTestSubj, +}: Props) { + const { legacy, active, id, title, disabled, euiIconType, icon, tooltip, href } = link; return { - category, - key: id, label: tooltip ?? title, - href, // Use href and onClick to support "open in new tab" and SPA navigation in the same link - onClick(event) { + href, + /* Use href and onClick to support "open in new tab" and SPA navigation in the same link */ + onClick(event: React.MouseEvent) { + onClick(); if ( !legacyMode && // ignore when in legacy mode !legacy && // ignore links to legacy apps @@ -96,57 +71,31 @@ export function createNavLink( !isModifiedEvent(event) // ignore clicks with modifier keys ) { event.preventDefault(); - navigateToApp(navLink.id); + navigateToApp(id); } }, // Legacy apps use `active` property, NP apps should match the current app - isActive: active || currentAppId === id, + isActive: active || appId === id, isDisabled: disabled, - iconType: euiIconType, - icon: !euiIconType && icon ? : undefined, - order, - 'data-test-subj': 'navDrawerAppsMenuLink', + 'data-test-subj': dataTestSubj, + ...(basePath && { + iconType: euiIconType, + icon: !euiIconType && icon ? : undefined, + }), }; } -// Providing a buffer between the limit and the cut off index -// protects from truncating just the last couple (6) characters -const TRUNCATE_LIMIT: number = 64; -const TRUNCATE_AT: number = 58; - -function truncateRecentItemLabel(label: string): string { - if (label.length > TRUNCATE_LIMIT) { - label = `${label.substring(0, TRUNCATE_AT)}…`; - } - - return label; -} - -/** - * @param {string} url - a relative or root relative url. If a relative path is given then the - * absolute url returned will depend on the current page where this function is called from. For example - * if you are on page "http://www.mysite.com/shopping/kids" and you pass this function "adults", you would get - * back "http://www.mysite.com/shopping/adults". If you passed this function a root relative path, or one that - * starts with a "/", for example "/account/cart", you would get back "http://www.mysite.com/account/cart". - * @return {string} the relative url transformed into an absolute url - */ -function relativeToAbsolute(url: string) { - const a = document.createElement('a'); - a.setAttribute('href', url); - return a.href; -} - export interface RecentNavLink { href: string; label: string; title: string; 'aria-label': string; iconType?: string; - onClick?(event: React.MouseEvent): void; } /** * Add saved object type info to recently links + * TODO #64541 - set return type to EuiListGroupItemProps * * Recent nav links are similar to normal nav links but are missing some Kibana Platform magic and * because of legacy reasons have slightly different properties. @@ -176,7 +125,7 @@ export function createRecentNavLink( return { href, - label: truncateRecentItemLabel(label), + label, title: titleAndAriaLabel, 'aria-label': titleAndAriaLabel, iconType: navLink?.euiIconType, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 4ccded9b9afec..90c5dbb5f6558 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -286,6 +286,7 @@ export interface ChromeNavLink { readonly disableSubUrlTracking?: boolean; readonly euiIconType?: string; readonly hidden?: boolean; + readonly href?: string; readonly icon?: string; readonly id: string; // @internal @@ -314,7 +315,7 @@ export interface ChromeNavLinks { } // @public (undocumented) -export type ChromeNavLinkUpdateableFields = Partial>; +export type ChromeNavLinkUpdateableFields = Partial>; // @public export interface ChromeRecentlyAccessed { diff --git a/src/core/server/legacy/types.ts b/src/core/server/legacy/types.ts index 2567ca790e04f..98f8d874c7088 100644 --- a/src/core/server/legacy/types.ts +++ b/src/core/server/legacy/types.ts @@ -151,7 +151,7 @@ export type LegacyAppSpec = Partial & { * @internal * @deprecated */ -export type LegacyNavLink = Omit & { +export type LegacyNavLink = Omit & { order: number; }; diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 32abfd2694f16..142ec9c8c877e 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1366,7 +1366,7 @@ export interface QueryState { // Warning: (ae-missing-release-tag) "QueryStringInput" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const QueryStringInput: React.FC>; +export const QueryStringInput: React.FC>; // @public (undocumented) export type QuerySuggestion = QuerySuggestionBasic | QuerySuggestionField; @@ -1578,8 +1578,8 @@ export const search: { // Warning: (ae-missing-release-tag) "SearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "customSubmitButton" | "screenTitle" | "dataTestSubj" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +export const SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; }; // Warning: (ae-forgotten-export) The symbol "SearchBarOwnProps" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/vis_type_metric/public/components/metric_vis_component.tsx b/src/plugins/vis_type_metric/public/components/metric_vis_component.tsx index eb3986b6388fe..5e8a463748188 100644 --- a/src/plugins/vis_type_metric/public/components/metric_vis_component.tsx +++ b/src/plugins/vis_type_metric/public/components/metric_vis_component.tsx @@ -93,7 +93,7 @@ export class MetricVisComponent extends Component { return false; } - const [red, green, blue] = colors.slice(1).map(parseInt); + const [red, green, blue] = colors.slice(1).map((c) => parseInt(c, 10)); return isColorDark(red, green, blue); } diff --git a/test/accessibility/apps/discover.ts b/test/accessibility/apps/discover.ts index 38552f5ecdafe..7e905fbe89fbd 100644 --- a/test/accessibility/apps/discover.ts +++ b/test/accessibility/apps/discover.ts @@ -34,8 +34,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ['geo.src', 'IN'], ]; - // FLAKY: https://github.com/elastic/kibana/issues/62497 - describe.skip('Discover', () => { + describe('Discover', () => { before(async () => { await esArchiver.load('discover'); await esArchiver.loadIfNeeded('logstash_functional'); @@ -133,9 +132,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // Context view test it('should open context view on a doc', async () => { - await docTable.clickRowToggle(); - // click the open action await retry.try(async () => { + await docTable.clickRowToggle(); + // click the open action const rowActions = await docTable.getRowActions(); if (!rowActions.length) { throw new Error('row actions empty, trying again'); diff --git a/test/scripts/jenkins_xpack.sh b/test/scripts/jenkins_xpack.sh index ebc58c5e4e773..50a92a41e3932 100755 --- a/test/scripts/jenkins_xpack.sh +++ b/test/scripts/jenkins_xpack.sh @@ -21,6 +21,12 @@ if [[ -z "$CODE_COVERAGE" ]] ; then echo "" echo "" + echo " -> Running List cyclic dependency test" + cd "$XPACK_DIR" + checks-reporter-with-killswitch "X-Pack List cyclic dependency test" node plugins/lists/scripts/check_circular_deps + echo "" + echo "" + # echo " -> Running jest integration tests" # cd "$XPACK_DIR" # node scripts/jest_integration --ci --verbose diff --git a/vars/workers.groovy b/vars/workers.groovy index d2cc19787bc5f..387f62a625230 100644 --- a/vars/workers.groovy +++ b/vars/workers.groovy @@ -5,6 +5,8 @@ def label(size) { switch(size) { case 's': return 'linux && immutable' + case 's-highmem': + return 'tests-s' case 'l': return 'tests-l' case 'xl': @@ -114,7 +116,7 @@ def ci(Map params, Closure closure) { // Worker for running the current intake jobs. Just runs a single script after bootstrap. def intake(jobName, String script) { return { - ci(name: jobName, size: 's', ramDisk: false) { + ci(name: jobName, size: 's-highmem', ramDisk: true) { withEnv(["JOB=${jobName}"]) { runbld(script, "Execute ${jobName}") } diff --git a/x-pack/index.js b/x-pack/index.js index 0975a82f16f6d..9cf63854d4093 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -12,7 +12,6 @@ import { dashboardMode } from './legacy/plugins/dashboard_mode'; import { beats } from './legacy/plugins/beats_management'; import { maps } from './legacy/plugins/maps'; import { spaces } from './legacy/plugins/spaces'; -import { encryptedSavedObjects } from './legacy/plugins/encrypted_saved_objects'; import { ingestManager } from './legacy/plugins/ingest_manager'; module.exports = function (kibana) { @@ -25,7 +24,6 @@ module.exports = function (kibana) { dashboardMode(kibana), beats(kibana), maps(kibana), - encryptedSavedObjects(kibana), ingestManager(kibana), ]; }; diff --git a/x-pack/legacy/plugins/encrypted_saved_objects/index.ts b/x-pack/legacy/plugins/encrypted_saved_objects/index.ts deleted file mode 100644 index ce343dba006cf..0000000000000 --- a/x-pack/legacy/plugins/encrypted_saved_objects/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Root } from 'joi'; -import { Legacy } from 'kibana'; -import { EncryptedSavedObjectsPluginSetup } from '../../../plugins/encrypted_saved_objects/server'; -// @ts-ignore -import { AuditLogger } from '../../server/lib/audit_logger'; - -export const encryptedSavedObjects = (kibana: { - Plugin: new (options: Legacy.PluginSpecOptions & { configPrefix?: string }) => unknown; -}) => - new kibana.Plugin({ - id: 'encryptedSavedObjects', - configPrefix: 'xpack.encryptedSavedObjects', - require: ['xpack_main'], - - // Some legacy plugins still use `enabled` config key, so we keep it here, but the rest of the - // keys is handled by the New Platform plugin. - config: (Joi: Root) => - Joi.object({ - enabled: Joi.boolean().default(true), - }) - .unknown(true) - .default(), - - init(server: Legacy.Server) { - const encryptedSavedObjectsPlugin = (server.newPlatform.setup.plugins - .encryptedSavedObjects as unknown) as EncryptedSavedObjectsPluginSetup; - if (!encryptedSavedObjectsPlugin) { - throw new Error('New Platform XPack EncryptedSavedObjects plugin is not available.'); - } - - encryptedSavedObjectsPlugin.__legacyCompat.registerLegacyAPI({ - auditLogger: new AuditLogger( - server, - 'encryptedSavedObjects', - server.config(), - server.plugins.xpack_main.info - ), - }); - }, - }); diff --git a/x-pack/legacy/plugins/maps/public/angular/map_controller.js b/x-pack/legacy/plugins/maps/public/angular/map_controller.js index 91b54d2698c1d..70d5195feef42 100644 --- a/x-pack/legacy/plugins/maps/public/angular/map_controller.js +++ b/x-pack/legacy/plugins/maps/public/angular/map_controller.js @@ -38,7 +38,6 @@ import { setGotoWithCenter, replaceLayerList, setQuery, - clearTransientLayerStateAndCloseFlyout, setMapSettings, enableFullScreen, updateFlyout, @@ -535,7 +534,6 @@ app.controller( addHelpMenuToAppChrome(); async function doSave(saveOptions) { - await store.dispatch(clearTransientLayerStateAndCloseFlyout()); savedMap.syncWithStore(store.getState()); let id; diff --git a/x-pack/legacy/plugins/security/index.ts b/x-pack/legacy/plugins/security/index.ts index 577b23f3418e8..41371fcbc4c65 100644 --- a/x-pack/legacy/plugins/security/index.ts +++ b/x-pack/legacy/plugins/security/index.ts @@ -9,8 +9,6 @@ import { resolve } from 'path'; import { Server } from 'src/legacy/server/kbn_server'; import { KibanaRequest, LegacyRequest } from '../../../../src/core/server'; // @ts-ignore -import { AuditLogger } from '../../server/lib/audit_logger'; -// @ts-ignore import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status_and_license_to_initialize'; import { AuthenticatedUser, SecurityPluginSetup } from '../../../plugins/security/server'; @@ -33,28 +31,24 @@ function getSecurityPluginSetup(server: Server) { export const security = (kibana: Record) => new kibana.Plugin({ id: 'security', - configPrefix: 'xpack.security', publicDir: resolve(__dirname, 'public'), - require: ['kibana', 'elasticsearch', 'xpack_main'], + require: ['kibana', 'xpack_main'], + configPrefix: 'xpack.security', + uiExports: { + hacks: ['plugins/security/hacks/legacy'], + injectDefaultVars: (server: Server) => { + return { enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled') }; + }, + }, - // This config is only used by `AuditLogger` and should be removed as soon as `AuditLogger` - // is migrated to Kibana Platform. config(Joi: Root) { return Joi.object({ enabled: Joi.boolean().default(true), - audit: Joi.object({ enabled: Joi.boolean().default(false) }).default(), }) .unknown() .default(); }, - uiExports: { - hacks: ['plugins/security/hacks/legacy'], - injectDefaultVars: (server: Server) => { - return { enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled') }; - }, - }, - async postInit(server: Server) { watchStatusAndLicenseToInitialize(server.plugins.xpack_main, this, async () => { const xpackInfo = server.plugins.xpack_main.info; @@ -67,11 +61,6 @@ export const security = (kibana: Record) => async init(server: Server) { const securityPlugin = getSecurityPluginSetup(server); - const xpackInfo = server.plugins.xpack_main.info; - securityPlugin.__legacyCompat.registerLegacyAPI({ - auditLogger: new AuditLogger(server, 'security', server.config(), xpackInfo), - }); - server.expose({ getUser: async (request: LegacyRequest) => securityPlugin.authc.getCurrentUser(KibanaRequest.from(request)), diff --git a/x-pack/legacy/plugins/spaces/index.ts b/x-pack/legacy/plugins/spaces/index.ts index 723164480b3b8..2f3e5e0a86d21 100644 --- a/x-pack/legacy/plugins/spaces/index.ts +++ b/x-pack/legacy/plugins/spaces/index.ts @@ -8,20 +8,9 @@ import { resolve } from 'path'; import KbnServer, { Server } from 'src/legacy/server/kbn_server'; import { Legacy } from 'kibana'; import { KibanaRequest } from '../../../../src/core/server'; -import { SpacesServiceSetup } from '../../../plugins/spaces/server'; import { SpacesPluginSetup } from '../../../plugins/spaces/server'; -// @ts-ignore -import { AuditLogger } from '../../server/lib/audit_logger'; import { wrapError } from './server/lib/errors'; -export interface LegacySpacesPlugin { - getSpaceId: (request: Legacy.Request) => ReturnType; - getActiveSpace: (request: Legacy.Request) => ReturnType; - spaceIdToNamespace: SpacesServiceSetup['spaceIdToNamespace']; - namespaceToSpaceId: SpacesServiceSetup['namespaceToSpaceId']; - getBasePath: SpacesServiceSetup['getBasePath']; -} - export const spaces = (kibana: Record) => new kibana.Plugin({ id: 'spaces', @@ -79,15 +68,6 @@ export const spaces = (kibana: Record) => throw new Error('New Platform XPack Spaces plugin is not available.'); } - const { registerLegacyAPI } = spacesPlugin.__legacyCompat; - - registerLegacyAPI({ - auditLogger: { - create: (pluginId: string) => - new AuditLogger(server, pluginId, server.config(), server.plugins.xpack_main.info), - }, - }); - server.expose('getSpaceId', (request: Legacy.Request) => spacesPlugin.spacesService.getSpaceId(request) ); diff --git a/x-pack/legacy/server/lib/audit_logger.js b/x-pack/legacy/server/lib/audit_logger.js deleted file mode 100644 index 7d3467b323b3f..0000000000000 --- a/x-pack/legacy/server/lib/audit_logger.js +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { checkLicense } from './check_license'; -import { LICENSE_TYPE_STANDARD, LICENSE_STATUS_VALID } from '../../common/constants'; - -const FEATURE = { - ID: 'audit_logging', -}; - -export class AuditLogger { - constructor(server, pluginId, config, xPackInfo) { - this._server = server; - this._pluginId = pluginId; - this._enabled = - config.get('xpack.security.enabled') && config.get('xpack.security.audit.enabled'); - this._licensed = false; - this._checkLicense = (xPackInfo) => { - this._licensed = - checkLicense(FEATURE.ID, LICENSE_TYPE_STANDARD, xPackInfo).status === LICENSE_STATUS_VALID; - }; - xPackInfo - .feature(`${FEATURE.ID}-${pluginId}`) - .registerLicenseCheckResultsGenerator(this._checkLicense); - this._checkLicense(xPackInfo); - } - - log(eventType, message, data = {}) { - if (!this._licensed || !this._enabled) { - return; - } - - this._server.logWithMetadata(['info', 'audit', this._pluginId, eventType], message, { - ...data, - eventType, - }); - } -} diff --git a/x-pack/legacy/server/lib/audit_logger.test.js b/x-pack/legacy/server/lib/audit_logger.test.js deleted file mode 100644 index 51a239801caac..0000000000000 --- a/x-pack/legacy/server/lib/audit_logger.test.js +++ /dev/null @@ -1,195 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { AuditLogger } from './audit_logger'; -import { - LICENSE_TYPE_STANDARD, - LICENSE_TYPE_BASIC, - LICENSE_TYPE_GOLD, -} from '../../common/constants'; - -const createMockConfig = (settings) => { - const mockConfig = { - get: jest.fn(), - }; - - mockConfig.get.mockImplementation((key) => { - return settings[key]; - }); - - return mockConfig; -}; - -const mockLicenseInfo = { - isAvailable: () => true, - feature: () => { - return { - registerLicenseCheckResultsGenerator: () => { - return; - }, - }; - }, - license: { - isActive: () => true, - isOneOf: () => true, - getType: () => LICENSE_TYPE_STANDARD, - }, -}; - -const mockConfig = createMockConfig({ - 'xpack.security.enabled': true, - 'xpack.security.audit.enabled': true, -}); - -test(`calls server.log with 'info', audit', pluginId and eventType as tags`, () => { - const mockServer = { - logWithMetadata: jest.fn(), - }; - - const pluginId = 'foo'; - const auditLogger = new AuditLogger(mockServer, pluginId, mockConfig, mockLicenseInfo); - - const eventType = 'bar'; - auditLogger.log(eventType, ''); - expect(mockServer.logWithMetadata).toHaveBeenCalledTimes(1); - expect(mockServer.logWithMetadata).toHaveBeenCalledWith( - ['info', 'audit', pluginId, eventType], - expect.anything(), - expect.anything() - ); -}); - -test(`calls server.log with message`, () => { - const mockServer = { - logWithMetadata: jest.fn(), - }; - const auditLogger = new AuditLogger(mockServer, 'foo', mockConfig, mockLicenseInfo); - - const message = 'summary of what happened'; - auditLogger.log('bar', message); - expect(mockServer.logWithMetadata).toHaveBeenCalledTimes(1); - expect(mockServer.logWithMetadata).toHaveBeenCalledWith( - expect.anything(), - message, - expect.anything() - ); -}); - -test(`calls server.log with metadata `, () => { - const mockServer = { - logWithMetadata: jest.fn(), - }; - - const auditLogger = new AuditLogger(mockServer, 'foo', mockConfig, mockLicenseInfo); - - const data = { - foo: 'yup', - baz: 'nah', - }; - - auditLogger.log('bar', 'summary of what happened', data); - expect(mockServer.logWithMetadata).toHaveBeenCalledTimes(1); - expect(mockServer.logWithMetadata).toHaveBeenCalledWith(expect.anything(), expect.anything(), { - eventType: 'bar', - foo: data.foo, - baz: data.baz, - }); -}); - -test(`does not call server.log for license level < Standard`, () => { - const mockServer = { - logWithMetadata: jest.fn(), - }; - const mockLicenseInfo = { - isAvailable: () => true, - feature: () => { - return { - registerLicenseCheckResultsGenerator: () => { - return; - }, - }; - }, - license: { - isActive: () => true, - isOneOf: () => false, - getType: () => LICENSE_TYPE_BASIC, - }, - }; - - const auditLogger = new AuditLogger(mockServer, 'foo', mockConfig, mockLicenseInfo); - auditLogger.log('bar', 'what happened'); - expect(mockServer.logWithMetadata).toHaveBeenCalledTimes(0); -}); - -test(`does not call server.log if security is not enabled`, () => { - const mockServer = { - logWithMetadata: jest.fn(), - }; - - const mockConfig = createMockConfig({ - 'xpack.security.enabled': false, - 'xpack.security.audit.enabled': true, - }); - - const auditLogger = new AuditLogger(mockServer, 'foo', mockConfig, mockLicenseInfo); - auditLogger.log('bar', 'what happened'); - expect(mockServer.logWithMetadata).toHaveBeenCalledTimes(0); -}); - -test(`does not call server.log if security audit logging is not enabled`, () => { - const mockServer = { - logWithMetadata: jest.fn(), - }; - - const mockConfig = createMockConfig({ - 'xpack.security.enabled': true, - }); - - const auditLogger = new AuditLogger(mockServer, 'foo', mockConfig, mockLicenseInfo); - auditLogger.log('bar', 'what happened'); - expect(mockServer.logWithMetadata).toHaveBeenCalledTimes(0); -}); - -test(`calls server.log after basic -> gold upgrade`, () => { - const mockServer = { - logWithMetadata: jest.fn(), - }; - - const endLicenseInfo = { - isAvailable: () => true, - license: { - isActive: () => true, - isOneOf: () => true, - getType: () => LICENSE_TYPE_GOLD, - }, - }; - - let licenseCheckResultsGenerator; - - const startLicenseInfo = { - isAvailable: () => true, - feature: () => { - return { - registerLicenseCheckResultsGenerator: (fn) => { - licenseCheckResultsGenerator = fn; - }, - }; - }, - license: { - isActive: () => true, - isOneOf: () => false, - getType: () => LICENSE_TYPE_BASIC, - }, - }; - - const auditLogger = new AuditLogger(mockServer, 'foo', mockConfig, startLicenseInfo); - auditLogger.log('bar', 'what happened'); - expect(mockServer.logWithMetadata).toHaveBeenCalledTimes(0); - - //change basic to gold - licenseCheckResultsGenerator(endLicenseInfo); - auditLogger.log('bar', 'what happened'); - expect(mockServer.logWithMetadata).toHaveBeenCalledTimes(1); -}); diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx index dc4413ee98360..988edb197a230 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx @@ -105,10 +105,11 @@ export const TransactionActionMenu: FunctionComponent = ({ if (app === 'uptime' || app === 'metrics' || app === 'logs') { event.preventDefault(); + const search = parsed.search || ''; + + const path = `${rest.join('/')}${search}`; core.application.navigateToApp(app, { - path: `${rest.join('/')}${ - parsed.search ? `&${parsed.search}` : '' - }`, + path, }); } }, diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx index 718f81b3c1027..bad9292f3e768 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/TransactionActionMenu.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { render, fireEvent, act } from '@testing-library/react'; +import { merge, tail } from 'lodash'; import { TransactionActionMenu } from '../TransactionActionMenu'; import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; import * as Transactions from './mockData'; @@ -16,13 +17,43 @@ import { import * as hooks from '../../../../hooks/useFetcher'; import { LicenseContext } from '../../../../context/LicenseContext'; import { License } from '../../../../../../licensing/common/license'; -import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; +import { + MockApmPluginContextWrapper, + mockApmPluginContextValue, +} from '../../../../context/ApmPluginContext/MockApmPluginContext'; import * as apmApi from '../../../../services/rest/createCallApmApi'; +import { ApmPluginContextValue } from '../../../../context/ApmPluginContext'; + +const getMock = () => { + return (merge({}, mockApmPluginContextValue, { + core: { + application: { + navigateToApp: jest.fn(), + }, + http: { + basePath: { + remove: jest.fn((path: string) => { + return tail(path.split('/')).join('/'); + }), + }, + }, + }, + }) as unknown) as ApmPluginContextValue; +}; -const renderTransaction = async (transaction: Record) => { +const renderTransaction = async ( + transaction: Record, + mock: ApmPluginContextValue = getMock() +) => { const rendered = render( , - { wrapper: MockApmPluginContextWrapper } + { + wrapper: ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), + } ); fireEvent.click(rendered.getByText('Actions')); @@ -49,11 +80,21 @@ describe('TransactionActionMenu component', () => { }); it('should always render the trace logs link', async () => { - const { queryByText } = await renderTransaction( - Transactions.transactionWithMinimalData + const mock = getMock(); + + const { queryByText, getByText } = await renderTransaction( + Transactions.transactionWithMinimalData, + mock ); expect(queryByText('Trace logs')).not.toBeNull(); + + fireEvent.click(getByText('Trace logs')); + + expect(mock.core.application.navigateToApp).toHaveBeenCalledWith('logs', { + path: + 'link-to/logs?time=1545092070952&filter=trace.id:%228b60bd32ecc6e1506735a8b6cfcf175c%22%20OR%208b60bd32ecc6e1506735a8b6cfcf175c', + }); }); it('should not render the pod links when there is no pod id', async () => { @@ -66,12 +107,33 @@ describe('TransactionActionMenu component', () => { }); it('should render the pod links when there is a pod id', async () => { - const { queryByText } = await renderTransaction( - Transactions.transactionWithKubernetesData + const mock = getMock(); + + const { queryByText, getByText } = await renderTransaction( + Transactions.transactionWithKubernetesData, + mock ); expect(queryByText('Pod logs')).not.toBeNull(); expect(queryByText('Pod metrics')).not.toBeNull(); + + fireEvent.click(getByText('Pod logs')); + + expect(mock.core.application.navigateToApp).toHaveBeenCalledWith('logs', { + path: 'link-to/pod-logs/pod123456abcdef?time=1545092070952', + }); + + (mock.core.application.navigateToApp as jest.Mock).mockClear(); + + fireEvent.click(getByText('Pod metrics')); + + expect(mock.core.application.navigateToApp).toHaveBeenCalledWith( + 'metrics', + { + path: + 'link-to/pod-detail/pod123456abcdef?from=1545091770952&to=1545092370952', + } + ); }); it('should not render the container links when there is no container id', async () => { @@ -84,12 +146,33 @@ describe('TransactionActionMenu component', () => { }); it('should render the container links when there is a container id', async () => { - const { queryByText } = await renderTransaction( - Transactions.transactionWithContainerData + const mock = getMock(); + + const { queryByText, getByText } = await renderTransaction( + Transactions.transactionWithContainerData, + mock ); expect(queryByText('Container logs')).not.toBeNull(); expect(queryByText('Container metrics')).not.toBeNull(); + + fireEvent.click(getByText('Container logs')); + + expect(mock.core.application.navigateToApp).toHaveBeenCalledWith('logs', { + path: 'link-to/container-logs/container123456abcdef?time=1545092070952', + }); + + (mock.core.application.navigateToApp as jest.Mock).mockClear(); + + fireEvent.click(getByText('Container metrics')); + + expect(mock.core.application.navigateToApp).toHaveBeenCalledWith( + 'metrics', + { + path: + 'link-to/container-detail/container123456abcdef?from=1545091770952&to=1545092370952', + } + ); }); it('should not render the host links when there is no hostname', async () => { @@ -102,12 +185,32 @@ describe('TransactionActionMenu component', () => { }); it('should render the host links when there is a hostname', async () => { - const { queryByText } = await renderTransaction( - Transactions.transactionWithHostData + const mock = getMock(); + const { queryByText, getByText } = await renderTransaction( + Transactions.transactionWithHostData, + mock ); expect(queryByText('Host logs')).not.toBeNull(); expect(queryByText('Host metrics')).not.toBeNull(); + + fireEvent.click(getByText('Host logs')); + + expect(mock.core.application.navigateToApp).toHaveBeenCalledWith('logs', { + path: 'link-to/host-logs/227453131a17?time=1545092070952', + }); + + (mock.core.application.navigateToApp as jest.Mock).mockClear(); + + fireEvent.click(getByText('Host metrics')); + + expect(mock.core.application.navigateToApp).toHaveBeenCalledWith( + 'metrics', + { + path: + 'link-to/host-detail/227453131a17?from=1545091770952&to=1545092370952', + } + ); }); it('should not render the uptime link if there is no url available', async () => { @@ -127,11 +230,20 @@ describe('TransactionActionMenu component', () => { }); it('should render the uptime link if there is a url with a domain', async () => { - const { queryByText } = await renderTransaction( - Transactions.transactionWithUrlAndDomain + const mock = getMock(); + + const { queryByText, getByText } = await renderTransaction( + Transactions.transactionWithUrlAndDomain, + mock ); expect(queryByText('Status')).not.toBeNull(); + + fireEvent.click(getByText('Status')); + + expect(mock.core.application.navigateToApp).toHaveBeenCalledWith('uptime', { + path: '?search=url.domain:%22example.com%22', + }); }); it('should match the snapshot', async () => { diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/sections.ts b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/sections.ts index 60cedfde24258..7f99939a0a0d0 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/sections.ts +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/sections.ts @@ -62,7 +62,7 @@ export const getSections = ({ const uptimeLink = url.format({ pathname: basePath.prepend('/app/uptime'), - hash: `/?${fromQuery( + search: `?${fromQuery( pick( { dateRangeStart: urlParams.rangeFrom, diff --git a/x-pack/plugins/apm/public/context/LoadingIndicatorContext.tsx b/x-pack/plugins/apm/public/context/LoadingIndicatorContext.tsx index c1776d7437e05..32e52f8e396b5 100644 --- a/x-pack/plugins/apm/public/context/LoadingIndicatorContext.tsx +++ b/x-pack/plugins/apm/public/context/LoadingIndicatorContext.tsx @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ import { EuiPortal, EuiProgress } from '@elastic/eui'; +import { pick } from 'lodash'; import React, { Fragment, useMemo, useReducer } from 'react'; import { useDelayedVisibility } from '../components/shared/useDelayedVisibility'; export const LoadingIndicatorContext = React.createContext({ statuses: {}, - dispatchStatus: (action: Action) => undefined as void, + dispatchStatus: (action: Action) => {}, }); interface State { @@ -22,14 +23,13 @@ interface Action { } function reducer(statuses: State, action: Action) { - // add loading status - if (action.isLoading) { - return { ...statuses, [action.id]: true }; - } - - // remove loading status - const { [action.id]: statusToRemove, ...restStatuses } = statuses; - return restStatuses; + // Return an object with only the ids with `true` as their value, so that ids + // that previously had `false` are removed and do not remain hanging around in + // the object. + return pick( + { ...statuses, [action.id.toString()]: action.isLoading }, + Boolean + ); } function getIsAnyLoading(statuses: State) { diff --git a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss index c198884ee7131..7110a22408fe2 100644 --- a/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss +++ b/x-pack/plugins/canvas/public/components/fullscreen/fullscreen.scss @@ -1,5 +1,5 @@ body.canvas-isFullscreen { // sass-lint:disable-line no-qualifying-elements - // following two rules are for overriding the header bar padding + // following two rules are for overriding the header bar padding &.euiBody--headerIsFixed { padding-top: 0; } @@ -8,6 +8,12 @@ body.canvas-isFullscreen { // sass-lint:disable-line no-qualifying-elements min-height: 100vh; } + // following rule is for docked navigation + &.euiBody--collapsibleNavIsDocked { + padding-left: 0 !important; // sass-lint:disable-line no-important + } + + // hide global loading indicator .kbnLoadingIndicator { display: none; diff --git a/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.test.ts b/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.test.ts index 760c4ef01b31c..1d1396fd520d1 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.test.ts @@ -9,7 +9,7 @@ import { mockAuthenticatedUser } from '../../../security/common/model/authentica it('properly logs audit events', () => { const mockInternalAuditLogger = { log: jest.fn() }; - const audit = new EncryptedSavedObjectsAuditLogger(() => mockInternalAuditLogger); + const audit = new EncryptedSavedObjectsAuditLogger(mockInternalAuditLogger); audit.encryptAttributesSuccess(['one', 'two'], { type: 'known-type', diff --git a/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.ts b/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.ts index 1a10dd343d43d..de14a79dd0ddb 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.ts @@ -4,22 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AuditLogger, AuthenticatedUser } from '../../../security/server'; import { SavedObjectDescriptor, descriptorToArray } from '../crypto'; -import { LegacyAPI } from '../plugin'; -import { AuthenticatedUser } from '../../../security/common/model'; /** * Represents all audit events the plugin can log. */ export class EncryptedSavedObjectsAuditLogger { - constructor(private readonly getAuditLogger: () => LegacyAPI['auditLogger']) {} + constructor(private readonly logger: AuditLogger = { log() {} }) {} public encryptAttributeFailure( attributeName: string, descriptor: SavedObjectDescriptor, user?: AuthenticatedUser ) { - this.getAuditLogger().log( + this.logger.log( 'encrypt_failure', `Failed to encrypt attribute "${attributeName}" for saved object "[${descriptorToArray( descriptor @@ -33,7 +32,7 @@ export class EncryptedSavedObjectsAuditLogger { descriptor: SavedObjectDescriptor, user?: AuthenticatedUser ) { - this.getAuditLogger().log( + this.logger.log( 'decrypt_failure', `Failed to decrypt attribute "${attributeName}" for saved object "[${descriptorToArray( descriptor @@ -47,7 +46,7 @@ export class EncryptedSavedObjectsAuditLogger { descriptor: SavedObjectDescriptor, user?: AuthenticatedUser ) { - this.getAuditLogger().log( + this.logger.log( 'encrypt_success', `Successfully encrypted attributes "[${attributesNames}]" for saved object "[${descriptorToArray( descriptor @@ -61,7 +60,7 @@ export class EncryptedSavedObjectsAuditLogger { descriptor: SavedObjectDescriptor, user?: AuthenticatedUser ) { - this.getAuditLogger().log( + this.logger.log( 'decrypt_success', `Successfully decrypted attributes "[${attributesNames}]" for saved object "[${descriptorToArray( descriptor diff --git a/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts b/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts index e8568e9964c2f..4afd74488f9fe 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts @@ -16,9 +16,6 @@ describe('EncryptedSavedObjects Plugin', () => { await expect(plugin.setup(coreMock.createSetup(), { security: securityMock.createSetup() })) .resolves.toMatchInlineSnapshot(` Object { - "__legacyCompat": Object { - "registerLegacyAPI": [Function], - }, "registerType": [Function], "usingEphemeralEncryptionKey": true, } diff --git a/x-pack/plugins/encrypted_saved_objects/server/plugin.ts b/x-pack/plugins/encrypted_saved_objects/server/plugin.ts index 83b412de5db7e..cdbdd18b9d696 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/plugin.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/plugin.ts @@ -22,7 +22,6 @@ export interface PluginsSetup { export interface EncryptedSavedObjectsPluginSetup { registerType: (typeRegistration: EncryptedSavedObjectTypeRegistration) => void; - __legacyCompat: { registerLegacyAPI: (legacyAPI: LegacyAPI) => void }; usingEphemeralEncryptionKey: boolean; } @@ -31,16 +30,6 @@ export interface EncryptedSavedObjectsPluginStart { getClient: ClientInstanciator; } -/** - * Describes a set of APIs that is available in the legacy platform only and required by this plugin - * to function properly. - */ -export interface LegacyAPI { - auditLogger: { - log: (eventType: string, message: string, data?: Record) => void; - }; -} - /** * Represents EncryptedSavedObjects Plugin instance that will be managed by the Kibana plugin system. */ @@ -48,14 +37,6 @@ export class Plugin { private readonly logger: Logger; private savedObjectsSetup!: ClientInstanciator; - private legacyAPI?: LegacyAPI; - private readonly getLegacyAPI = () => { - if (!this.legacyAPI) { - throw new Error('Legacy API is not registered!'); - } - return this.legacyAPI; - }; - constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); } @@ -72,7 +53,9 @@ export class Plugin { new EncryptedSavedObjectsService( config.encryptionKey, this.logger, - new EncryptedSavedObjectsAuditLogger(() => this.getLegacyAPI().auditLogger) + new EncryptedSavedObjectsAuditLogger( + deps.security?.audit.getLogger('encryptedSavedObjects') + ) ) ); @@ -86,7 +69,6 @@ export class Plugin { return { registerType: (typeRegistration: EncryptedSavedObjectTypeRegistration) => service.registerType(typeRegistration), - __legacyCompat: { registerLegacyAPI: (legacyAPI: LegacyAPI) => (this.legacyAPI = legacyAPI) }, usingEphemeralEncryptionKey, }; } diff --git a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx index cc87167b10a96..d81d11e01d4a5 100644 --- a/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx +++ b/x-pack/plugins/infra/public/components/alerting/logs/expression_editor/editor.tsx @@ -17,11 +17,7 @@ import { import { IErrorObject } from '../../../../../../triggers_actions_ui/public/types'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { AlertsContextValue } from '../../../../../../triggers_actions_ui/public/application/context/alerts_context'; -import { - LogDocumentCountAlertParams, - Comparator, - TimeUnit, -} from '../../../../../common/alerting/logs/types'; +import { LogDocumentCountAlertParams, Comparator } from '../../../../../common/alerting/logs/types'; import { DocumentCount } from './document_count'; import { Criteria } from './criteria'; import { useSourceId } from '../../../../containers/source_id'; @@ -123,8 +119,6 @@ export const SourceStatusWrapper: React.FC = (props) => { export const Editor: React.FC = (props) => { const { setAlertParams, alertParams, errors } = props; - const [timeSize, setTimeSize] = useState(1); - const [timeUnit, setTimeUnit] = useState('m'); const [hasSetDefaults, setHasSetDefaults] = useState(false); const { sourceStatus } = useLogSourceContext(); @@ -165,15 +159,13 @@ export const Editor: React.FC = (props) => { const updateTimeSize = useCallback( (ts: number | undefined) => { - setTimeSize(ts || undefined); setAlertParams('timeSize', ts); }, - [setTimeSize, setAlertParams] + [setAlertParams] ); const updateTimeUnit = useCallback( (tu: string) => { - setTimeUnit(tu as TimeUnit); setAlertParams('timeUnit', tu); }, [setAlertParams] @@ -217,8 +209,8 @@ export const Editor: React.FC = (props) => { /> { test('dragover calls preventDefault if droppable is true', () => { const preventDefault = jest.fn(); - const component = shallow(Hello!); + const component = mount(Hello!); component.find('[data-test-subj="lnsDragDrop"]').simulate('dragover', { preventDefault }); @@ -33,7 +33,7 @@ describe('DragDrop', () => { test('dragover does not call preventDefault if droppable is false', () => { const preventDefault = jest.fn(); - const component = shallow(Hello!); + const component = mount(Hello!); component.find('[data-test-subj="lnsDragDrop"]').simulate('dragover', { preventDefault }); diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 72b0d58122405..5a0fc3b3839f7 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -88,11 +88,39 @@ type Props = DraggableProps | NonDraggableProps; * * @param props */ -export function DragDrop(props: Props) { + +export const DragDrop = (props: Props) => { const { dragging, setDragging } = useContext(DragContext); + const { value, draggable, droppable } = props; + return ( + + ); +}; + +const DragDropInner = React.memo(function DragDropInner( + props: Props & { + dragging: unknown; + setDragging: (dragging: unknown) => void; + isDragging: boolean; + } +) { const [state, setState] = useState({ isActive: false }); - const { className, onDrop, value, children, droppable, draggable } = props; - const isDragging = draggable && value === dragging; + const { + className, + onDrop, + value, + children, + droppable, + draggable, + dragging, + setDragging, + isDragging, + } = props; const classes = classNames('lnsDragDrop', className, { 'lnsDragDrop-isDropTarget': droppable, @@ -166,4 +194,4 @@ export function DragDrop(props: Props) { {children} ); -} +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx index 81eb53cd10002..6c00706cc8609 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx @@ -78,7 +78,7 @@ function wrapOnDot(str?: string) { return str ? str.replace(/\./g, '.\u200B') : ''; } -export function FieldItem(props: FieldItemProps) { +export const FieldItem = React.memo(function FieldItem(props: FieldItemProps) { const { core, field, @@ -170,6 +170,10 @@ export function FieldItem(props: FieldItemProps) { } } + const value = React.useMemo(() => ({ field, indexPatternId: indexPattern.id } as DraggedField), [ + field, + indexPattern.id, + ]); return ( ); -} +}); function FieldItemPopoverContents(props: State & FieldItemProps) { const { diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index be74ec352287f..36e8d9660ab70 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -31,6 +31,7 @@ import { ColumnGroups, PieExpressionProps } from './types'; import { getSliceValueWithFallback, getFilterContext } from './render_helpers'; import { EmptyPlaceholder } from '../shared_components'; import './visualization.scss'; +import { desanitizeFilterContext } from '../utils'; const EMPTY_SLICE = Symbol('empty_slice'); @@ -242,7 +243,7 @@ export function PieComponent( firstTable ); - onClickValue(context); + onClickValue(desanitizeFilterContext(context)); }} /> { + it(`When filtered value equals '(empty)' replaces it with '' in table and in value.`, () => { + const table = { + rows: [ + { + 'f903668f-1175-4705-a5bd-713259d10326': 1589414640000, + '5d5446b2-72e8-4f86-91e0-88380f0fa14c': '(empty)', + 'col-1-9f0b6f88-c399-43a0-a993-0ad943c9af25': 1, + }, + { + 'f903668f-1175-4705-a5bd-713259d10326': 1589414670000, + 'col-1-9f0b6f88-c399-43a0-a993-0ad943c9af25': 0, + }, + { + 'f903668f-1175-4705-a5bd-713259d10326': 1589414880000, + '5d5446b2-72e8-4f86-91e0-88380f0fa14c': '123123123', + 'col-1-9f0b6f88-c399-43a0-a993-0ad943c9af25': 1, + }, + { + 'f903668f-1175-4705-a5bd-713259d10326': 1589414910000, + '5d5446b2-72e8-4f86-91e0-88380f0fa14c': '(empty)', + 'col-1-9f0b6f88-c399-43a0-a993-0ad943c9af25': 1, + }, + ], + columns: [ + { + id: 'f903668f-1175-4705-a5bd-713259d10326', + name: 'order_date per 30 seconds', + }, + { + id: '5d5446b2-72e8-4f86-91e0-88380f0fa14c', + name: 'Top values of customer_phone', + }, + { + id: '9f0b6f88-c399-43a0-a993-0ad943c9af25', + name: 'Count of records', + }, + ], + }; + + const contextWithEmptyValue: LensFilterEvent['data'] = { + data: [ + { + row: 3, + column: 0, + value: 1589414910000, + table, + }, + { + row: 0, + column: 1, + value: '(empty)', + table, + }, + ], + timeFieldName: 'order_date', + }; + + const desanitizedFilterContext = desanitizeFilterContext(contextWithEmptyValue); + + expect(desanitizedFilterContext).toEqual({ + data: [ + { + row: 3, + column: 0, + value: 1589414910000, + table, + }, + { + value: '', + row: 0, + column: 1, + table: { + rows: [ + { + 'f903668f-1175-4705-a5bd-713259d10326': 1589414640000, + '5d5446b2-72e8-4f86-91e0-88380f0fa14c': '', + 'col-1-9f0b6f88-c399-43a0-a993-0ad943c9af25': 1, + }, + { + 'f903668f-1175-4705-a5bd-713259d10326': 1589414670000, + 'col-1-9f0b6f88-c399-43a0-a993-0ad943c9af25': 0, + }, + { + 'f903668f-1175-4705-a5bd-713259d10326': 1589414880000, + '5d5446b2-72e8-4f86-91e0-88380f0fa14c': '123123123', + 'col-1-9f0b6f88-c399-43a0-a993-0ad943c9af25': 1, + }, + { + 'f903668f-1175-4705-a5bd-713259d10326': 1589414910000, + '5d5446b2-72e8-4f86-91e0-88380f0fa14c': '(empty)', + 'col-1-9f0b6f88-c399-43a0-a993-0ad943c9af25': 1, + }, + ], + columns: table.columns, + }, + }, + ], + timeFieldName: 'order_date', + }); + }); +}); diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts new file mode 100644 index 0000000000000..171707dcb9d26 --- /dev/null +++ b/x-pack/plugins/lens/public/utils.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { LensFilterEvent } from './types'; + +/** replaces the value `(empty) to empty string for proper filtering` */ +export const desanitizeFilterContext = ( + context: LensFilterEvent['data'] +): LensFilterEvent['data'] => { + const emptyTextValue = i18n.translate('xpack.lens.indexpattern.emptyTextColumnValue', { + defaultMessage: '(empty)', + }); + return { + ...context, + data: context.data.map((point) => + point.value === emptyTextValue + ? { + ...point, + value: '', + table: { + ...point.table, + rows: point.table.rows.map((row, index) => + index === point.row + ? { + ...row, + [point.table.columns[point.column].id]: '', + } + : row + ), + }, + } + : point + ), + }; +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx index 4ad2b2f22c98a..003036b211f03 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -39,6 +39,7 @@ import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart } from './state_helpers'; import { parseInterval } from '../../../../../src/plugins/data/common'; import { EmptyPlaceholder } from '../shared_components'; +import { desanitizeFilterContext } from '../utils'; type InferPropType = T extends React.FunctionComponent ? P : T; type SeriesSpec = InferPropType & @@ -354,7 +355,7 @@ export function XYChart({ })), timeFieldName, }; - onClickValue(context); + onClickValue(desanitizeFilterContext(context)); }} /> diff --git a/x-pack/plugins/lists/common/constants.mock.ts b/x-pack/plugins/lists/common/constants.mock.ts index d174211f348ea..8c5f6b0cbe56c 100644 --- a/x-pack/plugins/lists/common/constants.mock.ts +++ b/x-pack/plugins/lists/common/constants.mock.ts @@ -28,3 +28,4 @@ export const META = {}; export const TYPE = 'ip'; export const VALUE = '127.0.0.1'; export const VALUE_2 = '255.255.255'; +export const NAMESPACE_TYPE = 'single'; diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.ts b/x-pack/plugins/lists/common/schemas/common/schemas.ts index cd69685ffcb1b..14ae030c63df3 100644 --- a/x-pack/plugins/lists/common/schemas/common/schemas.ts +++ b/x-pack/plugins/lists/common/schemas/common/schemas.ts @@ -9,6 +9,7 @@ import * as t from 'io-ts'; import { DefaultStringArray, NonEmptyString } from '../types'; +import { DefaultNamespace } from '../types/default_namespace'; export const name = t.string; export type Name = t.TypeOf; @@ -97,8 +98,38 @@ export const itemIdOrUndefined = t.union([item_id, t.undefined]); export type ItemIdOrUndefined = t.TypeOf; export const per_page = t.number; // TODO: Change this out for PositiveNumber from siem +export type PerPage = t.TypeOf; + +export const perPageOrUndefined = t.union([per_page, t.undefined]); +export type PerPageOrUndefined = t.TypeOf; + export const total = t.number; // TODO: Change this out for PositiveNumber from siem +export const totalUndefined = t.union([total, t.undefined]); +export type TotalOrUndefined = t.TypeOf; + export const page = t.number; // TODO: Change this out for PositiveNumber from siem +export type Page = t.TypeOf; + +export const pageOrUndefined = t.union([page, t.undefined]); +export type PageOrUndefined = t.TypeOf; + export const sort_field = t.string; +export const sortFieldOrUndefined = t.union([sort_field, t.undefined]); +export type SortFieldOrUndefined = t.TypeOf; + export const sort_order = t.keyof({ asc: null, desc: null }); +export const sortOrderOrUndefined = t.union([sort_order, t.undefined]); +export type SortOrderOrUndefined = t.TypeOf; + export const filter = t.string; +export type Filter = t.TypeOf; +export const filterOrUndefined = t.union([filter, t.undefined]); +export type FilterOrUndefined = t.TypeOf; + +export const cursor = t.string; +export type Cursor = t.TypeOf; +export const cursorOrUndefined = t.union([cursor, t.undefined]); +export type CursorOrUndefined = t.TypeOf; + +export const namespace_type = DefaultNamespace; +export type NamespaceType = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts index f899fd69110fa..c10d441d93aa5 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts @@ -10,6 +10,7 @@ import * as t from 'io-ts'; import { ItemId, + NamespaceType, Tags, _Tags, _tags, @@ -19,6 +20,7 @@ import { list_id, meta, name, + namespace_type, tags, } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; @@ -41,6 +43,7 @@ export const createExceptionListItemSchema = t.intersection([ entries: DefaultEntryArray, // defaults to empty array if not set during decode item_id: DefaultUuid, // defaults to GUID (uuid v4) if not set during decode meta, // defaults to undefined if not set during decode + namespace_type, // defaults to 'single' if not set during decode tags, // defaults to empty array if not set during decode }) ), @@ -53,13 +56,16 @@ export type CreateExceptionListItemSchema = RequiredKeepUndefined< t.TypeOf >; -// This type is used after a decode since the arrays turn into defaults of empty arrays -// and if a item_id is not specified it turns into a default GUID +// This type is used after a decode since some things are defaults after a decode. export type CreateExceptionListItemSchemaDecoded = Identity< - Omit & { + Omit< + CreateExceptionListItemSchema, + '_tags' | 'tags' | 'item_id' | 'entries' | 'namespace_type' + > & { _tags: _Tags; tags: Tags; item_id: ItemId; entries: EntriesArray; + namespace_type: NamespaceType; } >; diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts index d38d3cc038525..f0b98cb96f743 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DESCRIPTION, LIST_ID, META, NAME, TYPE } from '../../constants.mock'; +import { DESCRIPTION, LIST_ID, META, NAME, NAMESPACE_TYPE, TYPE } from '../../constants.mock'; import { CreateExceptionListSchema } from './create_exception_list_schema'; @@ -14,6 +14,7 @@ export const getCreateExceptionListSchemaMock = (): CreateExceptionListSchema => list_id: LIST_ID, meta: META, name: NAME, + namespace_type: NAMESPACE_TYPE, tags: [], type: TYPE, }); diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts index 5ba3bf4e8f43b..3da8bfca126ae 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts @@ -10,6 +10,7 @@ import * as t from 'io-ts'; import { ListId, + NamespaceType, Tags, _Tags, _tags, @@ -17,6 +18,7 @@ import { exceptionListType, meta, name, + namespace_type, tags, } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; @@ -35,6 +37,7 @@ export const createExceptionListSchema = t.intersection([ _tags, // defaults to empty array if not set during decode list_id: DefaultUuid, // defaults to a GUID (UUID v4) string if not set during decode meta, // defaults to undefined if not set during decode + namespace_type, // defaults to 'single' if not set during decode tags, // defaults to empty array if not set during decode }) ), @@ -45,11 +48,12 @@ export type CreateExceptionListSchema = RequiredKeepUndefined< t.TypeOf >; -// This type is used after a decode since the arrays turn into defaults of empty arrays. +// This type is used after a decode since some things are defaults after a decode. export type CreateExceptionListSchemaDecoded = Identity< - CreateExceptionListSchema & { + Omit & { _tags: _Tags; tags: Tags; list_id: ListId; + namespace_type: NamespaceType; } >; diff --git a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts index 607e05ef8286f..4c5b70d9a4073 100644 --- a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts @@ -8,13 +8,22 @@ import * as t from 'io-ts'; -import { id, item_id } from '../common/schemas'; +import { NamespaceType, id, item_id, namespace_type } from '../common/schemas'; export const deleteExceptionListItemSchema = t.exact( t.partial({ id, item_id, + namespace_type, // defaults to 'single' if not set during decode }) ); export type DeleteExceptionListItemSchema = t.TypeOf; + +// This type is used after a decode since some things are defaults after a decode. +export type DeleteExceptionListItemSchemaDecoded = Omit< + DeleteExceptionListItemSchema, + 'namespace_type' +> & { + namespace_type: NamespaceType; +}; diff --git a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts index 7a6086514f943..2577d867031f0 100644 --- a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts @@ -8,13 +8,19 @@ import * as t from 'io-ts'; -import { id, list_id } from '../common/schemas'; +import { NamespaceType, id, list_id, namespace_type } from '../common/schemas'; export const deleteExceptionListSchema = t.exact( t.partial({ id, list_id, + namespace_type, // defaults to 'single' if not set during decode }) ); export type DeleteExceptionListSchema = t.TypeOf; + +// This type is used after a decode since some things are defaults after a decode. +export type DeleteExceptionListSchemaDecoded = Omit & { + namespace_type: NamespaceType; +}; diff --git a/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts index 3fc51dd20b0b3..31eb4925eb6d6 100644 --- a/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts @@ -8,8 +8,16 @@ import * as t from 'io-ts'; -import { filter, list_id, page, per_page, sort_field, sort_order } from '../common/schemas'; +import { + NamespaceType, + filter, + list_id, + namespace_type, + sort_field, + sort_order, +} from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; +import { StringToPositiveNumber } from '../types/string_to_positive_number'; export const findExceptionListItemSchema = t.intersection([ t.exact( @@ -20,8 +28,9 @@ export const findExceptionListItemSchema = t.intersection([ t.exact( t.partial({ filter, // defaults to undefined if not set during decode - page, // defaults to undefined if not set during decode - per_page, // defaults to undefined if not set during decode + namespace_type, // defaults to 'single' if not set during decode + page: StringToPositiveNumber, // defaults to undefined if not set during decode + per_page: StringToPositiveNumber, // defaults to undefined if not set during decode sort_field, // defaults to undefined if not set during decode sort_order, // defaults to undefined if not set during decode }) @@ -30,6 +39,19 @@ export const findExceptionListItemSchema = t.intersection([ export type FindExceptionListItemSchemaPartial = t.TypeOf; +// This type is used after a decode since some things are defaults after a decode. +export type FindExceptionListItemSchemaPartialDecoded = Omit< + FindExceptionListItemSchemaPartial, + 'namespace_type' +> & { + namespace_type: NamespaceType; +}; + +// This type is used after a decode since some things are defaults after a decode. +export type FindExceptionListItemSchemaDecoded = RequiredKeepUndefined< + FindExceptionListItemSchemaPartialDecoded +>; + export type FindExceptionListItemSchema = RequiredKeepUndefined< t.TypeOf >; diff --git a/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts index f795be9493fbf..fa00c5b0dafb1 100644 --- a/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts @@ -8,14 +8,16 @@ import * as t from 'io-ts'; -import { filter, page, per_page, sort_field, sort_order } from '../common/schemas'; +import { NamespaceType, filter, namespace_type, sort_field, sort_order } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; +import { StringToPositiveNumber } from '../types/string_to_positive_number'; export const findExceptionListSchema = t.exact( t.partial({ filter, // defaults to undefined if not set during decode - page, // defaults to undefined if not set during decode - per_page, // defaults to undefined if not set during decode + namespace_type, // defaults to 'single' if not set during decode + page: StringToPositiveNumber, // defaults to undefined if not set during decode + per_page: StringToPositiveNumber, // defaults to undefined if not set during decode sort_field, // defaults to undefined if not set during decode sort_order, // defaults to undefined if not set during decode }) @@ -23,6 +25,19 @@ export const findExceptionListSchema = t.exact( export type FindExceptionListSchemaPartial = t.TypeOf; +// This type is used after a decode since some things are defaults after a decode. +export type FindExceptionListSchemaPartialDecoded = Omit< + FindExceptionListSchemaPartial, + 'namespace_type' +> & { + namespace_type: NamespaceType; +}; + +// This type is used after a decode since some things are defaults after a decode. +export type FindExceptionListSchemaDecoded = RequiredKeepUndefined< + FindExceptionListSchemaPartialDecoded +>; + export type FindExceptionListSchema = RequiredKeepUndefined< t.TypeOf >; diff --git a/x-pack/plugins/lists/common/schemas/request/find_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/find_list_item_schema.ts new file mode 100644 index 0000000000000..c9ece4224c4ce --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/request/find_list_item_schema.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable @typescript-eslint/camelcase */ + +import * as t from 'io-ts'; + +import { cursor, filter, list_id, sort_field, sort_order } from '../common/schemas'; +import { Identity, RequiredKeepUndefined } from '../../types'; +import { StringToPositiveNumber } from '../types/string_to_positive_number'; + +export const findListItemSchema = t.intersection([ + t.exact(t.type({ list_id })), + t.exact( + t.partial({ + cursor, // defaults to undefined if not set during decode + filter, // defaults to undefined if not set during decode + page: StringToPositiveNumber, // defaults to undefined if not set during decode + per_page: StringToPositiveNumber, // defaults to undefined if not set during decode + sort_field, // defaults to undefined if not set during decode + sort_order, // defaults to undefined if not set during decode + }) + ), +]); + +export type FindListItemSchemaPartial = Identity>; + +export type FindListItemSchema = RequiredKeepUndefined>; diff --git a/x-pack/plugins/lists/common/schemas/request/find_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/find_list_schema.ts new file mode 100644 index 0000000000000..c29ab4f5360dd --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/request/find_list_schema.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable @typescript-eslint/camelcase */ + +import * as t from 'io-ts'; + +import { cursor, filter, sort_field, sort_order } from '../common/schemas'; +import { RequiredKeepUndefined } from '../../types'; +import { StringToPositiveNumber } from '../types/string_to_positive_number'; + +export const findListSchema = t.exact( + t.partial({ + cursor, // defaults to undefined if not set during decode + filter, // defaults to undefined if not set during decode + page: StringToPositiveNumber, // defaults to undefined if not set during decode + per_page: StringToPositiveNumber, // defaults to undefined if not set during decode + sort_field, // defaults to undefined if not set during decode + sort_order, // defaults to undefined if not set during decode + }) +); + +export type FindListSchemaPartial = t.TypeOf; + +export type FindListSchema = RequiredKeepUndefined>; diff --git a/x-pack/plugins/lists/common/schemas/request/index.ts b/x-pack/plugins/lists/common/schemas/request/index.ts index 0dbd9297b773e..7ab3d943f14da 100644 --- a/x-pack/plugins/lists/common/schemas/request/index.ts +++ b/x-pack/plugins/lists/common/schemas/request/index.ts @@ -15,6 +15,8 @@ export * from './delete_list_schema'; export * from './export_list_item_query_schema'; export * from './find_exception_list_item_schema'; export * from './find_exception_list_schema'; +export * from './find_list_item_schema'; +export * from './find_list_schema'; export * from './import_list_item_schema'; export * from './patch_list_item_schema'; export * from './patch_list_schema'; diff --git a/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts index 095fcd2f63b48..fded35dfd1cc9 100644 --- a/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts @@ -8,13 +8,28 @@ import * as t from 'io-ts'; -import { id, item_id } from '../common/schemas'; +import { NamespaceType, id, item_id, namespace_type } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; export const readExceptionListItemSchema = t.partial({ id, item_id, + namespace_type, // defaults to 'single' if not set during decode }); export type ReadExceptionListItemSchemaPartial = t.TypeOf; + +// This type is used after a decode since some things are defaults after a decode. +export type ReadExceptionListItemSchemaPartialDecoded = Omit< + ReadExceptionListItemSchemaPartial, + 'namespace_type' +> & { + namespace_type: NamespaceType; +}; + +// This type is used after a decode since some things are defaults after a decode. +export type ReadExceptionListItemSchemaDecoded = RequiredKeepUndefined< + ReadExceptionListItemSchemaPartialDecoded +>; + export type ReadExceptionListItemSchema = RequiredKeepUndefined; diff --git a/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts index 5593e640f71ac..6b623ea8c0b9b 100644 --- a/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts @@ -8,13 +8,28 @@ import * as t from 'io-ts'; -import { id, list_id } from '../common/schemas'; +import { NamespaceType, id, list_id, namespace_type } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; export const readExceptionListSchema = t.partial({ id, list_id, + namespace_type, // defaults to 'single' if not set during decode }); export type ReadExceptionListSchemaPartial = t.TypeOf; + +// This type is used after a decode since some things are defaults after a decode. +export type ReadExceptionListSchemaPartialDecoded = Omit< + ReadExceptionListSchemaPartial, + 'namespace_type' +> & { + namespace_type: NamespaceType; +}; + +// This type is used after a decode since some things are defaults after a decode. +export type ReadExceptionListSchemaDecoded = RequiredKeepUndefined< + ReadExceptionListSchemaPartialDecoded +>; + export type ReadExceptionListSchema = RequiredKeepUndefined; diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts index 162406a6d6589..3d66dad959c25 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts @@ -9,6 +9,7 @@ import * as t from 'io-ts'; import { + NamespaceType, Tags, _Tags, _tags, @@ -18,6 +19,7 @@ import { id, meta, name, + namespace_type, tags, } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; @@ -40,6 +42,7 @@ export const updateExceptionListItemSchema = t.intersection([ id, // defaults to undefined if not set during decode item_id: t.union([t.string, t.undefined]), meta, // defaults to undefined if not set during decode + namespace_type, // defaults to 'single' if not set during decode tags, // defaults to empty array if not set during decode }) ), @@ -52,12 +55,12 @@ export type UpdateExceptionListItemSchema = RequiredKeepUndefined< t.TypeOf >; -// This type is used after a decode since the arrays turn into defaults of empty arrays -// and if a item_id is not specified it turns into a default GUID +// This type is used after a decode since some things are defaults after a decode. export type UpdateExceptionListItemSchemaDecoded = Identity< - Omit & { + Omit & { _tags: _Tags; tags: Tags; entries: EntriesArray; + namespace_type: NamespaceType; } >; diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts index e8a0dcd4994a2..76160c3419449 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts @@ -9,6 +9,7 @@ import * as t from 'io-ts'; import { + NamespaceType, Tags, _Tags, _tags, @@ -16,6 +17,7 @@ import { exceptionListType, meta, name, + namespace_type, tags, } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; @@ -34,6 +36,7 @@ export const updateExceptionListSchema = t.intersection([ id: t.union([t.string, t.undefined]), // defaults to undefined if not set during decode list_id: t.union([t.string, t.undefined]), // defaults to undefined if not set during decode meta, // defaults to undefined if not set during decode + namespace_type, // defaults to 'single' if not set during decode tags, // defaults to empty array if not set during decode }) ), @@ -46,8 +49,9 @@ export type UpdateExceptionListSchema = RequiredKeepUndefined< // This type is used after a decode since the arrays turn into defaults of empty arrays. export type UpdateExceptionListSchemaDecoded = Identity< - Omit & { + Omit & { _tags: _Tags; tags: Tags; + namespace_type: NamespaceType; } >; diff --git a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.ts index 15e1c92c06d13..ab405c21d9c77 100644 --- a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.ts @@ -20,6 +20,7 @@ import { list_id, metaOrUndefined, name, + namespace_type, tags, tie_breaker_id, updated_at, @@ -41,6 +42,7 @@ export const exceptionListItemSchema = t.exact( list_id, meta: metaOrUndefined, name, + namespace_type, tags, tie_breaker_id, type: exceptionListItemType, diff --git a/x-pack/plugins/lists/common/schemas/response/exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/response/exception_list_schema.ts index 1940d94597dec..120ed31f87d0d 100644 --- a/x-pack/plugins/lists/common/schemas/response/exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/response/exception_list_schema.ts @@ -18,6 +18,7 @@ import { list_id, metaOrUndefined, name, + namespace_type, tags, tie_breaker_id, updated_at, @@ -35,6 +36,7 @@ export const exceptionListSchema = t.exact( list_id, meta: metaOrUndefined, name, + namespace_type, tags, tie_breaker_id, type: exceptionListType, diff --git a/x-pack/plugins/lists/common/schemas/response/found_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/response/found_list_item_schema.ts new file mode 100644 index 0000000000000..f792774cd0c12 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/response/found_list_item_schema.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable @typescript-eslint/camelcase */ + +import * as t from 'io-ts'; + +import { cursor, page, per_page, total } from '../common/schemas'; + +import { listItemSchema } from './list_item_schema'; + +export const foundListItemSchema = t.exact( + t.type({ + cursor, + data: t.array(listItemSchema), + page, + per_page, + total, + }) +); + +export type FoundListItemSchema = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/response/found_list_schema.ts b/x-pack/plugins/lists/common/schemas/response/found_list_schema.ts new file mode 100644 index 0000000000000..aaf4a721d050d --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/response/found_list_schema.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable @typescript-eslint/camelcase */ + +import * as t from 'io-ts'; + +import { cursor, page, per_page, total } from '../common/schemas'; + +import { listSchema } from './list_schema'; + +export const foundListSchema = t.exact( + t.type({ + cursor, + data: t.array(listSchema), + page, + per_page, + total, + }) +); + +export type FoundListSchema = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/response/index.ts b/x-pack/plugins/lists/common/schemas/response/index.ts index 213685d1183bd..fb6f17a896ddb 100644 --- a/x-pack/plugins/lists/common/schemas/response/index.ts +++ b/x-pack/plugins/lists/common/schemas/response/index.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './list_item_schema'; -export * from './list_schema'; export * from './acknowledge_schema'; -export * from './list_item_index_exist_schema'; export * from './exception_list_schema'; +export * from './exception_list_item_schema'; export * from './found_exception_list_item_schema'; export * from './found_exception_list_schema'; -export * from './exception_list_item_schema'; +export * from './found_list_item_schema'; +export * from './found_list_schema'; +export * from './list_item_schema'; +export * from './list_schema'; +export * from './list_item_index_exist_schema'; diff --git a/x-pack/plugins/lists/common/schemas/response/list_schema.ts b/x-pack/plugins/lists/common/schemas/response/list_schema.ts index cad449766ceb4..4e664685db9c7 100644 --- a/x-pack/plugins/lists/common/schemas/response/list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/response/list_schema.ts @@ -37,3 +37,6 @@ export const listSchema = t.exact( ); export type ListSchema = t.TypeOf; + +export const listArraySchema = t.array(listSchema); +export type ListArraySchema = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/default_namespace.ts b/x-pack/plugins/lists/common/schemas/types/default_namespace.ts new file mode 100644 index 0000000000000..ebe2cd60cf6c8 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_namespace.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +const namespaceType = t.keyof({ agnostic: null, single: null }); + +type NamespaceType = t.TypeOf; + +export type DefaultNamespaceC = t.Type; + +/** + * Types the DefaultNamespace as: + * - If null or undefined, then a default string/enumeration of "single" will be used. + */ +export const DefaultNamespace: DefaultNamespaceC = new t.Type< + NamespaceType, + NamespaceType, + unknown +>( + 'DefaultNamespace', + namespaceType.is, + (input): Either => + input == null ? t.success('single') : namespaceType.decode(input), + t.identity +); diff --git a/x-pack/plugins/lists/common/schemas/types/string_to_positive_number.ts b/x-pack/plugins/lists/common/schemas/types/string_to_positive_number.ts new file mode 100644 index 0000000000000..4b62d6c11d801 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/string_to_positive_number.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either, either } from 'fp-ts/lib/Either'; + +export type StringToPositiveNumberC = t.Type; + +/** + * Types the StrongToPositiveNumber as: + * - If a string this converts the string into a number + * - Ensures it is a number (and not NaN) + * - Ensures it is positive number + */ +export const StringToPositiveNumber: StringToPositiveNumberC = new t.Type( + 'StringToPositiveNumber', + t.number.is, + (input, context): Either => { + return either.chain( + t.string.validate(input, context), + (numberAsString): Either => { + const stringAsNumber = +numberAsString; + if (numberAsString.trim().length === 0 || isNaN(stringAsNumber) || stringAsNumber <= 0) { + return t.failure(input, context); + } else { + return t.success(stringAsNumber); + } + } + ); + }, + String +); diff --git a/x-pack/plugins/lists/public/exceptions/api.test.ts b/x-pack/plugins/lists/public/exceptions/api.test.ts index cc172ee1e6109..3a61140e5621d 100644 --- a/x-pack/plugins/lists/public/exceptions/api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/api.test.ts @@ -68,7 +68,7 @@ describe('Exceptions Lists API', () => { }); expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', { body: - '{"_tags":["endpoint","process","malware","os:linux"],"created_at":"2020-04-23T00:19:13.289Z","created_by":"user_name","description":"This is a sample endpoint type exception","id":"1","list_id":"endpoint_list","meta":{},"name":"Sample Endpoint Exception List","tags":["user added string for a tag","malware"],"tie_breaker_id":"77fd1909-6786-428a-a671-30229a719c1f","type":"endpoint","updated_at":"2020-04-23T00:19:13.289Z","updated_by":"user_name"}', + '{"_tags":["endpoint","process","malware","os:linux"],"created_at":"2020-04-23T00:19:13.289Z","created_by":"user_name","description":"This is a sample endpoint type exception","id":"1","list_id":"endpoint_list","meta":{},"name":"Sample Endpoint Exception List","namespace_type":"single","tags":["user added string for a tag","malware"],"tie_breaker_id":"77fd1909-6786-428a-a671-30229a719c1f","type":"endpoint","updated_at":"2020-04-23T00:19:13.289Z","updated_by":"user_name"}', method: 'PUT', signal: abortCtrl.signal, }); @@ -112,7 +112,7 @@ describe('Exceptions Lists API', () => { }); expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items', { body: - '{"_tags":["endpoint","process","malware","os:linux"],"comment":[],"created_at":"2020-04-23T00:19:13.289Z","created_by":"user_name","description":"This is a sample endpoint type exception","entries":[{"field":"actingProcess.file.signer","match":"Elastic, N.V.","operator":"included"},{"field":"event.category","match_any":["process","malware"],"operator":"included"}],"id":"1","item_id":"endpoint_list_item","list_id":"endpoint_list","meta":{},"name":"Sample Endpoint Exception List","tags":["user added string for a tag","malware"],"tie_breaker_id":"77fd1909-6786-428a-a671-30229a719c1f","type":"simple","updated_at":"2020-04-23T00:19:13.289Z","updated_by":"user_name"}', + '{"_tags":["endpoint","process","malware","os:linux"],"comment":[],"created_at":"2020-04-23T00:19:13.289Z","created_by":"user_name","description":"This is a sample endpoint type exception","entries":[{"field":"actingProcess.file.signer","match":"Elastic, N.V.","operator":"included"},{"field":"event.category","match_any":["process","malware"],"operator":"included"}],"id":"1","item_id":"endpoint_list_item","list_id":"endpoint_list","meta":{},"name":"Sample Endpoint Exception List","namespace_type":"single","tags":["user added string for a tag","malware"],"tie_breaker_id":"77fd1909-6786-428a-a671-30229a719c1f","type":"simple","updated_at":"2020-04-23T00:19:13.289Z","updated_by":"user_name"}', method: 'PUT', signal: abortCtrl.signal, }); diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx index a4390ac07a5a0..308d1cf4d1b17 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.tsx @@ -69,6 +69,7 @@ describe('useExceptionList', () => { list_id: 'endpoint_list', meta: {}, name: 'Sample Endpoint Exception List', + namespace_type: 'single', tags: ['user added string for a tag', 'malware'], tie_breaker_id: '77fd1909-6786-428a-a671-30229a719c1f', type: 'simple', @@ -84,6 +85,7 @@ describe('useExceptionList', () => { list_id: 'endpoint_list', meta: {}, name: 'Sample Endpoint Exception List', + namespace_type: 'single', tags: ['user added string for a tag', 'malware'], tie_breaker_id: '77fd1909-6786-428a-a671-30229a719c1f', type: 'endpoint', diff --git a/x-pack/plugins/lists/public/exceptions/mock.ts b/x-pack/plugins/lists/public/exceptions/mock.ts index 6980051238973..38a0e65992982 100644 --- a/x-pack/plugins/lists/public/exceptions/mock.ts +++ b/x-pack/plugins/lists/public/exceptions/mock.ts @@ -19,6 +19,7 @@ export const mockExceptionList: ExceptionListSchema = { list_id: 'endpoint_list', meta: {}, name: 'Sample Endpoint Exception List', + namespace_type: 'single', tags: ['user added string for a tag', 'malware'], tie_breaker_id: '77fd1909-6786-428a-a671-30229a719c1f', type: 'endpoint', @@ -84,6 +85,7 @@ export const mockExceptionItem: ExceptionListItemSchema = { list_id: 'endpoint_list', meta: {}, name: 'Sample Endpoint Exception List', + namespace_type: 'single', tags: ['user added string for a tag', 'malware'], tie_breaker_id: '77fd1909-6786-428a-a671-30229a719c1f', type: 'simple', diff --git a/x-pack/plugins/lists/scripts/check_circular_deps.js b/x-pack/plugins/lists/scripts/check_circular_deps.js new file mode 100644 index 0000000000000..4ba7020d13465 --- /dev/null +++ b/x-pack/plugins/lists/scripts/check_circular_deps.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +require('../../../../src/setup_node_env'); +require('./check_circular_deps/run_check_circular_deps_cli'); diff --git a/x-pack/plugins/lists/scripts/check_circular_deps/run_check_circular_deps_cli.ts b/x-pack/plugins/lists/scripts/check_circular_deps/run_check_circular_deps_cli.ts new file mode 100644 index 0000000000000..430e4983882cb --- /dev/null +++ b/x-pack/plugins/lists/scripts/check_circular_deps/run_check_circular_deps_cli.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve } from 'path'; + +// @ts-ignore +import madge from 'madge'; +import { createFailError, run } from '@kbn/dev-utils'; + +run( + async ({ log }) => { + const result = await madge( + [resolve(__dirname, '../../public'), resolve(__dirname, '../../common')], + { + excludeRegExp: [ + 'test.ts$', + 'test.tsx$', + 'src/core/server/types.ts$', + 'src/core/server/saved_objects/types.ts$', + 'src/core/public/chrome/chrome_service.tsx$', + 'src/core/public/overlays/banners/banners_service.tsx$', + 'src/core/public/saved_objects/saved_objects_client.ts$', + 'src/plugins/data/public', + 'src/plugins/ui_actions/public', + ], + fileExtensions: ['ts', 'js', 'tsx'], + } + ); + + const circularFound = result.circular(); + if (circularFound.length !== 0) { + throw createFailError( + `Lists circular dependencies of imports has been found:\n - ${circularFound.join('\n - ')}` + ); + } else { + log.success('No circular deps 👍'); + } + }, + { + description: 'Check the Lists plugin for circular deps', + } +); diff --git a/x-pack/plugins/lists/server/index.ts b/x-pack/plugins/lists/server/index.ts index c1e577aa60195..33f58ba65d3c3 100644 --- a/x-pack/plugins/lists/server/index.ts +++ b/x-pack/plugins/lists/server/index.ts @@ -9,6 +9,10 @@ import { PluginInitializerContext } from '../../../../src/core/server'; import { ConfigSchema } from './config'; import { ListPlugin } from './plugin'; +// exporting these since its required at top level in siem plugin +export { ListClient } from './services/lists/list_client'; +export { ListPluginSetup } from './types'; + export const config = { schema: ConfigSchema }; export const plugin = (initializerContext: PluginInitializerContext): ListPlugin => new ListPlugin(initializerContext); diff --git a/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts index ddcae137a961a..e914d816b5e91 100644 --- a/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts @@ -39,6 +39,7 @@ export const createExceptionListItemRoute = (router: IRouter): void => { const siemResponse = buildSiemResponse(response); try { const { + namespace_type: namespaceType, name, _tags, tags, @@ -54,8 +55,7 @@ export const createExceptionListItemRoute = (router: IRouter): void => { const exceptionList = await exceptionLists.getExceptionList({ id: undefined, listId, - // TODO: Expose the name space type - namespaceType: 'single', + namespaceType, }); if (exceptionList == null) { return siemResponse.error({ @@ -66,8 +66,7 @@ export const createExceptionListItemRoute = (router: IRouter): void => { const exceptionListItem = await exceptionLists.getExceptionListItem({ id: undefined, itemId, - // TODO: Expose the name space type - namespaceType: 'single', + namespaceType, }); if (exceptionListItem != null) { return siemResponse.error({ @@ -84,8 +83,7 @@ export const createExceptionListItemRoute = (router: IRouter): void => { listId, meta, name, - // TODO: Expose the name space type - namespaceType: 'single', + namespaceType, tags, type, }); diff --git a/x-pack/plugins/lists/server/routes/create_exception_list_route.ts b/x-pack/plugins/lists/server/routes/create_exception_list_route.ts index c8a1b080c16f6..9be6b72dcd255 100644 --- a/x-pack/plugins/lists/server/routes/create_exception_list_route.ts +++ b/x-pack/plugins/lists/server/routes/create_exception_list_route.ts @@ -38,13 +38,21 @@ export const createExceptionListRoute = (router: IRouter): void => { async (context, request, response) => { const siemResponse = buildSiemResponse(response); try { - const { name, _tags, tags, meta, description, list_id: listId, type } = request.body; + const { + name, + _tags, + tags, + meta, + namespace_type: namespaceType, + description, + list_id: listId, + type, + } = request.body; const exceptionLists = getExceptionListClient(context); const exceptionList = await exceptionLists.getExceptionList({ id: undefined, listId, - // TODO: Expose the name space type - namespaceType: 'single', + namespaceType, }); if (exceptionList != null) { return siemResponse.error({ @@ -58,8 +66,7 @@ export const createExceptionListRoute = (router: IRouter): void => { listId, meta, name, - // TODO: Expose the name space type - namespaceType: 'single', + namespaceType, tags, type, }); diff --git a/x-pack/plugins/lists/server/routes/delete_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/delete_exception_list_item_route.ts index e10ffab5359b0..2c91fe3c28681 100644 --- a/x-pack/plugins/lists/server/routes/delete_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/delete_exception_list_item_route.ts @@ -13,7 +13,11 @@ import { transformError, validate, } from '../siem_server_deps'; -import { deleteExceptionListItemSchema, exceptionListItemSchema } from '../../common/schemas'; +import { + DeleteExceptionListItemSchemaDecoded, + deleteExceptionListItemSchema, + exceptionListItemSchema, +} from '../../common/schemas'; import { getErrorMessageExceptionListItem, getExceptionListClient } from './utils'; @@ -25,14 +29,17 @@ export const deleteExceptionListItemRoute = (router: IRouter): void => { }, path: EXCEPTION_LIST_ITEM_URL, validate: { - query: buildRouteValidation(deleteExceptionListItemSchema), + query: buildRouteValidation< + typeof deleteExceptionListItemSchema, + DeleteExceptionListItemSchemaDecoded + >(deleteExceptionListItemSchema), }, }, async (context, request, response) => { const siemResponse = buildSiemResponse(response); try { const exceptionLists = getExceptionListClient(context); - const { item_id: itemId, id } = request.query; + const { item_id: itemId, id, namespace_type: namespaceType } = request.query; if (itemId == null && id == null) { return siemResponse.error({ body: 'Either "item_id" or "id" needs to be defined in the request', @@ -42,7 +49,7 @@ export const deleteExceptionListItemRoute = (router: IRouter): void => { const deleted = await exceptionLists.deleteExceptionListItem({ id, itemId, - namespaceType: 'single', // TODO: Bubble this up + namespaceType, }); if (deleted == null) { return siemResponse.error({ diff --git a/x-pack/plugins/lists/server/routes/delete_exception_list_route.ts b/x-pack/plugins/lists/server/routes/delete_exception_list_route.ts index ef30ab6ab64c5..b4c67c0ab1418 100644 --- a/x-pack/plugins/lists/server/routes/delete_exception_list_route.ts +++ b/x-pack/plugins/lists/server/routes/delete_exception_list_route.ts @@ -13,7 +13,11 @@ import { transformError, validate, } from '../siem_server_deps'; -import { deleteExceptionListSchema, exceptionListSchema } from '../../common/schemas'; +import { + DeleteExceptionListSchemaDecoded, + deleteExceptionListSchema, + exceptionListSchema, +} from '../../common/schemas'; import { getErrorMessageExceptionList, getExceptionListClient } from './utils'; @@ -25,25 +29,27 @@ export const deleteExceptionListRoute = (router: IRouter): void => { }, path: EXCEPTION_LIST_URL, validate: { - query: buildRouteValidation(deleteExceptionListSchema), + query: buildRouteValidation< + typeof deleteExceptionListSchema, + DeleteExceptionListSchemaDecoded + >(deleteExceptionListSchema), }, }, async (context, request, response) => { const siemResponse = buildSiemResponse(response); try { const exceptionLists = getExceptionListClient(context); - const { list_id: listId, id } = request.query; + const { list_id: listId, id, namespace_type: namespaceType } = request.query; if (listId == null && id == null) { return siemResponse.error({ body: 'Either "list_id" or "id" needs to be defined in the request', statusCode: 400, }); } else { - // TODO: At the moment this will delete the list but we need to delete all the list items before deleting the list const deleted = await exceptionLists.deleteExceptionList({ id, listId, - namespaceType: 'single', + namespaceType, }); if (deleted == null) { return siemResponse.error({ diff --git a/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts index 3b5503ffb9833..1820ffdeadb88 100644 --- a/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts @@ -13,7 +13,11 @@ import { transformError, validate, } from '../siem_server_deps'; -import { findExceptionListItemSchema, foundExceptionListItemSchema } from '../../common/schemas'; +import { + FindExceptionListItemSchemaDecoded, + findExceptionListItemSchema, + foundExceptionListItemSchema, +} from '../../common/schemas'; import { getExceptionListClient } from './utils'; @@ -25,7 +29,10 @@ export const findExceptionListItemRoute = (router: IRouter): void => { }, path: `${EXCEPTION_LIST_ITEM_URL}/_find`, validate: { - query: buildRouteValidation(findExceptionListItemSchema), + query: buildRouteValidation< + typeof findExceptionListItemSchema, + FindExceptionListItemSchemaDecoded + >(findExceptionListItemSchema), }, }, async (context, request, response) => { @@ -35,6 +42,7 @@ export const findExceptionListItemRoute = (router: IRouter): void => { const { filter, list_id: listId, + namespace_type: namespaceType, page, per_page: perPage, sort_field: sortField, @@ -43,7 +51,7 @@ export const findExceptionListItemRoute = (router: IRouter): void => { const exceptionListItems = await exceptionLists.findExceptionListItem({ filter, listId, - namespaceType: 'single', // TODO: Bubble this up + namespaceType, page, perPage, sortField, diff --git a/x-pack/plugins/lists/server/routes/find_exception_list_route.ts b/x-pack/plugins/lists/server/routes/find_exception_list_route.ts index 41c0c0760e03b..3181deda8b91d 100644 --- a/x-pack/plugins/lists/server/routes/find_exception_list_route.ts +++ b/x-pack/plugins/lists/server/routes/find_exception_list_route.ts @@ -13,7 +13,11 @@ import { transformError, validate, } from '../siem_server_deps'; -import { findExceptionListSchema, foundExceptionListSchema } from '../../common/schemas'; +import { + FindExceptionListSchemaDecoded, + findExceptionListSchema, + foundExceptionListSchema, +} from '../../common/schemas'; import { getExceptionListClient } from './utils'; @@ -25,7 +29,9 @@ export const findExceptionListRoute = (router: IRouter): void => { }, path: `${EXCEPTION_LIST_URL}/_find`, validate: { - query: buildRouteValidation(findExceptionListSchema), + query: buildRouteValidation( + findExceptionListSchema + ), }, }, async (context, request, response) => { @@ -35,13 +41,14 @@ export const findExceptionListRoute = (router: IRouter): void => { const { filter, page, + namespace_type: namespaceType, per_page: perPage, sort_field: sortField, sort_order: sortOrder, } = request.query; const exceptionListItems = await exceptionLists.findExceptionList({ filter, - namespaceType: 'single', // TODO: Bubble this up + namespaceType, page, perPage, sortField, diff --git a/x-pack/plugins/lists/server/routes/find_list_item_route.ts b/x-pack/plugins/lists/server/routes/find_list_item_route.ts new file mode 100644 index 0000000000000..37b5fe44b919c --- /dev/null +++ b/x-pack/plugins/lists/server/routes/find_list_item_route.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'kibana/server'; + +import { LIST_ITEM_URL } from '../../common/constants'; +import { + buildRouteValidation, + buildSiemResponse, + transformError, + validate, +} from '../siem_server_deps'; +import { findListItemSchema, foundListItemSchema } from '../../common/schemas'; +import { decodeCursor } from '../services/utils'; + +import { getListClient } from './utils'; + +export const findListItemRoute = (router: IRouter): void => { + router.get( + { + options: { + tags: ['access:lists'], + }, + path: `${LIST_ITEM_URL}/_find`, + validate: { + query: buildRouteValidation(findListItemSchema), + }, + }, + async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + try { + const lists = getListClient(context); + const { + cursor, + filter: filterOrUndefined, + list_id: listId, + page: pageOrUndefined, + per_page: perPageOrUndefined, + sort_field: sortField, + sort_order: sortOrder, + } = request.query; + + const page = pageOrUndefined ?? 1; + const perPage = perPageOrUndefined ?? 20; + const filter = filterOrUndefined ?? ''; + const { + isValid, + errorMessage, + cursor: [currentIndexPosition, searchAfter], + } = decodeCursor({ + cursor, + page, + perPage, + sortField, + }); + + if (!isValid) { + return siemResponse.error({ + body: errorMessage, + statusCode: 400, + }); + } else { + const exceptionList = await lists.findListItem({ + currentIndexPosition, + filter, + listId, + page, + perPage, + searchAfter, + sortField, + sortOrder, + }); + if (exceptionList == null) { + return siemResponse.error({ + body: `list id: "${listId}" does not exist`, + statusCode: 404, + }); + } else { + const [validated, errors] = validate(exceptionList, foundListItemSchema); + if (errors != null) { + return siemResponse.error({ body: errors, statusCode: 500 }); + } else { + return response.ok({ body: validated ?? {} }); + } + } + } + } catch (err) { + const error = transformError(err); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/lists/server/routes/find_list_route.ts b/x-pack/plugins/lists/server/routes/find_list_route.ts new file mode 100644 index 0000000000000..04b33e3d67075 --- /dev/null +++ b/x-pack/plugins/lists/server/routes/find_list_route.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'kibana/server'; + +import { LIST_URL } from '../../common/constants'; +import { + buildRouteValidation, + buildSiemResponse, + transformError, + validate, +} from '../siem_server_deps'; +import { findListSchema, foundListSchema } from '../../common/schemas'; +import { decodeCursor } from '../services/utils'; + +import { getListClient } from './utils'; + +export const findListRoute = (router: IRouter): void => { + router.get( + { + options: { + tags: ['access:lists'], + }, + path: `${LIST_URL}/_find`, + validate: { + query: buildRouteValidation(findListSchema), + }, + }, + async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + try { + const lists = getListClient(context); + const { + cursor, + filter: filterOrUndefined, + page: pageOrUndefined, + per_page: perPageOrUndefined, + sort_field: sortField, + sort_order: sortOrder, + } = request.query; + + const page = pageOrUndefined ?? 1; + const perPage = perPageOrUndefined ?? 20; + const filter = filterOrUndefined ?? ''; + const { + isValid, + errorMessage, + cursor: [currentIndexPosition, searchAfter], + } = decodeCursor({ + cursor, + page, + perPage, + sortField, + }); + if (!isValid) { + return siemResponse.error({ + body: errorMessage, + statusCode: 400, + }); + } else { + const exceptionList = await lists.findList({ + currentIndexPosition, + filter, + page, + perPage, + searchAfter, + sortField, + sortOrder, + }); + const [validated, errors] = validate(exceptionList, foundListSchema); + if (errors != null) { + return siemResponse.error({ body: errors, statusCode: 500 }); + } else { + return response.ok({ body: validated ?? {} }); + } + } + } catch (err) { + const error = transformError(err); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/lists/server/routes/index.ts b/x-pack/plugins/lists/server/routes/index.ts index 97f497bca7183..72117c46213fe 100644 --- a/x-pack/plugins/lists/server/routes/index.ts +++ b/x-pack/plugins/lists/server/routes/index.ts @@ -17,6 +17,8 @@ export * from './delete_list_route'; export * from './export_list_item_route'; export * from './find_exception_list_item_route'; export * from './find_exception_list_route'; +export * from './find_list_item_route'; +export * from './find_list_route'; export * from './import_list_item_route'; export * from './init_routes'; export * from './patch_list_item_route'; diff --git a/x-pack/plugins/lists/server/routes/init_routes.ts b/x-pack/plugins/lists/server/routes/init_routes.ts index 16f96d99505d8..e74fa471734b0 100644 --- a/x-pack/plugins/lists/server/routes/init_routes.ts +++ b/x-pack/plugins/lists/server/routes/init_routes.ts @@ -20,6 +20,8 @@ import { exportListItemRoute, findExceptionListItemRoute, findExceptionListRoute, + findListItemRoute, + findListRoute, importListItemRoute, patchListItemRoute, patchListRoute, @@ -41,6 +43,7 @@ export const initRoutes = (router: IRouter): void => { updateListRoute(router); deleteListRoute(router); patchListRoute(router); + findListRoute(router); // list items createListItemRoute(router); @@ -50,6 +53,7 @@ export const initRoutes = (router: IRouter): void => { patchListItemRoute(router); exportListItemRoute(router); importListItemRoute(router); + findListItemRoute(router); // indexes of lists createListIndexRoute(router); diff --git a/x-pack/plugins/lists/server/routes/read_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/read_exception_list_item_route.ts index 77d37373549c7..083d4d7a0d479 100644 --- a/x-pack/plugins/lists/server/routes/read_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/read_exception_list_item_route.ts @@ -13,7 +13,11 @@ import { transformError, validate, } from '../siem_server_deps'; -import { exceptionListItemSchema, readExceptionListItemSchema } from '../../common/schemas'; +import { + ReadExceptionListItemSchemaDecoded, + exceptionListItemSchema, + readExceptionListItemSchema, +} from '../../common/schemas'; import { getErrorMessageExceptionListItem, getExceptionListClient } from './utils'; @@ -25,20 +29,22 @@ export const readExceptionListItemRoute = (router: IRouter): void => { }, path: EXCEPTION_LIST_ITEM_URL, validate: { - query: buildRouteValidation(readExceptionListItemSchema), + query: buildRouteValidation< + typeof readExceptionListItemSchema, + ReadExceptionListItemSchemaDecoded + >(readExceptionListItemSchema), }, }, async (context, request, response) => { const siemResponse = buildSiemResponse(response); try { - const { id, item_id: itemId } = request.query; + const { id, item_id: itemId, namespace_type: namespaceType } = request.query; const exceptionLists = getExceptionListClient(context); if (id != null || itemId != null) { const exceptionListItem = await exceptionLists.getExceptionListItem({ id, itemId, - // TODO: Bubble this up - namespaceType: 'single', + namespaceType, }); if (exceptionListItem == null) { return siemResponse.error({ diff --git a/x-pack/plugins/lists/server/routes/read_exception_list_route.ts b/x-pack/plugins/lists/server/routes/read_exception_list_route.ts index 1668124acdfce..c295f045b38c2 100644 --- a/x-pack/plugins/lists/server/routes/read_exception_list_route.ts +++ b/x-pack/plugins/lists/server/routes/read_exception_list_route.ts @@ -13,7 +13,11 @@ import { transformError, validate, } from '../siem_server_deps'; -import { exceptionListSchema, readExceptionListSchema } from '../../common/schemas'; +import { + ReadExceptionListSchemaDecoded, + exceptionListSchema, + readExceptionListSchema, +} from '../../common/schemas'; import { getErrorMessageExceptionList, getExceptionListClient } from './utils'; @@ -25,20 +29,21 @@ export const readExceptionListRoute = (router: IRouter): void => { }, path: EXCEPTION_LIST_URL, validate: { - query: buildRouteValidation(readExceptionListSchema), + query: buildRouteValidation( + readExceptionListSchema + ), }, }, async (context, request, response) => { const siemResponse = buildSiemResponse(response); try { - const { id, list_id: listId } = request.query; + const { id, list_id: listId, namespace_type: namespaceType } = request.query; const exceptionLists = getExceptionListClient(context); if (id != null || listId != null) { const exceptionList = await exceptionLists.getExceptionList({ id, listId, - // TODO: Bubble this up - namespaceType: 'single', + namespaceType, }); if (exceptionList == null) { return siemResponse.error({ diff --git a/x-pack/plugins/lists/server/routes/read_list_index_route.ts b/x-pack/plugins/lists/server/routes/read_list_index_route.ts index 248fc72666d70..21f539d97fc74 100644 --- a/x-pack/plugins/lists/server/routes/read_list_index_route.ts +++ b/x-pack/plugins/lists/server/routes/read_list_index_route.ts @@ -31,7 +31,7 @@ export const readListIndexRoute = (router: IRouter): void => { if (listIndexExists || listItemIndexExists) { const [validated, errors] = validate( - { list_index: listIndexExists, lists_item_index: listItemIndexExists }, + { list_index: listIndexExists, list_item_index: listItemIndexExists }, listItemIndexExistSchema ); if (errors != null) { diff --git a/x-pack/plugins/lists/server/routes/update_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/update_exception_list_item_route.ts index 478225ee35eb8..14b97bbe15206 100644 --- a/x-pack/plugins/lists/server/routes/update_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/update_exception_list_item_route.ts @@ -48,6 +48,7 @@ export const updateExceptionListItemRoute = (router: IRouter): void => { comment, entries, item_id: itemId, + namespace_type: namespaceType, tags, } = request.body; const exceptionLists = getExceptionListClient(context); @@ -60,7 +61,7 @@ export const updateExceptionListItemRoute = (router: IRouter): void => { itemId, meta, name, - namespaceType: 'single', // TODO: Bubble this up + namespaceType, tags, type, }); diff --git a/x-pack/plugins/lists/server/routes/update_exception_list_route.ts b/x-pack/plugins/lists/server/routes/update_exception_list_route.ts index a112c7422b952..fe45d403c040f 100644 --- a/x-pack/plugins/lists/server/routes/update_exception_list_route.ts +++ b/x-pack/plugins/lists/server/routes/update_exception_list_route.ts @@ -38,7 +38,17 @@ export const updateExceptionListRoute = (router: IRouter): void => { async (context, request, response) => { const siemResponse = buildSiemResponse(response); try { - const { _tags, tags, name, description, id, list_id: listId, meta, type } = request.body; + const { + _tags, + tags, + name, + description, + id, + list_id: listId, + meta, + namespace_type: namespaceType, + type, + } = request.body; const exceptionLists = getExceptionListClient(context); if (id == null && listId == null) { return siemResponse.error({ @@ -53,7 +63,7 @@ export const updateExceptionListRoute = (router: IRouter): void => { listId, meta, name, - namespaceType: 'single', // TODO: Bubble this up + namespaceType, tags, type, }); diff --git a/x-pack/plugins/lists/server/scripts/delete_all_lists.sh b/x-pack/plugins/lists/server/scripts/delete_all_lists.sh deleted file mode 100755 index 5b65bb14414c7..0000000000000 --- a/x-pack/plugins/lists/server/scripts/delete_all_lists.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/sh - -# -# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -# or more contributor license agreements. Licensed under the Elastic License; -# you may not use this file except in compliance with the Elastic License. -# - -set -e -./check_env_variables.sh - -# Example: ./delete_all_lists.sh -# https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete-by-query.html - - -# Delete all the main lists that have children items -curl -s -k \ - -H "Content-Type: application/json" \ - -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X POST ${ELASTICSEARCH_URL}/${KIBANA_INDEX}*/_delete_by_query \ - --data '{ - "query": { - "exists": { "field": "siem_list" } - } - }' \ - | jq . - -# Delete all the list children items as well -curl -s -k \ - -H "Content-Type: application/json" \ - -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X POST ${ELASTICSEARCH_URL}/${KIBANA_INDEX}*/_delete_by_query \ - --data '{ - "query": { - "exists": { "field": "siem_list_item" } - } - }' \ - | jq . diff --git a/x-pack/plugins/lists/server/scripts/delete_exception_list.sh b/x-pack/plugins/lists/server/scripts/delete_exception_list.sh index fe2ca501b4416..efdb6d03db60b 100755 --- a/x-pack/plugins/lists/server/scripts/delete_exception_list.sh +++ b/x-pack/plugins/lists/server/scripts/delete_exception_list.sh @@ -9,8 +9,12 @@ set -e ./check_env_variables.sh +NAMESPACE_TYPE=${2-single} + # Example: ./delete_exception_list.sh ${list_id} +# Example: ./delete_exception_list.sh ${list_id} single +# Example: ./delete_exception_list.sh ${list_id} agnostic curl -s -k \ -H 'kbn-xsrf: 123' \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X DELETE ${KIBANA_URL}${SPACE_URL}/api/exception_lists?list_id="$1" | jq . + -X DELETE "${KIBANA_URL}${SPACE_URL}/api/exception_lists?list_id=$1&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/delete_exception_list_by_id.sh b/x-pack/plugins/lists/server/scripts/delete_exception_list_by_id.sh index a87881b385328..2eb4f93d93015 100755 --- a/x-pack/plugins/lists/server/scripts/delete_exception_list_by_id.sh +++ b/x-pack/plugins/lists/server/scripts/delete_exception_list_by_id.sh @@ -9,8 +9,12 @@ set -e ./check_env_variables.sh +NAMESPACE_TYPE=${2-single} + # Example: ./delete_exception_list_by_id.sh ${list_id} +# Example: ./delete_exception_list_by_id.sh ${list_id} single +# Example: ./delete_exception_list_by_id.sh ${list_id} agnostic curl -s -k \ -H 'kbn-xsrf: 123' \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X DELETE ${KIBANA_URL}${SPACE_URL}/api/exception_lists?id="$1" | jq . + -X DELETE "${KIBANA_URL}${SPACE_URL}/api/exception_lists?id=$1&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/delete_exception_list_item.sh b/x-pack/plugins/lists/server/scripts/delete_exception_list_item.sh index 7e09452a23e11..7617b4c47b1bc 100755 --- a/x-pack/plugins/lists/server/scripts/delete_exception_list_item.sh +++ b/x-pack/plugins/lists/server/scripts/delete_exception_list_item.sh @@ -9,8 +9,12 @@ set -e ./check_env_variables.sh +NAMESPACE_TYPE=${2-single} + # Example: ./delete_exception_list_item.sh ${item_id} +# Example: ./delete_exception_list_item.sh ${item_id} single +# Example: ./delete_exception_list_item.sh ${item_id} agnostic curl -s -k \ -H 'kbn-xsrf: 123' \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X DELETE ${KIBANA_URL}${SPACE_URL}/api/exception_lists/items?item_id="$1" | jq . + -X DELETE "${KIBANA_URL}${SPACE_URL}/api/exception_lists/items?item_id=$1&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/delete_exception_list_item_by_id.sh b/x-pack/plugins/lists/server/scripts/delete_exception_list_item_by_id.sh index bbfbc3135ddb8..0e18004909222 100755 --- a/x-pack/plugins/lists/server/scripts/delete_exception_list_item_by_id.sh +++ b/x-pack/plugins/lists/server/scripts/delete_exception_list_item_by_id.sh @@ -9,8 +9,12 @@ set -e ./check_env_variables.sh +NAMESPACE_TYPE=${2-single} + # Example: ./delete_exception_list_item_by_id.sh ${list_id} +# Example: ./delete_exception_list_item_by_id.sh ${list_id} single +# Example: ./delete_exception_list_item_by_id.sh ${list_id} agnostic curl -s -k \ -H 'kbn-xsrf: 123' \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X DELETE ${KIBANA_URL}${SPACE_URL}/api/exception_lists/items?id="$1" | jq . + -X DELETE "${KIBANA_URL}${SPACE_URL}/api/exception_lists/items?id=$1&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/delete_list.sh b/x-pack/plugins/lists/server/scripts/delete_list.sh index ce9fdd6aa21d4..95aa8eddbdf8d 100755 --- a/x-pack/plugins/lists/server/scripts/delete_list.sh +++ b/x-pack/plugins/lists/server/scripts/delete_list.sh @@ -13,4 +13,4 @@ set -e curl -s -k \ -H 'kbn-xsrf: 123' \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X DELETE ${KIBANA_URL}${SPACE_URL}/api/lists?id="$1" | jq . + -X DELETE "${KIBANA_URL}${SPACE_URL}/api/lists?id=$1" | jq . diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_agnostic.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_agnostic.json new file mode 100644 index 0000000000000..4121b13880660 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_agnostic.json @@ -0,0 +1,9 @@ +{ + "list_id": "endpoint_list", + "_tags": ["endpoint", "process", "malware", "os:linux"], + "tags": ["user added string for a tag", "malware"], + "type": "endpoint", + "description": "This is a sample agnostic endpoint type exception", + "name": "Sample Endpoint Exception List", + "namespace_type": "agnostic" +} diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_agnostic.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_agnostic.json new file mode 100644 index 0000000000000..db0b11480b81a --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_agnostic.json @@ -0,0 +1,22 @@ +{ + "list_id": "endpoint_list", + "item_id": "endpoint_list_item", + "_tags": ["endpoint", "process", "malware", "os:linux"], + "tags": ["user added string for a tag", "malware"], + "type": "simple", + "description": "This is a sample agnostic endpoint type exception", + "name": "Sample Endpoint Exception List", + "namespace_type": "agnostic", + "entries": [ + { + "field": "actingProcess.file.signer", + "operator": "included", + "match": "Elastic, N.V." + }, + { + "field": "event.category", + "operator": "included", + "match_any": ["process", "malware"] + } + ] +} diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_agnostic.json b/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_agnostic.json new file mode 100644 index 0000000000000..72ddd15ebee47 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_agnostic.json @@ -0,0 +1,16 @@ +{ + "item_id": "endpoint_list_item", + "_tags": ["endpoint", "process", "malware", "os:windows"], + "tags": ["user added string for a tag", "malware"], + "type": "simple", + "description": "This is a sample agnostic change here this list", + "name": "Sample Endpoint Exception List update change", + "namespace_type": "agnostic", + "entries": [ + { + "field": "event.category", + "operator": "included", + "match_any": ["process", "malware"] + } + ] +} diff --git a/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh b/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh index 85c5b0e518fab..e3f21da56d1b7 100755 --- a/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh +++ b/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh @@ -10,7 +10,11 @@ set -e ./check_env_variables.sh LIST_ID=${1:-endpoint_list} +NAMESPACE_TYPE=${2-single} + # Example: ./find_exception_list_items.sh {list-id} +# Example: ./find_exception_list_items.sh {list-id} single +# Example: ./find_exception_list_items.sh {list-id} agnostic curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X GET ${KIBANA_URL}${SPACE_URL}/api/exception_lists/items/_find?list_id=${LIST_ID} | jq . + -X GET "${KIBANA_URL}${SPACE_URL}/api/exception_lists/items/_find?list_id=${LIST_ID}&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh b/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh new file mode 100755 index 0000000000000..57313275ccd0e --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh @@ -0,0 +1,29 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +LIST_ID=${1:-endpoint_list} +FILTER=${2:-'exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List'} +NAMESPACE_TYPE=${3-single} + +# The %20 is just an encoded space that is typical of URL's. +# The %22 is just an encoded quote of " +# Table of them for testing if needed: https://www.w3schools.com/tags/ref_urlencode.asp + +# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List +# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List single +# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List agnostic +# +# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.entries.field:actingProcess.file.signer +# Example: ./find_exception_list_items_by_filter.sh endpoint_list "exception-list.attributes.entries.field:actingProcess.file.signe*" +# Example: ./find_exception_list_items_by_filter.sh endpoint_list "exception-list.attributes.entries.match:Elastic*%20AND%20exception-list.attributes.entries.field:actingProcess.file.signe*" +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/exception_lists/items/_find?list_id=${LIST_ID}&filter=${FILTER}&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_exception_lists.sh b/x-pack/plugins/lists/server/scripts/find_exception_lists.sh index a1ee184b3e5bb..d3420e53343a3 100755 --- a/x-pack/plugins/lists/server/scripts/find_exception_lists.sh +++ b/x-pack/plugins/lists/server/scripts/find_exception_lists.sh @@ -9,7 +9,11 @@ set -e ./check_env_variables.sh +NAMESPACE_TYPE=${1-single} + # Example: ./find_exception_lists.sh {list-id} +# Example: ./find_exception_lists.sh {list-id} single +# Example: ./find_exception_lists.sh {list-id} agnostic curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X GET ${KIBANA_URL}${SPACE_URL}/api/exception_lists/_find | jq . + -X GET "${KIBANA_URL}${SPACE_URL}/api/exception_lists/_find?namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_exception_lists_by_filter.sh b/x-pack/plugins/lists/server/scripts/find_exception_lists_by_filter.sh new file mode 100755 index 0000000000000..3f5600af76b83 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/find_exception_lists_by_filter.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +FILTER=${1:-'exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List'} +NAMESPACE_TYPE=${2-single} + +# The %20 is just an encoded space that is typical of URL's. +# The %22 is just an encoded quote of " +# Table of them for testing if needed: https://www.w3schools.com/tags/ref_urlencode.asp + +# Example get all lists by a particular name: +# ./find_exception_lists_by_filter.sh exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List +# ./find_exception_lists_by_filter.sh exception-list.attributes.tags:%20malware +# ./find_exception_lists_by_filter.sh exception-list.attributes.tags:%20malware single +# ./find_exception_lists_by_filter.sh exception-list.attributes.tags:%20malware agnostic +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/exception_lists/_find?filter=${FILTER}&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_list_items.sh b/x-pack/plugins/lists/server/scripts/find_list_items.sh new file mode 100755 index 0000000000000..c4a610e313fa8 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/find_list_items.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +PAGE=${1-1} +PER_PAGE=${2-20} +LIST_ID=${3-list-ip} + +# Example: ./find_list_items.sh 1 20 list-ip +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh b/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh new file mode 100755 index 0000000000000..3fd5178b2d9b1 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +PAGE=${1-1} +PER_PAGE=${2-20} +LIST_ID=${3-list-ip} +CURSOR=${4-invalid} + +# Example: +# ./find_list_items.sh 1 20 | jq .cursor +# Copy the cursor into the argument below like so +# ./find_list_items_with_cursor.sh 1 10 list-ip eyJwYWdlX2luZGV4IjoyMCwic2VhcmNoX2FmdGVyIjpbIjAyZDZlNGY3LWUzMzAtNGZkYi1iNTY0LTEzZjNiOTk1MjRiYSJdfQ== +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}&cursor=${CURSOR}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh new file mode 100755 index 0000000000000..dcea698be231d --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +PAGE=${1-1} +PER_PAGE=${2-20} +SORT_FIELD=${3-value} +SORT_ORDER=${4-asc} +LIST_ID=${5-list-ip} + +# Example: ./find_list_items_with_sort.sh 1 20 value asc list-ip +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}&sort_field=${SORT_FIELD}&sort_order=${SORT_ORDER}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh new file mode 100755 index 0000000000000..07b67a9bd1c5f --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +PAGE=${1-1} +PER_PAGE=${2-20} +SORT_FIELD=${3-value} +SORT_ORDER=${4-asc} +LIST_ID=${5-list-ip} +CURSOR=${6-invalid} + +# Example: ./find_list_items_with_sort_cursor.sh 1 20 value asc list-ip +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}&sort_field=${SORT_FIELD}&sort_order=${SORT_ORDER}&cursor=${CURSOR}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_lists.sh b/x-pack/plugins/lists/server/scripts/find_lists.sh new file mode 100755 index 0000000000000..6ff673c91cad4 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/find_lists.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +PAGE=${1-1} +PER_PAGE=${2-20} + +# Example: ./find_lists.sh 1 20 +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/_find?page=${PAGE}&per_page=${PER_PAGE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_lists_with_cursor.sh b/x-pack/plugins/lists/server/scripts/find_lists_with_cursor.sh new file mode 100755 index 0000000000000..a3bff5c37d090 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/find_lists_with_cursor.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +PAGE=${1-1} +PER_PAGE=${2-20} +CURSOR=${3-invalid} + +# Example: +# ./find_lists.sh 1 20 | jq .cursor +# Copy the cursor into the argument below like so +# ./find_lists_with_cursor.sh 1 10 eyJwYWdlX2luZGV4IjoyMCwic2VhcmNoX2FmdGVyIjpbIjAyZDZlNGY3LWUzMzAtNGZkYi1iNTY0LTEzZjNiOTk1MjRiYSJdfQ== +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/_find?page=${PAGE}&per_page=${PER_PAGE}&cursor=${CURSOR}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_lists_with_filter.sh b/x-pack/plugins/lists/server/scripts/find_lists_with_filter.sh new file mode 100755 index 0000000000000..1919d13fdf793 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/find_lists_with_filter.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +PAGE=${1-1} +PER_PAGE=${2-20} +FILTER=${3-type:ip} +# Example: ./find_lists_with_filter.sh 1 20 type:ip +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/_find?page=${PAGE}&per_page=${PER_PAGE}&filter=${FILTER}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_lists_with_sort.sh b/x-pack/plugins/lists/server/scripts/find_lists_with_sort.sh new file mode 100755 index 0000000000000..411f3a396cdb3 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/find_lists_with_sort.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +PAGE=${1-1} +PER_PAGE=${2-20} +SORT_FIELD=${3-name} +SORT_ORDER=${4-asc} + +# Example: ./find_lists_with_sort.sh 1 20 name asc +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/_find?page=${PAGE}&per_page=${PER_PAGE}&sort_field=${SORT_FIELD}&sort_order=${SORT_ORDER}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_lists_with_sort_cursor.sh b/x-pack/plugins/lists/server/scripts/find_lists_with_sort_cursor.sh new file mode 100755 index 0000000000000..c706eb68869ef --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/find_lists_with_sort_cursor.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +PAGE=${1-1} +PER_PAGE=${2-20} +SORT_FIELD=${3-name} +SORT_ORDER=${4-asc} +CURSOR=${5-invalid} + +# Example: ./find_lists_with_sort_cursor.sh 1 20 name asc +curl -s -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/_find?page=${PAGE}&per_page=${PER_PAGE}&sort_field=${SORT_FIELD}&sort_order=${SORT_ORDER}&cursor=${CURSOR}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/get_exception_list.sh b/x-pack/plugins/lists/server/scripts/get_exception_list.sh index 34e6de2576879..9aa15a08dec14 100755 --- a/x-pack/plugins/lists/server/scripts/get_exception_list.sh +++ b/x-pack/plugins/lists/server/scripts/get_exception_list.sh @@ -9,7 +9,10 @@ set -e ./check_env_variables.sh -# Example: ./get_exception_list.sh {id} +NAMESPACE_TYPE=${2-single} + +# Example: ./get_exception_list.sh {id} single +# Example: ./get_exception_list.sh {id} agnostic curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X GET ${KIBANA_URL}${SPACE_URL}/api/exception_lists?list_id="$1" | jq . + -X GET "${KIBANA_URL}${SPACE_URL}/api/exception_lists?list_id=$1&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/get_exception_list_by_id.sh b/x-pack/plugins/lists/server/scripts/get_exception_list_by_id.sh index 0420a1f702328..bcd6721b6fd00 100755 --- a/x-pack/plugins/lists/server/scripts/get_exception_list_by_id.sh +++ b/x-pack/plugins/lists/server/scripts/get_exception_list_by_id.sh @@ -9,7 +9,9 @@ set -e ./check_env_variables.sh +NAMESPACE_TYPE=${2-single} + # Example: ./get_exception_list_by_id.sh {id} curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X GET ${KIBANA_URL}${SPACE_URL}/api/exception_lists?id="$1" | jq . + -X GET "${KIBANA_URL}${SPACE_URL}/api/exception_lists?id=$1&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/get_exception_list_item.sh b/x-pack/plugins/lists/server/scripts/get_exception_list_item.sh index ac8337aab8368..141bbe60f193f 100755 --- a/x-pack/plugins/lists/server/scripts/get_exception_list_item.sh +++ b/x-pack/plugins/lists/server/scripts/get_exception_list_item.sh @@ -9,7 +9,11 @@ set -e ./check_env_variables.sh +NAMESPACE_TYPE=${2-single} + # Example: ./get_exception_list_item.sh {id} +# Example: ./get_exception_list_item.sh {id} single +# Example: ./get_exception_list_item.sh {id} agnostic curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X GET ${KIBANA_URL}${SPACE_URL}/api/exception_lists/items?item_id="$1" | jq . + -X GET "${KIBANA_URL}${SPACE_URL}/api/exception_lists/items?item_id=$1&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/get_exception_list_item_by_id.sh b/x-pack/plugins/lists/server/scripts/get_exception_list_item_by_id.sh index 575a529c69906..97a90c28daebd 100755 --- a/x-pack/plugins/lists/server/scripts/get_exception_list_item_by_id.sh +++ b/x-pack/plugins/lists/server/scripts/get_exception_list_item_by_id.sh @@ -9,7 +9,11 @@ set -e ./check_env_variables.sh +NAMESPACE_TYPE=${2-single} + # Example: ./get_exception_list_item_by_id.sh {id} +# Example: ./get_exception_list_item_by_id.sh {id} single +# Example: ./get_exception_list_item_by_id.sh {id} agnostic curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X GET ${KIBANA_URL}${SPACE_URL}/api/exception_lists/items?id="$1" | jq . + -X GET "${KIBANA_URL}${SPACE_URL}/api/exception_lists/items?id=$1&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/lists/new/list_auto_id.json b/x-pack/plugins/lists/server/scripts/lists/new/list_auto_id.json new file mode 100644 index 0000000000000..ef48ba8f67009 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/lists/new/list_auto_id.json @@ -0,0 +1,5 @@ +{ + "name": "Simple list with a type of ip and an auto created id", + "description": "list with an auto created id", + "type": "ip" +} diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list.ts b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list.ts index 7ba832e72bb8e..c6d4bc006ef0b 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list.ts @@ -15,12 +15,12 @@ import { ListId, MetaOrUndefined, Name, + NamespaceType, Tags, _Tags, } from '../../../common/schemas'; import { getSavedObjectType, transformSavedObjectToExceptionList } from './utils'; -import { NamespaceType } from './types'; interface CreateExceptionListOptions { _tags: _Tags; @@ -68,5 +68,5 @@ export const createExceptionList = async ({ type, updated_by: user, }); - return transformSavedObjectToExceptionList({ savedObject }); + return transformSavedObjectToExceptionList({ namespaceType, savedObject }); }; diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts index 4a6dc1da97854..44e87ab06f52b 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts @@ -18,12 +18,12 @@ import { ListId, MetaOrUndefined, Name, + NamespaceType, Tags, _Tags, } from '../../../common/schemas'; import { getSavedObjectType, transformSavedObjectToExceptionListItem } from './utils'; -import { NamespaceType } from './types'; interface CreateExceptionListItemOptions { _tags: _Tags; @@ -77,5 +77,5 @@ export const createExceptionListItem = async ({ type, updated_by: user, }); - return transformSavedObjectToExceptionListItem({ savedObject }); + return transformSavedObjectToExceptionListItem({ namespaceType, savedObject }); }; diff --git a/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list.ts b/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list.ts index 6904438c8d275..afeed6b5e2cde 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list.ts @@ -6,10 +6,14 @@ import { SavedObjectsClientContract } from 'kibana/server'; -import { ExceptionListSchema, IdOrUndefined, ListIdOrUndefined } from '../../../common/schemas'; +import { + ExceptionListSchema, + IdOrUndefined, + ListIdOrUndefined, + NamespaceType, +} from '../../../common/schemas'; import { getSavedObjectType } from './utils'; -import { NamespaceType } from './types'; import { getExceptionList } from './get_exception_list'; import { deleteExceptionListItemByList } from './delete_exception_list_items_by_list'; diff --git a/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_item.ts index 3b2d991281cd6..8dce1f1f79e35 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_item.ts @@ -6,10 +6,14 @@ import { SavedObjectsClientContract } from 'kibana/server'; -import { ExceptionListItemSchema, IdOrUndefined, ItemIdOrUndefined } from '../../../common/schemas'; +import { + ExceptionListItemSchema, + IdOrUndefined, + ItemIdOrUndefined, + NamespaceType, +} from '../../../common/schemas'; import { getSavedObjectType } from './utils'; -import { NamespaceType } from './types'; import { getExceptionListItem } from './get_exception_list_item'; interface DeleteExceptionListItemOptions { diff --git a/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_items_by_list.ts b/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_items_by_list.ts index 31bf1ffacbbb2..e835ffae02c9e 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_items_by_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_items_by_list.ts @@ -5,10 +5,9 @@ */ import { SavedObjectsClientContract } from '../../../../../../src/core/server/'; -import { ListId } from '../../../common/schemas'; +import { ListId, NamespaceType } from '../../../common/schemas'; import { findExceptionListItem } from './find_exception_list_item'; -import { NamespaceType } from './types'; import { getSavedObjectType } from './utils'; const PER_PAGE = 100; diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index 6e71ed1b3e59d..efd117a3c38f4 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -7,6 +7,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { + ExceptionListItemSchema, ExceptionListSchema, FoundExceptionListItemSchema, FoundExceptionListSchema, @@ -59,7 +60,7 @@ export class ExceptionListClient { itemId, id, namespaceType, - }: GetExceptionListItemOptions): Promise => { + }: GetExceptionListItemOptions): Promise => { const { savedObjectsClient } = this; return getExceptionListItem({ id, itemId, namespaceType, savedObjectsClient }); }; @@ -142,7 +143,7 @@ export class ExceptionListClient { namespaceType, tags, type, - }: CreateExceptionListItemOptions): Promise => { + }: CreateExceptionListItemOptions): Promise => { const { savedObjectsClient, user } = this; return createExceptionListItem({ _tags, @@ -173,7 +174,7 @@ export class ExceptionListClient { namespaceType, tags, type, - }: UpdateExceptionListItemOptions): Promise => { + }: UpdateExceptionListItemOptions): Promise => { const { savedObjectsClient, user } = this; return updateExceptionListItem({ _tags, @@ -196,7 +197,7 @@ export class ExceptionListClient { id, itemId, namespaceType, - }: DeleteExceptionListItemOptions): Promise => { + }: DeleteExceptionListItemOptions): Promise => { const { savedObjectsClient } = this; return deleteExceptionListItem({ id, diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index cecd6bf3397a7..0ac543afee9f9 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -14,6 +14,7 @@ import { EntriesArrayOrUndefined, ExceptionListType, ExceptionListTypeOrUndefined, + FilterOrUndefined, IdOrUndefined, ItemId, ItemIdOrUndefined, @@ -22,14 +23,17 @@ import { MetaOrUndefined, Name, NameOrUndefined, + NamespaceType, + PageOrUndefined, + PerPageOrUndefined, + SortFieldOrUndefined, + SortOrderOrUndefined, Tags, TagsOrUndefined, _Tags, _TagsOrUndefined, } from '../../../common/schemas'; -import { NamespaceType } from './types'; - export interface ConstructorOptions { user: string; savedObjectsClient: SavedObjectsClientContract; @@ -113,18 +117,18 @@ export interface UpdateExceptionListItemOptions { export interface FindExceptionListItemOptions { listId: ListId; namespaceType: NamespaceType; - filter: string | undefined; - perPage: number | undefined; - page: number | undefined; - sortField: string | undefined; - sortOrder: string | undefined; + filter: FilterOrUndefined; + perPage: PerPageOrUndefined; + page: PageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; } export interface FindExceptionListOptions { namespaceType: NamespaceType; - filter: string | undefined; - perPage: number | undefined; - page: number | undefined; - sortField: string | undefined; - sortOrder: string | undefined; + filter: FilterOrUndefined; + perPage: PerPageOrUndefined; + page: PageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; } diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.ts index 539dda673208b..6a8fbf3306971 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list.ts @@ -6,20 +6,28 @@ import { SavedObjectsClientContract } from 'kibana/server'; -import { ExceptionListSoSchema, FoundExceptionListSchema } from '../../../common/schemas'; +import { + ExceptionListSoSchema, + FilterOrUndefined, + FoundExceptionListSchema, + NamespaceType, + PageOrUndefined, + PerPageOrUndefined, + SortFieldOrUndefined, + SortOrderOrUndefined, +} from '../../../common/schemas'; import { SavedObjectType } from '../../saved_objects'; import { getSavedObjectType, transformSavedObjectsToFounExceptionList } from './utils'; -import { NamespaceType } from './types'; interface FindExceptionListOptions { namespaceType: NamespaceType; savedObjectsClient: SavedObjectsClientContract; - filter: string | undefined; - perPage: number | undefined; - page: number | undefined; - sortField: string | undefined; - sortOrder: string | undefined; + filter: FilterOrUndefined; + perPage: PerPageOrUndefined; + page: PageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; } export const findExceptionList = async ({ @@ -40,14 +48,14 @@ export const findExceptionList = async ({ sortOrder, type: savedObjectType, }); - return transformSavedObjectsToFounExceptionList({ savedObjectsFindResponse }); + return transformSavedObjectsToFounExceptionList({ namespaceType, savedObjectsFindResponse }); }; export const getExceptionListFilter = ({ filter, savedObjectType, }: { - filter: string | undefined; + filter: FilterOrUndefined; savedObjectType: SavedObjectType; }): string => { if (filter == null) { diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts index d635cafbd3b1b..c3b09a5f44b15 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts @@ -8,24 +8,29 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { ExceptionListSoSchema, + FilterOrUndefined, FoundExceptionListItemSchema, ListId, + NamespaceType, + PageOrUndefined, + PerPageOrUndefined, + SortFieldOrUndefined, + SortOrderOrUndefined, } from '../../../common/schemas'; import { SavedObjectType } from '../../saved_objects'; import { getSavedObjectType, transformSavedObjectsToFounExceptionListItem } from './utils'; -import { NamespaceType } from './types'; import { getExceptionList } from './get_exception_list'; interface FindExceptionListItemOptions { listId: ListId; namespaceType: NamespaceType; savedObjectsClient: SavedObjectsClientContract; - filter: string | undefined; - perPage: number | undefined; - page: number | undefined; - sortField: string | undefined; - sortOrder: string | undefined; + filter: FilterOrUndefined; + perPage: PerPageOrUndefined; + page: PageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; } export const findExceptionListItem = async ({ @@ -56,7 +61,10 @@ export const findExceptionListItem = async ({ sortOrder, type: savedObjectType, }); - return transformSavedObjectsToFounExceptionListItem({ savedObjectsFindResponse }); + return transformSavedObjectsToFounExceptionListItem({ + namespaceType, + savedObjectsFindResponse, + }); } }; @@ -66,7 +74,7 @@ export const getExceptionListItemFilter = ({ savedObjectType, }: { listId: ListId; - filter: string | undefined; + filter: FilterOrUndefined; savedObjectType: SavedObjectType; }): string => { if (filter == null) { diff --git a/x-pack/plugins/lists/server/services/exception_lists/get_exception_list.ts b/x-pack/plugins/lists/server/services/exception_lists/get_exception_list.ts index 8b28443b4e30c..8f511d140b0ff 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/get_exception_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/get_exception_list.ts @@ -13,10 +13,10 @@ import { ExceptionListSoSchema, IdOrUndefined, ListIdOrUndefined, + NamespaceType, } from '../../../common/schemas'; import { getSavedObjectType, transformSavedObjectToExceptionList } from './utils'; -import { NamespaceType } from './types'; interface GetExceptionListOptions { id: IdOrUndefined; @@ -35,7 +35,7 @@ export const getExceptionList = async ({ if (id != null) { try { const savedObject = await savedObjectsClient.get(savedObjectType, id); - return transformSavedObjectToExceptionList({ savedObject }); + return transformSavedObjectToExceptionList({ namespaceType, savedObject }); } catch (err) { if (SavedObjectsErrorHelpers.isNotFoundError(err)) { return null; @@ -54,7 +54,10 @@ export const getExceptionList = async ({ type: savedObjectType, }); if (savedObject.saved_objects[0] != null) { - return transformSavedObjectToExceptionList({ savedObject: savedObject.saved_objects[0] }); + return transformSavedObjectToExceptionList({ + namespaceType, + savedObject: savedObject.saved_objects[0], + }); } else { return null; } diff --git a/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts index 7ef3e4af3d604..d7efdc054c48c 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts @@ -13,10 +13,10 @@ import { ExceptionListSoSchema, IdOrUndefined, ItemIdOrUndefined, + NamespaceType, } from '../../../common/schemas'; import { getSavedObjectType, transformSavedObjectToExceptionListItem } from './utils'; -import { NamespaceType } from './types'; interface GetExceptionListItemOptions { id: IdOrUndefined; @@ -35,7 +35,7 @@ export const getExceptionListItem = async ({ if (id != null) { try { const savedObject = await savedObjectsClient.get(savedObjectType, id); - return transformSavedObjectToExceptionListItem({ savedObject }); + return transformSavedObjectToExceptionListItem({ namespaceType, savedObject }); } catch (err) { if (SavedObjectsErrorHelpers.isNotFoundError(err)) { return null; @@ -55,6 +55,7 @@ export const getExceptionListItem = async ({ }); if (savedObject.saved_objects[0] != null) { return transformSavedObjectToExceptionListItem({ + namespaceType, savedObject: savedObject.saved_objects[0], }); } else { diff --git a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list.ts b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list.ts index 6c5ccb5e1f2fd..e4d6718ddc29f 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list.ts @@ -15,12 +15,12 @@ import { ListIdOrUndefined, MetaOrUndefined, NameOrUndefined, + NamespaceType, TagsOrUndefined, _TagsOrUndefined, } from '../../../common/schemas'; import { getSavedObjectType, transformSavedObjectUpdateToExceptionList } from './utils'; -import { NamespaceType } from './types'; import { getExceptionList } from './get_exception_list'; interface UpdateExceptionListOptions { @@ -69,6 +69,6 @@ export const updateExceptionList = async ({ updated_by: user, } ); - return transformSavedObjectUpdateToExceptionList({ exceptionList, savedObject }); + return transformSavedObjectUpdateToExceptionList({ exceptionList, namespaceType, savedObject }); } }; diff --git a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts index 4e955d4281c4d..39c319a944e38 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts @@ -17,12 +17,12 @@ import { ItemIdOrUndefined, MetaOrUndefined, NameOrUndefined, + NamespaceType, TagsOrUndefined, _TagsOrUndefined, } from '../../../common/schemas'; import { getSavedObjectType, transformSavedObjectUpdateToExceptionListItem } from './utils'; -import { NamespaceType } from './types'; import { getExceptionListItem } from './get_exception_list_item'; interface UpdateExceptionListItemOptions { @@ -82,6 +82,10 @@ export const updateExceptionListItem = async ({ updated_by: user, } ); - return transformSavedObjectUpdateToExceptionListItem({ exceptionListItem, savedObject }); + return transformSavedObjectUpdateToExceptionListItem({ + exceptionListItem, + namespaceType, + savedObject, + }); } }; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils.ts b/x-pack/plugins/lists/server/services/exception_lists/utils.ts index 28dfb9c1cddaf..82a98f4bdd3e2 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils.ts @@ -12,6 +12,7 @@ import { ExceptionListSoSchema, FoundExceptionListItemSchema, FoundExceptionListSchema, + NamespaceType, } from '../../../common/schemas'; import { SavedObjectType, @@ -19,8 +20,6 @@ import { exceptionListSavedObjectType, } from '../../saved_objects'; -import { NamespaceType } from './types'; - export const getSavedObjectType = ({ namespaceType, }: { @@ -35,8 +34,10 @@ export const getSavedObjectType = ({ export const transformSavedObjectToExceptionList = ({ savedObject, + namespaceType, }: { savedObject: SavedObject; + namespaceType: NamespaceType; }): ExceptionListSchema => { const dateNow = new Date().toISOString(); const { @@ -68,6 +69,7 @@ export const transformSavedObjectToExceptionList = ({ list_id, meta, name, + namespace_type: namespaceType, tags, tie_breaker_id, type, @@ -79,9 +81,11 @@ export const transformSavedObjectToExceptionList = ({ export const transformSavedObjectUpdateToExceptionList = ({ exceptionList, savedObject, + namespaceType, }: { exceptionList: ExceptionListSchema; savedObject: SavedObjectsUpdateResponse; + namespaceType: NamespaceType; }): ExceptionListSchema => { const dateNow = new Date().toISOString(); const { @@ -101,6 +105,7 @@ export const transformSavedObjectUpdateToExceptionList = ({ list_id: exceptionList.list_id, meta: meta ?? exceptionList.meta, name: name ?? exceptionList.name, + namespace_type: namespaceType, tags: tags ?? exceptionList.tags, tie_breaker_id: exceptionList.tie_breaker_id, type: type ?? exceptionList.type, @@ -111,8 +116,10 @@ export const transformSavedObjectUpdateToExceptionList = ({ export const transformSavedObjectToExceptionListItem = ({ savedObject, + namespaceType, }: { savedObject: SavedObject; + namespaceType: NamespaceType; }): ExceptionListItemSchema => { const dateNow = new Date().toISOString(); const { @@ -150,6 +157,7 @@ export const transformSavedObjectToExceptionListItem = ({ list_id, meta, name, + namespace_type: namespaceType, tags, tie_breaker_id, type, @@ -161,9 +169,11 @@ export const transformSavedObjectToExceptionListItem = ({ export const transformSavedObjectUpdateToExceptionListItem = ({ exceptionListItem, savedObject, + namespaceType, }: { exceptionListItem: ExceptionListItemSchema; savedObject: SavedObjectsUpdateResponse; + namespaceType: NamespaceType; }): ExceptionListItemSchema => { const dateNow = new Date().toISOString(); const { @@ -196,6 +206,7 @@ export const transformSavedObjectUpdateToExceptionListItem = ({ list_id: exceptionListItem.list_id, meta: meta ?? exceptionListItem.meta, name: name ?? exceptionListItem.name, + namespace_type: namespaceType, tags: tags ?? exceptionListItem.tags, tie_breaker_id: exceptionListItem.tie_breaker_id, type: type ?? exceptionListItem.type, @@ -206,12 +217,14 @@ export const transformSavedObjectUpdateToExceptionListItem = ({ export const transformSavedObjectsToFounExceptionListItem = ({ savedObjectsFindResponse, + namespaceType, }: { savedObjectsFindResponse: SavedObjectsFindResponse; + namespaceType: NamespaceType; }): FoundExceptionListItemSchema => { return { data: savedObjectsFindResponse.saved_objects.map((savedObject) => - transformSavedObjectToExceptionListItem({ savedObject }) + transformSavedObjectToExceptionListItem({ namespaceType, savedObject }) ), page: savedObjectsFindResponse.page, per_page: savedObjectsFindResponse.per_page, @@ -221,12 +234,14 @@ export const transformSavedObjectsToFounExceptionListItem = ({ export const transformSavedObjectsToFounExceptionList = ({ savedObjectsFindResponse, + namespaceType, }: { savedObjectsFindResponse: SavedObjectsFindResponse; + namespaceType: NamespaceType; }): FoundExceptionListSchema => { return { data: savedObjectsFindResponse.saved_objects.map((savedObject) => - transformSavedObjectToExceptionList({ savedObject }) + transformSavedObjectToExceptionList({ namespaceType, savedObject }) ), page: savedObjectsFindResponse.page, per_page: savedObjectsFindResponse.per_page, diff --git a/x-pack/plugins/lists/server/services/items/create_list_item.ts b/x-pack/plugins/lists/server/services/items/create_list_item.ts index 83a118b795192..d46b9b4703fcb 100644 --- a/x-pack/plugins/lists/server/services/items/create_list_item.ts +++ b/x-pack/plugins/lists/server/services/items/create_list_item.ts @@ -58,7 +58,7 @@ export const createListItem = async ({ ...transformListItemToElasticQuery({ type, value }), }; - const response: CreateDocumentResponse = await callCluster('index', { + const response = await callCluster('index', { body, id, index: listItemIndex, diff --git a/x-pack/plugins/lists/server/services/items/find_list_item.ts b/x-pack/plugins/lists/server/services/items/find_list_item.ts new file mode 100644 index 0000000000000..d10e6466d03d0 --- /dev/null +++ b/x-pack/plugins/lists/server/services/items/find_list_item.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { APICaller } from 'kibana/server'; + +import { + Filter, + FoundListItemSchema, + ListId, + Page, + PerPage, + SearchEsListItemSchema, + SortFieldOrUndefined, + SortOrderOrUndefined, +} from '../../../common/schemas'; +import { getList } from '../lists'; +import { + encodeCursor, + getQueryFilter, + getSearchAfterWithTieBreaker, + getSortWithTieBreaker, + scrollToStartPage, + transformElasticToListItem, +} from '../utils'; + +interface FindListItemOptions { + listId: ListId; + filter: Filter; + currentIndexPosition: number; + searchAfter: string[] | undefined; + perPage: PerPage; + page: Page; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; + callCluster: APICaller; + listIndex: string; + listItemIndex: string; +} + +export const findListItem = async ({ + callCluster, + currentIndexPosition, + filter, + listId, + page, + perPage, + searchAfter, + sortField: sortFieldWithPossibleValue, + listIndex, + listItemIndex, + sortOrder, +}: FindListItemOptions): Promise => { + const query = getQueryFilter({ filter }); + const list = await getList({ callCluster, id: listId, listIndex }); + if (list == null) { + return null; + } else { + const sortField = + sortFieldWithPossibleValue === 'value' ? list.type : sortFieldWithPossibleValue; + const scroll = await scrollToStartPage({ + callCluster, + currentIndexPosition, + filter, + hopSize: 100, + index: listItemIndex, + page, + perPage, + searchAfter, + sortField, + sortOrder, + }); + + const { count } = await callCluster('count', { + body: { + query, + }, + ignoreUnavailable: true, + index: listItemIndex, + }); + + if (scroll.validSearchAfterFound) { + const response = await callCluster('search', { + body: { + query, + search_after: scroll.searchAfter, + sort: getSortWithTieBreaker({ sortField, sortOrder }), + }, + ignoreUnavailable: true, + index: listItemIndex, + size: perPage, + }); + return { + cursor: encodeCursor({ + page, + perPage, + searchAfter: getSearchAfterWithTieBreaker({ response, sortField }), + }), + data: transformElasticToListItem({ response, type: list.type }), + page, + per_page: perPage, + total: count, + }; + } else { + return { + cursor: encodeCursor({ page, perPage, searchAfter: undefined }), + data: [], + page, + per_page: perPage, + total: count, + }; + } + } +}; diff --git a/x-pack/plugins/lists/server/services/items/get_list_item.ts b/x-pack/plugins/lists/server/services/items/get_list_item.ts index 83b30d336ccd4..296d1e4e82184 100644 --- a/x-pack/plugins/lists/server/services/items/get_list_item.ts +++ b/x-pack/plugins/lists/server/services/items/get_list_item.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SearchResponse } from 'elasticsearch'; import { APICaller } from 'kibana/server'; import { Id, ListItemSchema, SearchEsListItemSchema } from '../../../common/schemas'; @@ -21,7 +20,7 @@ export const getListItem = async ({ callCluster, listItemIndex, }: GetListItemOptions): Promise => { - const listItemES: SearchResponse = await callCluster('search', { + const listItemES = await callCluster('search', { body: { query: { term: { diff --git a/x-pack/plugins/lists/server/services/items/get_list_item_by_values.ts b/x-pack/plugins/lists/server/services/items/get_list_item_by_values.ts index 29b9b01754027..cf0ccf3f10aa6 100644 --- a/x-pack/plugins/lists/server/services/items/get_list_item_by_values.ts +++ b/x-pack/plugins/lists/server/services/items/get_list_item_by_values.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SearchResponse } from 'elasticsearch'; import { APICaller } from 'kibana/server'; import { ListItemArraySchema, SearchEsListItemSchema, Type } from '../../../common/schemas'; @@ -25,7 +24,7 @@ export const getListItemByValues = async ({ type, value, }: GetListItemByValuesOptions): Promise => { - const response: SearchResponse = await callCluster('search', { + const response = await callCluster('search', { body: { query: { bool: { diff --git a/x-pack/plugins/lists/server/services/items/index.ts b/x-pack/plugins/lists/server/services/items/index.ts index ee1d83fabca31..bc04ba88b943e 100644 --- a/x-pack/plugins/lists/server/services/items/index.ts +++ b/x-pack/plugins/lists/server/services/items/index.ts @@ -8,12 +8,13 @@ export * from './buffer_lines'; export * from './create_list_item'; export * from './create_list_items_bulk'; export * from './delete_list_item_by_value'; +export * from './delete_list_item'; +export * from './find_list_item'; export * from './get_list_item_by_value'; export * from './get_list_item'; export * from './get_list_item_by_values'; +export * from './get_list_item_template'; +export * from './get_list_item_index'; export * from './update_list_item'; export * from './write_lines_to_bulk_list_items'; export * from './write_list_items_to_stream'; -export * from './get_list_item_template'; -export * from './delete_list_item'; -export * from './get_list_item_index'; diff --git a/x-pack/plugins/lists/server/services/items/update_list_item.ts b/x-pack/plugins/lists/server/services/items/update_list_item.ts index 6a71b2a0caf41..6a428b4be854d 100644 --- a/x-pack/plugins/lists/server/services/items/update_list_item.ts +++ b/x-pack/plugins/lists/server/services/items/update_list_item.ts @@ -48,7 +48,7 @@ export const updateListItem = async ({ ...transformListItemToElasticQuery({ type: listItem.type, value: value ?? listItem.value }), }; - const response: CreateDocumentResponse = await callCluster('update', { + const response = await callCluster('update', { body: { doc, }, diff --git a/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.ts b/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.ts index 10d8581ccdbc0..f485f557433c6 100644 --- a/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.ts +++ b/x-pack/plugins/lists/server/services/items/write_list_items_to_stream.ts @@ -114,7 +114,7 @@ export const getResponse = async ({ listItemIndex, size = SIZE, }: GetResponseOptions): Promise> => { - return callCluster('search', { + return callCluster('search', { body: { query: { term: { diff --git a/x-pack/plugins/lists/server/services/lists/create_list.ts b/x-pack/plugins/lists/server/services/lists/create_list.ts index ddbc99c88a877..0d2ee606a066d 100644 --- a/x-pack/plugins/lists/server/services/lists/create_list.ts +++ b/x-pack/plugins/lists/server/services/lists/create_list.ts @@ -55,7 +55,7 @@ export const createList = async ({ updated_at: createdAt, updated_by: user, }; - const response: CreateDocumentResponse = await callCluster('index', { + const response = await callCluster('index', { body, id, index: listIndex, diff --git a/x-pack/plugins/lists/server/services/lists/find_list.ts b/x-pack/plugins/lists/server/services/lists/find_list.ts new file mode 100644 index 0000000000000..41dcdfcd0f8db --- /dev/null +++ b/x-pack/plugins/lists/server/services/lists/find_list.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { APICaller } from 'kibana/server'; + +import { + Filter, + FoundListSchema, + Page, + PerPage, + SearchEsListSchema, + SortFieldOrUndefined, + SortOrderOrUndefined, +} from '../../../common/schemas'; +import { + encodeCursor, + getQueryFilter, + getSearchAfterWithTieBreaker, + getSortWithTieBreaker, + scrollToStartPage, + transformElasticToList, +} from '../utils'; + +interface FindListOptions { + filter: Filter; + currentIndexPosition: number; + searchAfter: string[] | undefined; + perPage: PerPage; + page: Page; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; + callCluster: APICaller; + listIndex: string; +} + +export const findList = async ({ + callCluster, + currentIndexPosition, + filter, + page, + perPage, + searchAfter, + sortField, + listIndex, + sortOrder, +}: FindListOptions): Promise => { + const query = getQueryFilter({ filter }); + + const scroll = await scrollToStartPage({ + callCluster, + currentIndexPosition, + filter, + hopSize: 100, + index: listIndex, + page, + perPage, + searchAfter, + sortField, + sortOrder, + }); + + const { count } = await callCluster('count', { + body: { + query, + }, + ignoreUnavailable: true, + index: listIndex, + }); + + if (scroll.validSearchAfterFound) { + const response = await callCluster('search', { + body: { + query, + search_after: scroll.searchAfter, + sort: getSortWithTieBreaker({ sortField, sortOrder }), + }, + ignoreUnavailable: true, + index: listIndex, + size: perPage, + }); + return { + cursor: encodeCursor({ + page, + perPage, + searchAfter: getSearchAfterWithTieBreaker({ response, sortField }), + }), + data: transformElasticToList({ response }), + page, + per_page: perPage, + total: count, + }; + } else { + return { + cursor: encodeCursor({ page, perPage, searchAfter: undefined }), + data: [], + page, + per_page: perPage, + total: count, + }; + } +}; diff --git a/x-pack/plugins/lists/server/services/lists/get_list.ts b/x-pack/plugins/lists/server/services/lists/get_list.ts index c04bd504ad8c0..386232bfeee1f 100644 --- a/x-pack/plugins/lists/server/services/lists/get_list.ts +++ b/x-pack/plugins/lists/server/services/lists/get_list.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SearchResponse } from 'elasticsearch'; import { APICaller } from 'kibana/server'; import { Id, ListSchema, SearchEsListSchema } from '../../../common/schemas'; +import { transformElasticToList } from '../utils/transform_elastic_to_list'; interface GetListOptions { id: Id; @@ -20,7 +20,7 @@ export const getList = async ({ callCluster, listIndex, }: GetListOptions): Promise => { - const result: SearchResponse = await callCluster('search', { + const response = await callCluster('search', { body: { query: { term: { @@ -31,12 +31,6 @@ export const getList = async ({ ignoreUnavailable: true, index: listIndex, }); - if (result.hits.hits.length) { - return { - id: result.hits.hits[0]._id, - ...result.hits.hits[0]._source, - }; - } else { - return null; - } + const list = transformElasticToList({ response }); + return list[0] ?? null; }; diff --git a/x-pack/plugins/lists/server/services/lists/index.ts b/x-pack/plugins/lists/server/services/lists/index.ts index f704ef0b05b82..bafeb929a8d53 100644 --- a/x-pack/plugins/lists/server/services/lists/index.ts +++ b/x-pack/plugins/lists/server/services/lists/index.ts @@ -6,6 +6,7 @@ export * from './create_list'; export * from './delete_list'; +export * from './find_list'; export * from './get_list'; export * from './get_list_template'; export * from './update_list'; diff --git a/x-pack/plugins/lists/server/services/lists/list_client.ts b/x-pack/plugins/lists/server/services/lists/list_client.ts index cba48115c746c..5a7d20c7d64d5 100644 --- a/x-pack/plugins/lists/server/services/lists/list_client.ts +++ b/x-pack/plugins/lists/server/services/lists/list_client.ts @@ -6,11 +6,18 @@ import { APICaller } from 'kibana/server'; -import { ListItemArraySchema, ListItemSchema, ListSchema } from '../../../common/schemas'; +import { + FoundListItemSchema, + FoundListSchema, + ListItemArraySchema, + ListItemSchema, + ListSchema, +} from '../../../common/schemas'; import { ConfigType } from '../../config'; import { createList, deleteList, + findList, getList, getListIndex, getListTemplate, @@ -21,6 +28,7 @@ import { deleteListItem, deleteListItemByValue, exportListItemsToStream, + findListItem, getListItem, getListItemByValue, getListItemByValues, @@ -52,6 +60,8 @@ import { DeleteListItemOptions, DeleteListOptions, ExportListItemsToStreamOptions, + FindListItemOptions, + FindListOptions, GetListItemByValueOptions, GetListItemOptions, GetListItemsByValueOptions, @@ -410,4 +420,56 @@ export class ListClient { value, }); }; + + public findList = async ({ + filter, + currentIndexPosition, + perPage, + page, + sortField, + sortOrder, + searchAfter, + }: FindListOptions): Promise => { + const { callCluster } = this; + const listIndex = this.getListIndex(); + return findList({ + callCluster, + currentIndexPosition, + filter, + listIndex, + page, + perPage, + searchAfter, + sortField, + sortOrder, + }); + }; + + public findListItem = async ({ + listId, + filter, + currentIndexPosition, + perPage, + page, + sortField, + sortOrder, + searchAfter, + }: FindListItemOptions): Promise => { + const { callCluster } = this; + const listIndex = this.getListIndex(); + const listItemIndex = this.getListItemIndex(); + return findListItem({ + callCluster, + currentIndexPosition, + filter, + listId, + listIndex, + listItemIndex, + page, + perPage, + searchAfter, + sortField, + sortOrder, + }); + }; } diff --git a/x-pack/plugins/lists/server/services/lists/list_client_types.ts b/x-pack/plugins/lists/server/services/lists/list_client_types.ts index d66575e7a30db..4171b6ee9f165 100644 --- a/x-pack/plugins/lists/server/services/lists/list_client_types.ts +++ b/x-pack/plugins/lists/server/services/lists/list_client_types.ts @@ -11,11 +11,17 @@ import { APICaller } from 'kibana/server'; import { Description, DescriptionOrUndefined, + Filter, Id, IdOrUndefined, + ListId, MetaOrUndefined, Name, NameOrUndefined, + Page, + PerPage, + SortFieldOrUndefined, + SortOrderOrUndefined, Type, } from '../../../common/schemas'; import { ConfigType } from '../../config'; @@ -110,3 +116,24 @@ export interface GetListItemsByValueOptions { listId: string; value: string[]; } + +export interface FindListOptions { + currentIndexPosition: number; + filter: Filter; + perPage: PerPage; + page: Page; + searchAfter: string[] | undefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; +} + +export interface FindListItemOptions { + currentIndexPosition: number; + filter: Filter; + listId: ListId; + perPage: PerPage; + page: Page; + searchAfter: string[] | undefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; +} diff --git a/x-pack/plugins/lists/server/services/exception_lists/types.ts b/x-pack/plugins/lists/server/services/lists/types.ts similarity index 72% rename from x-pack/plugins/lists/server/services/exception_lists/types.ts rename to x-pack/plugins/lists/server/services/lists/types.ts index dbb188bc2754a..2e0e4b7d038e7 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/types.ts +++ b/x-pack/plugins/lists/server/services/lists/types.ts @@ -3,4 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -export type NamespaceType = 'agnostic' | 'single'; + +interface Scroll { + searchAfter: string[] | undefined; + validSearchAfterFound: boolean; +} diff --git a/x-pack/plugins/lists/server/services/lists/update_list.ts b/x-pack/plugins/lists/server/services/lists/update_list.ts index 9859adf062485..28be50e9d6ac8 100644 --- a/x-pack/plugins/lists/server/services/lists/update_list.ts +++ b/x-pack/plugins/lists/server/services/lists/update_list.ts @@ -51,7 +51,7 @@ export const updateList = async ({ updated_at: updatedAt, updated_by: user, }; - const response: CreateDocumentResponse = await callCluster('update', { + const response = await callCluster('update', { body: { doc }, id, index: listIndex, diff --git a/x-pack/plugins/lists/server/services/utils/calculate_scroll_math.ts b/x-pack/plugins/lists/server/services/utils/calculate_scroll_math.ts new file mode 100644 index 0000000000000..6ec240d844f84 --- /dev/null +++ b/x-pack/plugins/lists/server/services/utils/calculate_scroll_math.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Page, PerPage } from '../../../common/schemas'; + +interface CalculateScrollMathOptions { + perPage: PerPage; + page: Page; + hopSize: number; + currentIndexPosition: number; +} + +interface CalculateScrollMathReturn { + hops: number; + leftOverAfterHops: number; +} + +export const calculateScrollMath = ({ + currentIndexPosition, + page, + perPage, + hopSize, +}: CalculateScrollMathOptions): CalculateScrollMathReturn => { + const startPageIndex = (page - 1) * perPage - currentIndexPosition; + if (startPageIndex < 0) { + // This should never be hit but just in case I do a check. We do validate higher above this + // before the current index position gets to this point but to be safe we add this line. + throw new Error( + `page: ${page}, perPage ${perPage} and currentIndex ${currentIndexPosition} are less than zero` + ); + } + const hops = Math.floor(startPageIndex / hopSize); + const leftOverAfterHops = startPageIndex - hops * hopSize; + return { + hops, + leftOverAfterHops, + }; +}; diff --git a/x-pack/plugins/lists/server/services/utils/encode_decode_cursor.ts b/x-pack/plugins/lists/server/services/utils/encode_decode_cursor.ts new file mode 100644 index 0000000000000..205d61f204ba6 --- /dev/null +++ b/x-pack/plugins/lists/server/services/utils/encode_decode_cursor.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { CursorOrUndefined, SortFieldOrUndefined } from '../../../common/schemas'; +import { exactCheck } from '../../../common/siem_common_deps'; + +/** + * Used only internally for this ad-hoc opaque cursor structure to keep track of the + * current page_index that the search_after is currently on. The format of an array + * is to be consistent with other compact forms of opaque nature such as a saved object versioning. + * + * The format is [index of item, search_after_array] + */ + +// TODO: Use PositiveInteger from siem once that type is outside of server and in common +export const contextCursor = t.tuple([t.number, t.union([t.array(t.string), t.undefined])]); + +export type ContextCursor = t.TypeOf; + +export interface EncodeCursorOptions { + searchAfter: string[] | undefined; + page: number; + perPage: number; +} + +export const encodeCursor = ({ searchAfter, page, perPage }: EncodeCursorOptions): string => { + const index = searchAfter != null ? page * perPage : 0; + const encodedCursor = searchAfter != null ? [index, searchAfter] : [index]; + const scrollStringed = JSON.stringify(encodedCursor); + return Buffer.from(scrollStringed).toString('base64'); +}; + +export interface DecodeCursorOptions { + cursor: CursorOrUndefined; + page: number; + perPage: number; + sortField: SortFieldOrUndefined; +} + +export interface DecodeCursor { + cursor: ContextCursor; + isValid: boolean; + errorMessage: string; +} + +export const decodeCursor = ({ + cursor, + page, + perPage, + sortField, +}: DecodeCursorOptions): DecodeCursor => { + if (cursor == null) { + return { + cursor: [0, undefined], + errorMessage: '', + isValid: true, + }; + } else { + const fromBuffer = Buffer.from(cursor, 'base64').toString(); + const parsed = parseOrUndefined(fromBuffer); + if (parsed == null) { + return { + cursor: [0, undefined], + errorMessage: 'Error parsing JSON from base64 encoded cursor', + isValid: false, + }; + } else { + const decodedCursor = contextCursor.decode(parsed); + const checked = exactCheck(parsed, decodedCursor); + + const onLeft = (): ContextCursor | undefined => undefined; + const onRight = (schema: ContextCursor): ContextCursor | undefined => schema; + const cursorOrUndefined = pipe(checked, fold(onLeft, onRight)); + + const startPageIndex = (page - 1) * perPage; + if (cursorOrUndefined == null) { + return { + cursor: [0, undefined], + errorMessage: 'Error decoding cursor structure', + isValid: false, + }; + } else { + const [index, searchAfter] = cursorOrUndefined; + if (index < 0) { + return { + cursor: [0, undefined], + errorMessage: 'index of cursor cannot be less 0', + isValid: false, + }; + } else if (index > startPageIndex) { + return { + cursor: [0, undefined], + errorMessage: `index: ${index} of cursor cannot be greater than the start page index: ${startPageIndex}`, + isValid: false, + }; + } else if (searchAfter != null && searchAfter.length > 1 && sortField == null) { + return { + cursor: [0, undefined], + errorMessage: '', + isValid: false, + }; + } else { + return { + cursor: [index, searchAfter != null ? searchAfter : undefined], + errorMessage: '', + isValid: true, + }; + } + } + } + } +}; + +export const parseOrUndefined = (input: string): ContextCursor | undefined => { + try { + return JSON.parse(input); + } catch (err) { + return undefined; + } +}; diff --git a/x-pack/plugins/lists/server/services/utils/get_query_filter.test.ts b/x-pack/plugins/lists/server/services/utils/get_query_filter.test.ts new file mode 100644 index 0000000000000..50c266eb5d573 --- /dev/null +++ b/x-pack/plugins/lists/server/services/utils/get_query_filter.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getQueryFilter } from './get_query_filter'; + +describe('get_query_filter', () => { + test('it should work with a basic kuery', () => { + const esQuery = getQueryFilter({ filter: 'type: ip' }); + expect(esQuery).toEqual({ + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + type: 'ip', + }, + }, + ], + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/utils/get_query_filter.ts b/x-pack/plugins/lists/server/services/utils/get_query_filter.ts new file mode 100644 index 0000000000000..cf0dd5b6250e5 --- /dev/null +++ b/x-pack/plugins/lists/server/services/utils/get_query_filter.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DslQuery, EsQueryConfig } from 'src/plugins/data/common'; + +import { Filter, Query, esQuery } from '../../../../../../src/plugins/data/server'; + +export interface GetQueryFilterOptions { + filter: string; +} + +export interface GetQueryFilterReturn { + bool: { must: DslQuery[]; filter: Filter[]; should: never[]; must_not: Filter[] }; +} + +export const getQueryFilter = ({ filter }: GetQueryFilterOptions): GetQueryFilterReturn => { + const kqlQuery: Query = { + language: 'kuery', + query: filter, + }; + const config: EsQueryConfig = { + allowLeadingWildcards: true, + dateFormatTZ: 'Zulu', + ignoreFilterIfFieldNotInIndex: false, + queryStringOptions: { analyze_wildcard: true }, + }; + + return esQuery.buildEsQuery(undefined, kqlQuery, [], config); +}; diff --git a/x-pack/plugins/lists/server/services/utils/get_search_after_scroll.ts b/x-pack/plugins/lists/server/services/utils/get_search_after_scroll.ts new file mode 100644 index 0000000000000..9721baefbe5ee --- /dev/null +++ b/x-pack/plugins/lists/server/services/utils/get_search_after_scroll.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { APICaller } from 'kibana/server'; + +import { Filter, SortFieldOrUndefined, SortOrderOrUndefined } from '../../../common/schemas'; + +import { getQueryFilter } from './get_query_filter'; +import { getSortWithTieBreaker } from './get_sort_with_tie_breaker'; +import { getSourceWithTieBreaker } from './get_source_with_tie_breaker'; +import { TieBreaker, getSearchAfterWithTieBreaker } from './get_search_after_with_tie_breaker'; + +interface GetSearchAfterOptions { + callCluster: APICaller; + filter: Filter; + hops: number; + hopSize: number; + searchAfter: string[] | undefined; + index: string; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; +} + +export const getSearchAfterScroll = async ({ + callCluster, + filter, + hopSize, + hops, + searchAfter, + sortField, + sortOrder, + index, +}: GetSearchAfterOptions): Promise => { + const query = getQueryFilter({ filter }); + let newSearchAfter = searchAfter; + for (let i = 0; i < hops; ++i) { + const response = await callCluster>('search', { + body: { + _source: getSourceWithTieBreaker({ sortField }), + query, + search_after: newSearchAfter, + sort: getSortWithTieBreaker({ sortField, sortOrder }), + }, + ignoreUnavailable: true, + index, + size: hopSize, + }); + if (response.hits.hits.length > 0) { + newSearchAfter = getSearchAfterWithTieBreaker({ response, sortField }); + } else { + return { + searchAfter: undefined, + validSearchAfterFound: false, + }; + } + } + return { + searchAfter: newSearchAfter, + validSearchAfterFound: true, + }; +}; diff --git a/x-pack/plugins/lists/server/services/utils/get_search_after_with_tie_breaker.ts b/x-pack/plugins/lists/server/services/utils/get_search_after_with_tie_breaker.ts new file mode 100644 index 0000000000000..b5d44fbc9fd84 --- /dev/null +++ b/x-pack/plugins/lists/server/services/utils/get_search_after_with_tie_breaker.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchResponse } from 'elasticsearch'; + +import { SortFieldOrUndefined } from '../../../common/schemas'; + +export type TieBreaker = T & { + tie_breaker_id: string; +}; + +interface GetSearchAfterWithTieBreakerOptions { + response: SearchResponse>; + sortField: SortFieldOrUndefined; +} + +export const getSearchAfterWithTieBreaker = ({ + response, + sortField, +}: GetSearchAfterWithTieBreakerOptions): string[] | undefined => { + if (response.hits.hits.length === 0) { + return undefined; + } else { + const lastEsElement = response.hits.hits[response.hits.hits.length - 1]; + if (sortField == null) { + return [lastEsElement._source.tie_breaker_id]; + } else { + const [[, sortValue]] = Object.entries(lastEsElement._source).filter( + ([key]) => key === sortField + ); + if (typeof sortValue === 'string') { + return [sortValue, lastEsElement._source.tie_breaker_id]; + } else { + return [lastEsElement._source.tie_breaker_id]; + } + } + } +}; diff --git a/x-pack/plugins/lists/server/services/utils/get_sort_with_tie_breaker.ts b/x-pack/plugins/lists/server/services/utils/get_sort_with_tie_breaker.ts new file mode 100644 index 0000000000000..fee65cce580a0 --- /dev/null +++ b/x-pack/plugins/lists/server/services/utils/get_sort_with_tie_breaker.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SortFieldOrUndefined, SortOrderOrUndefined } from '../../../common/schemas'; + +export interface SortWithTieBreakerReturn { + tie_breaker_id: 'asc'; + [key: string]: string; +} + +export const getSortWithTieBreaker = ({ + sortField, + sortOrder, +}: { + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; +}): SortWithTieBreakerReturn[] | undefined => { + const ascOrDesc = sortOrder ?? 'asc'; + if (sortField != null) { + return [{ [sortField]: ascOrDesc, tie_breaker_id: 'asc' }]; + } else { + return [{ tie_breaker_id: 'asc' }]; + } +}; diff --git a/x-pack/plugins/lists/server/services/utils/get_source_with_tie_breaker.ts b/x-pack/plugins/lists/server/services/utils/get_source_with_tie_breaker.ts new file mode 100644 index 0000000000000..76cdd22f710e1 --- /dev/null +++ b/x-pack/plugins/lists/server/services/utils/get_source_with_tie_breaker.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SortFieldOrUndefined } from '../../../common/schemas'; + +export const getSourceWithTieBreaker = ({ + sortField, +}: { + sortField: SortFieldOrUndefined; +}): string[] => { + return sortField != null ? ['tie_breaker_id', sortField] : ['tie_breaker_id']; +}; diff --git a/x-pack/plugins/lists/server/services/utils/index.ts b/x-pack/plugins/lists/server/services/utils/index.ts index e6365e689f761..28bb3cea29e61 100644 --- a/x-pack/plugins/lists/server/services/utils/index.ts +++ b/x-pack/plugins/lists/server/services/utils/index.ts @@ -5,6 +5,14 @@ */ export * from './derive_type_from_es_type'; +export * from './encode_decode_cursor'; export * from './get_query_filter_from_type_value'; +export * from './get_query_filter'; +export * from './get_search_after_scroll'; +export * from './get_search_after_with_tie_breaker'; +export * from './get_sort_with_tie_breaker'; +export * from './get_source_with_tie_breaker'; +export * from './scroll_to_start_page'; export * from './transform_elastic_to_list_item'; +export * from './transform_elastic_to_list'; export * from './transform_list_item_to_elastic_query'; diff --git a/x-pack/plugins/lists/server/services/utils/scroll_to_start_page.ts b/x-pack/plugins/lists/server/services/utils/scroll_to_start_page.ts new file mode 100644 index 0000000000000..16e07044dc0d4 --- /dev/null +++ b/x-pack/plugins/lists/server/services/utils/scroll_to_start_page.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { APICaller } from 'kibana/server'; + +import { Filter, SortFieldOrUndefined, SortOrderOrUndefined } from '../../../common/schemas'; + +import { calculateScrollMath } from './calculate_scroll_math'; +import { getSearchAfterScroll } from './get_search_after_scroll'; + +interface ScrollToStartPageOptions { + callCluster: APICaller; + filter: Filter; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; + page: number; + perPage: number; + hopSize: number; + index: string; + currentIndexPosition: number; + searchAfter: string[] | undefined; +} + +export const scrollToStartPage = async ({ + callCluster, + filter, + hopSize, + currentIndexPosition, + searchAfter, + page, + perPage, + sortOrder, + sortField, + index, +}: ScrollToStartPageOptions): Promise => { + const { hops, leftOverAfterHops } = calculateScrollMath({ + currentIndexPosition, + hopSize, + page, + perPage, + }); + + if (hops === 0 && leftOverAfterHops === 0 && currentIndexPosition === 0) { + // We want to use a valid searchAfter of undefined to start at the start of our list + return { + searchAfter: undefined, + validSearchAfterFound: true, + }; + } else if (hops === 0 && leftOverAfterHops === 0 && currentIndexPosition > 0) { + return { + searchAfter, + validSearchAfterFound: true, + }; + } else if (hops > 0) { + const scroll = await getSearchAfterScroll({ + callCluster, + filter, + hopSize, + hops, + index, + searchAfter, + sortField, + sortOrder, + }); + if (scroll.validSearchAfterFound && leftOverAfterHops > 0) { + return getSearchAfterScroll({ + callCluster, + filter, + hopSize: leftOverAfterHops, + hops: 1, + index, + searchAfter: scroll.searchAfter, + sortField, + sortOrder, + }); + } else { + return scroll; + } + } else { + return getSearchAfterScroll({ + callCluster, + filter, + hopSize: leftOverAfterHops, + hops: 1, + index, + searchAfter, + sortField, + sortOrder, + }); + } +}; diff --git a/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list.ts b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list.ts new file mode 100644 index 0000000000000..bb1ae1d4b9ff3 --- /dev/null +++ b/x-pack/plugins/lists/server/services/utils/transform_elastic_to_list.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchResponse } from 'elasticsearch'; + +import { ListArraySchema, SearchEsListSchema } from '../../../common/schemas'; + +export interface TransformElasticToListOptions { + response: SearchResponse; +} + +export const transformElasticToList = ({ + response, +}: TransformElasticToListOptions): ListArraySchema => { + return response.hits.hits.map((hit) => { + return { + id: hit._id, + ...hit._source, + }; + }); +}; diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index 613e507459389..8fa44c512df4b 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -72,11 +72,12 @@ export enum FIELD_ORIGIN { } export const JOIN_FIELD_NAME_PREFIX = '__kbnjoin__'; -export const SOURCE_DATA_ID_ORIGIN = 'source'; -export const META_ID_ORIGIN_SUFFIX = 'meta'; -export const SOURCE_META_ID_ORIGIN = `${SOURCE_DATA_ID_ORIGIN}_${META_ID_ORIGIN_SUFFIX}`; -export const FORMATTERS_ID_ORIGIN_SUFFIX = 'formatters'; -export const SOURCE_FORMATTERS_ID_ORIGIN = `${SOURCE_DATA_ID_ORIGIN}_${FORMATTERS_ID_ORIGIN_SUFFIX}`; +export const META_DATA_REQUEST_ID_SUFFIX = 'meta'; +export const FORMATTERS_DATA_REQUEST_ID_SUFFIX = 'formatters'; +export const SOURCE_DATA_REQUEST_ID = 'source'; +export const SOURCE_META_DATA_REQUEST_ID = `${SOURCE_DATA_REQUEST_ID}_${META_DATA_REQUEST_ID_SUFFIX}`; +export const SOURCE_FORMATTERS_DATA_REQUEST_ID = `${SOURCE_DATA_REQUEST_ID}_${FORMATTERS_DATA_REQUEST_ID_SUFFIX}`; +export const SOURCE_BOUNDS_DATA_REQUEST_ID = `${SOURCE_DATA_REQUEST_ID}_bounds`; export const MIN_ZOOM = 0; export const MAX_ZOOM = 24; diff --git a/x-pack/plugins/maps/common/descriptor_types/descriptor_types.d.ts b/x-pack/plugins/maps/common/descriptor_types/descriptor_types.d.ts index 4bdafcabaad06..b412375874f68 100644 --- a/x-pack/plugins/maps/common/descriptor_types/descriptor_types.d.ts +++ b/x-pack/plugins/maps/common/descriptor_types/descriptor_types.d.ts @@ -132,6 +132,7 @@ export type SourceDescriptor = export type LayerDescriptor = { __dataRequests?: DataRequestDescriptor[]; __isInErrorState?: boolean; + __isPreviewLayer?: boolean; __errorMessage?: string; __trackedLayerDescriptor?: LayerDescriptor; alpha?: number; diff --git a/x-pack/plugins/maps/public/actions/data_request_actions.ts b/x-pack/plugins/maps/public/actions/data_request_actions.ts index d418214416900..13b658af6a0f3 100644 --- a/x-pack/plugins/maps/public/actions/data_request_actions.ts +++ b/x-pack/plugins/maps/public/actions/data_request_actions.ts @@ -6,12 +6,15 @@ /* eslint-disable @typescript-eslint/consistent-type-definitions */ import { Dispatch } from 'redux'; +// @ts-ignore +import turf from 'turf'; import { FeatureCollection } from 'geojson'; import { MapStoreState } from '../reducers/store'; -import { LAYER_TYPE, SOURCE_DATA_ID_ORIGIN } from '../../common/constants'; +import { LAYER_TYPE, SOURCE_DATA_REQUEST_ID } from '../../common/constants'; import { getDataFilters, getDataRequestDescriptor, + getFittableLayers, getLayerById, getLayerList, } from '../selectors/map_selectors'; @@ -27,13 +30,15 @@ import { LAYER_DATA_LOAD_ENDED, LAYER_DATA_LOAD_ERROR, LAYER_DATA_LOAD_STARTED, + SET_GOTO, SET_LAYER_ERROR_STATUS, SET_LAYER_STYLE_META, UPDATE_LAYER_PROP, UPDATE_SOURCE_DATA_REQUEST, } from './map_action_constants'; import { ILayer } from '../classes/layers/layer'; -import { DataMeta, MapFilters } from '../../common/descriptor_types'; +import { DataMeta, MapExtent, MapFilters } from '../../common/descriptor_types'; +import { DataRequestAbortError } from '../classes/util/data_request'; export type DataRequestContext = { startLoading(dataId: string, requestToken: symbol, meta: DataMeta): void; @@ -269,7 +274,7 @@ export function updateSourceDataRequest(layerId: string, newData: unknown) { return (dispatch: Dispatch) => { dispatch({ type: UPDATE_SOURCE_DATA_REQUEST, - dataId: SOURCE_DATA_ID_ORIGIN, + dataId: SOURCE_DATA_REQUEST_ID, layerId, newData, }); @@ -277,3 +282,99 @@ export function updateSourceDataRequest(layerId: string, newData: unknown) { dispatch(updateStyleMeta(layerId)); }; } + +export function fitToLayerExtent(layerId: string) { + return async (dispatch: Dispatch, getState: () => MapStoreState) => { + const targetLayer = getLayerById(layerId, getState()); + + if (targetLayer) { + try { + const bounds = await targetLayer.getBounds( + getDataRequestContext(dispatch, getState, layerId) + ); + if (bounds) { + await dispatch(setGotoWithBounds(bounds)); + } + } catch (error) { + if (!(error instanceof DataRequestAbortError)) { + // eslint-disable-next-line no-console + console.warn( + 'Unhandled getBounds error for layer. Only DataRequestAbortError should be surfaced', + error + ); + } + // new fitToLayerExtent request has superseded this thread of execution. Results no longer needed. + return; + } + } + }; +} + +export function fitToDataBounds() { + return async (dispatch: Dispatch, getState: () => MapStoreState) => { + const layerList = getFittableLayers(getState()); + + if (!layerList.length) { + return; + } + + const boundsPromises = layerList.map(async (layer: ILayer) => { + return layer.getBounds(getDataRequestContext(dispatch, getState, layer.getId())); + }); + + let bounds; + try { + bounds = await Promise.all(boundsPromises); + } catch (error) { + if (!(error instanceof DataRequestAbortError)) { + // eslint-disable-next-line no-console + console.warn( + 'Unhandled getBounds error for layer. Only DataRequestAbortError should be surfaced', + error + ); + } + // new fitToDataBounds request has superseded this thread of execution. Results no longer needed. + return; + } + + const corners = []; + for (let i = 0; i < bounds.length; i++) { + const b = bounds[i]; + + // filter out undefined bounds (uses Infinity due to turf responses) + if ( + b === null || + b.minLon === Infinity || + b.maxLon === Infinity || + b.minLat === -Infinity || + b.maxLat === -Infinity + ) { + continue; + } + + corners.push([b.minLon, b.minLat]); + corners.push([b.maxLon, b.maxLat]); + } + + if (!corners.length) { + return; + } + + const turfUnionBbox = turf.bbox(turf.multiPoint(corners)); + const dataBounds = { + minLon: turfUnionBbox[0], + minLat: turfUnionBbox[1], + maxLon: turfUnionBbox[2], + maxLat: turfUnionBbox[3], + }; + + dispatch(setGotoWithBounds(dataBounds)); + }; +} + +function setGotoWithBounds(bounds: MapExtent) { + return { + type: SET_GOTO, + bounds, + }; +} diff --git a/x-pack/plugins/maps/public/actions/index.ts b/x-pack/plugins/maps/public/actions/index.ts index a2e90ff6e9f28..5b153e37da5a8 100644 --- a/x-pack/plugins/maps/public/actions/index.ts +++ b/x-pack/plugins/maps/public/actions/index.ts @@ -9,7 +9,12 @@ export * from './ui_actions'; export * from './map_actions'; export * from './map_action_constants'; export * from './layer_actions'; -export { cancelAllInFlightRequests, DataRequestContext } from './data_request_actions'; +export { + cancelAllInFlightRequests, + DataRequestContext, + fitToLayerExtent, + fitToDataBounds, +} from './data_request_actions'; export { closeOnClickTooltip, openOnClickTooltip, diff --git a/x-pack/plugins/maps/public/actions/layer_actions.ts b/x-pack/plugins/maps/public/actions/layer_actions.ts index cac79093ce437..51e251a5d8e20 100644 --- a/x-pack/plugins/maps/public/actions/layer_actions.ts +++ b/x-pack/plugins/maps/public/actions/layer_actions.ts @@ -9,10 +9,10 @@ import { Query } from 'src/plugins/data/public'; import { MapStoreState } from '../reducers/store'; import { getLayerById, + getLayerList, getLayerListRaw, getSelectedLayerId, getMapReady, - getTransientLayerId, } from '../selectors/map_selectors'; import { FLYOUT_STATE } from '../reducers/ui'; import { cancelRequest } from '../reducers/non_serializable_instances'; @@ -27,7 +27,6 @@ import { SET_JOINS, SET_LAYER_VISIBILITY, SET_SELECTED_LAYER, - SET_TRANSIENT_LAYER, SET_WAITING_FOR_READY_HIDDEN_LAYERS, TRACK_CURRENT_LAYER_STATE, UPDATE_LAYER_ORDER, @@ -139,6 +138,41 @@ export function addLayerWithoutDataSync(layerDescriptor: LayerDescriptor) { }; } +export function addPreviewLayers(layerDescriptors: LayerDescriptor[]) { + return (dispatch: Dispatch) => { + dispatch(removePreviewLayers()); + + layerDescriptors.forEach((layerDescriptor) => { + dispatch(addLayer({ ...layerDescriptor, __isPreviewLayer: true })); + }); + }; +} + +export function removePreviewLayers() { + return (dispatch: Dispatch, getState: () => MapStoreState) => { + getLayerList(getState()).forEach((layer) => { + if (layer.isPreviewLayer()) { + dispatch(removeLayer(layer.getId())); + } + }); + }; +} + +export function promotePreviewLayers() { + return (dispatch: Dispatch, getState: () => MapStoreState) => { + getLayerList(getState()).forEach((layer) => { + if (layer.isPreviewLayer()) { + dispatch({ + type: UPDATE_LAYER_PROP, + id: layer.getId(), + propName: '__isPreviewLayer', + newValue: false, + }); + } + }); + }; +} + export function setLayerVisibility(layerId: string, makeVisible: boolean) { return async (dispatch: Dispatch, getState: () => MapStoreState) => { // if the current-state is invisible, we also want to sync data @@ -193,31 +227,17 @@ export function setSelectedLayer(layerId: string | null) { }; } -export function removeTransientLayer() { +export function setFirstPreviewLayerToSelectedLayer() { return async (dispatch: Dispatch, getState: () => MapStoreState) => { - const transientLayerId = getTransientLayerId(getState()); - if (transientLayerId) { - await dispatch(removeLayerFromLayerList(transientLayerId)); - await dispatch(setTransientLayer(null)); + const firstPreviewLayer = getLayerList(getState()).find((layer) => { + return layer.isPreviewLayer(); + }); + if (firstPreviewLayer) { + dispatch(setSelectedLayer(firstPreviewLayer.getId())); } }; } -export function setTransientLayer(layerId: string | null) { - return { - type: SET_TRANSIENT_LAYER, - transientLayerId: layerId, - }; -} - -export function clearTransientLayerStateAndCloseFlyout() { - return async (dispatch: Dispatch) => { - await dispatch(updateFlyout(FLYOUT_STATE.NONE)); - await dispatch(setSelectedLayer(null)); - await dispatch(removeTransientLayer()); - }; -} - export function updateLayerOrder(newLayerOrder: number[]) { return { type: UPDATE_LAYER_ORDER, diff --git a/x-pack/plugins/maps/public/actions/map_action_constants.ts b/x-pack/plugins/maps/public/actions/map_action_constants.ts index 0a32dba119429..25a86e4c50d07 100644 --- a/x-pack/plugins/maps/public/actions/map_action_constants.ts +++ b/x-pack/plugins/maps/public/actions/map_action_constants.ts @@ -5,7 +5,6 @@ */ export const SET_SELECTED_LAYER = 'SET_SELECTED_LAYER'; -export const SET_TRANSIENT_LAYER = 'SET_TRANSIENT_LAYER'; export const UPDATE_LAYER_ORDER = 'UPDATE_LAYER_ORDER'; export const ADD_LAYER = 'ADD_LAYER'; export const SET_LAYER_ERROR_STATUS = 'SET_LAYER_ERROR_STATUS'; diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index 02842edabbd2e..75df8689a670e 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -12,11 +12,9 @@ import turfBooleanContains from '@turf/boolean-contains'; import { Filter, Query, TimeRange } from 'src/plugins/data/public'; import { MapStoreState } from '../reducers/store'; import { - getLayerById, getDataFilters, getWaitingForMapReadyLayerListRaw, getQuery, - getFittableLayers, } from '../selectors/map_selectors'; import { CLEAR_GOTO, @@ -184,76 +182,6 @@ export function disableScrollZoom() { return { type: SET_SCROLL_ZOOM, scrollZoom: false }; } -export function fitToLayerExtent(layerId: string) { - return async (dispatch: Dispatch, getState: () => MapStoreState) => { - const targetLayer = getLayerById(layerId, getState()); - - if (targetLayer) { - const dataFilters = getDataFilters(getState()); - const bounds = await targetLayer.getBounds(dataFilters); - if (bounds) { - await dispatch(setGotoWithBounds(bounds)); - } - } - }; -} - -export function fitToDataBounds() { - return async (dispatch: Dispatch, getState: () => MapStoreState) => { - const layerList = getFittableLayers(getState()); - - if (!layerList.length) { - return; - } - - const dataFilters = getDataFilters(getState()); - const boundsPromises = layerList.map(async (layer) => { - return layer.getBounds(dataFilters); - }); - - const bounds = await Promise.all(boundsPromises); - const corners = []; - for (let i = 0; i < bounds.length; i++) { - const b = bounds[i]; - - // filter out undefined bounds (uses Infinity due to turf responses) - if ( - b === null || - b.minLon === Infinity || - b.maxLon === Infinity || - b.minLat === -Infinity || - b.maxLat === -Infinity - ) { - continue; - } - - corners.push([b.minLon, b.minLat]); - corners.push([b.maxLon, b.maxLat]); - } - - if (!corners.length) { - return; - } - - const turfUnionBbox = turf.bbox(turf.multiPoint(corners)); - const dataBounds = { - minLon: turfUnionBbox[0], - minLat: turfUnionBbox[1], - maxLon: turfUnionBbox[2], - maxLat: turfUnionBbox[3], - }; - - dispatch(setGotoWithBounds(dataBounds)); - }; -} - -export function setGotoWithBounds(bounds: MapExtent) { - return { - type: SET_GOTO, - bounds, - }; -} - export function setGotoWithCenter({ lat, lon, zoom }: MapCenterAndZoom) { return { type: SET_GOTO, diff --git a/x-pack/plugins/maps/public/classes/joins/inner_join.js b/x-pack/plugins/maps/public/classes/joins/inner_join.js index 5f8bc7385d04c..76afe2430b818 100644 --- a/x-pack/plugins/maps/public/classes/joins/inner_join.js +++ b/x-pack/plugins/maps/public/classes/joins/inner_join.js @@ -6,7 +6,10 @@ import { ESTermSource } from '../sources/es_term_source'; import { getComputedFieldNamePrefix } from '../styles/vector/style_util'; -import { META_ID_ORIGIN_SUFFIX, FORMATTERS_ID_ORIGIN_SUFFIX } from '../../../common/constants'; +import { + META_DATA_REQUEST_ID_SUFFIX, + FORMATTERS_DATA_REQUEST_ID_SUFFIX, +} from '../../../common/constants'; export class InnerJoin { constructor(joinDescriptor, leftSource) { @@ -42,11 +45,11 @@ export class InnerJoin { } getSourceMetaDataRequestId() { - return `${this.getSourceDataRequestId()}_${META_ID_ORIGIN_SUFFIX}`; + return `${this.getSourceDataRequestId()}_${META_DATA_REQUEST_ID_SUFFIX}`; } getSourceFormattersDataRequestId() { - return `${this.getSourceDataRequestId()}_${FORMATTERS_ID_ORIGIN_SUFFIX}`; + return `${this.getSourceDataRequestId()}_${FORMATTERS_DATA_REQUEST_ID_SUFFIX}`; } getLeftField() { diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index 263e9888cd059..2250d5663378c 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -17,21 +17,16 @@ import { MAX_ZOOM, MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER, MIN_ZOOM, - SOURCE_DATA_ID_ORIGIN, + SOURCE_DATA_REQUEST_ID, } from '../../../common/constants'; import { copyPersistentState } from '../../reducers/util'; -import { - LayerDescriptor, - MapExtent, - MapFilters, - StyleDescriptor, -} from '../../../common/descriptor_types'; +import { LayerDescriptor, MapExtent, StyleDescriptor } from '../../../common/descriptor_types'; import { Attribution, ImmutableSourceProperty, ISource, SourceEditorArgs } from '../sources/source'; import { DataRequestContext } from '../../actions'; import { IStyle } from '../styles/style'; export interface ILayer { - getBounds(mapFilters: MapFilters): Promise; + getBounds(dataRequestContext: DataRequestContext): Promise; getDataRequest(id: string): DataRequest | undefined; getDisplayName(source?: ISource): Promise; getId(): string; @@ -80,6 +75,7 @@ export interface ILayer { getInFlightRequestTokens(): symbol[]; getPrevRequestToken(dataId: string): symbol | undefined; destroy: () => void; + isPreviewLayer: () => boolean; } export type Footnote = { icon: ReactElement; @@ -179,6 +175,10 @@ export class AbstractLayer implements ILayer { return this.getSource().isJoinable(); } + isPreviewLayer(): boolean { + return !!this._descriptor.__isPreviewLayer; + } + supportsElasticsearchFilters(): boolean { return this.getSource().isESSource(); } @@ -396,7 +396,7 @@ export class AbstractLayer implements ILayer { } getSourceDataRequest(): DataRequest | undefined { - return this.getDataRequest(SOURCE_DATA_ID_ORIGIN); + return this.getDataRequest(SOURCE_DATA_REQUEST_ID); } getDataRequest(id: string): DataRequest | undefined { @@ -450,13 +450,8 @@ export class AbstractLayer implements ILayer { return sourceDataRequest ? sourceDataRequest.hasData() : false; } - async getBounds(mapFilters: MapFilters): Promise { - return { - minLon: -180, - maxLon: 180, - minLat: -89, - maxLat: 89, - }; + async getBounds(dataRequestContext: DataRequestContext): Promise { + return null; } renderStyleEditor({ diff --git a/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts b/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts index 7698fb7c0947e..2bdeb6446cf28 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts +++ b/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts @@ -9,7 +9,7 @@ import { ReactElement } from 'react'; import { LayerDescriptor } from '../../../common/descriptor_types'; export type RenderWizardArguments = { - previewLayer: (layerDescriptor: LayerDescriptor | null, isIndexingSource?: boolean) => void; + previewLayers: (layerDescriptors: LayerDescriptor[], isIndexingSource?: boolean) => void; mapColors: string[]; // upload arguments isIndexingTriggered: boolean; diff --git a/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/observability_layer_template.tsx b/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/observability_layer_template.tsx index bfd78d5490059..3f3c556dcae1e 100644 --- a/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/observability_layer_template.tsx +++ b/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/observability_layer_template.tsx @@ -53,13 +53,12 @@ export class ObservabilityLayerTemplate extends Component { return ( dynamicStyleProp.getFieldOrigin() === FIELD_ORIGIN.SOURCE && @@ -470,7 +494,7 @@ export class VectorLayer extends AbstractLayer { layerName, style, dynamicStyleProps, - registerCancelCallback, + registerCancelCallback.bind(null, requestToken), nextMeta ); stopLoading(dataRequestId, requestToken, styleMeta, nextMeta); @@ -488,7 +512,7 @@ export class VectorLayer extends AbstractLayer { return this._syncFormatters({ source, - dataRequestId: SOURCE_FORMATTERS_ID_ORIGIN, + dataRequestId: SOURCE_FORMATTERS_DATA_REQUEST_ID, fields: style .getDynamicPropertiesArray() .filter((dynamicStyleProp) => { diff --git a/x-pack/plugins/maps/public/classes/layers/vector_tile_layer/vector_tile_layer.js b/x-pack/plugins/maps/public/classes/layers/vector_tile_layer/vector_tile_layer.js index 6f616afb64041..61ec02e72adf2 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_tile_layer/vector_tile_layer.js +++ b/x-pack/plugins/maps/public/classes/layers/vector_tile_layer/vector_tile_layer.js @@ -6,7 +6,7 @@ import { TileLayer } from '../tile_layer/tile_layer'; import _ from 'lodash'; -import { SOURCE_DATA_ID_ORIGIN, LAYER_TYPE, LAYER_STYLE_TYPE } from '../../../../common/constants'; +import { SOURCE_DATA_REQUEST_ID, LAYER_TYPE, LAYER_STYLE_TYPE } from '../../../../common/constants'; import { isRetina } from '../../../meta'; import { addSpriteSheetToMapFromImageData, @@ -56,16 +56,16 @@ export class VectorTileLayer extends TileLayer { const requestToken = Symbol(`layer-source-refresh:${this.getId()} - source`); try { - startLoading(SOURCE_DATA_ID_ORIGIN, requestToken, dataFilters); + startLoading(SOURCE_DATA_REQUEST_ID, requestToken, dataFilters); const styleAndSprites = await this.getSource().getVectorStyleSheetAndSpriteMeta(isRetina()); const spriteSheetImageData = await loadSpriteSheetImageData(styleAndSprites.spriteMeta.png); const data = { ...styleAndSprites, spriteSheetImageData, }; - stopLoading(SOURCE_DATA_ID_ORIGIN, requestToken, data, nextMeta); + stopLoading(SOURCE_DATA_REQUEST_ID, requestToken, data, nextMeta); } catch (error) { - onLoadError(SOURCE_DATA_ID_ORIGIN, requestToken, error.message); + onLoadError(SOURCE_DATA_REQUEST_ID, requestToken, error.message); } } diff --git a/x-pack/plugins/maps/public/classes/sources/client_file_source/upload_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/client_file_source/upload_layer_wizard.tsx index d5ee354914e5c..3f4ec0d3f1268 100644 --- a/x-pack/plugins/maps/public/classes/sources/client_file_source/upload_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/client_file_source/upload_layer_wizard.tsx @@ -28,7 +28,7 @@ export const uploadLayerWizardConfig: LayerWizard = { icon: 'importAction', isIndexingSource: true, renderWizard: ({ - previewLayer, + previewLayers, mapColors, isIndexingTriggered, onRemove, @@ -38,13 +38,13 @@ export const uploadLayerWizardConfig: LayerWizard = { }: RenderWizardArguments) => { function previewGeojsonFile(geojsonFile: unknown, name: string) { if (!geojsonFile) { - previewLayer(null); + previewLayers([]); return; } const sourceDescriptor = GeojsonFileSource.createDescriptor(geojsonFile, name); const layerDescriptor = VectorLayer.createDescriptor({ sourceDescriptor }, mapColors); // TODO figure out a better way to handle passing this information back to layer_addpanel - previewLayer(layerDescriptor, true); + previewLayers([layerDescriptor], true); } function viewIndexedData(indexResponses: { @@ -72,7 +72,7 @@ export const uploadLayerWizardConfig: LayerWizard = { ) ); if (!indexPatternId || !geoField) { - previewLayer(null); + previewLayers([]); } else { const esSearchSourceConfig = { indexPatternId, @@ -85,7 +85,7 @@ export const uploadLayerWizardConfig: LayerWizard = { ? SCALING_TYPES.CLUSTERS : SCALING_TYPES.LIMIT, }; - previewLayer(createDefaultLayerDescriptor(esSearchSourceConfig, mapColors)); + previewLayers([createDefaultLayerDescriptor(esSearchSourceConfig, mapColors)]); importSuccessHandler(indexResponses); } } diff --git a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx index 4f1edca75b308..7eec84ef5bb2e 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx @@ -22,11 +22,11 @@ export const emsBoundariesLayerWizardConfig: LayerWizard = { defaultMessage: 'Administrative boundaries from Elastic Maps Service', }), icon: 'emsApp', - renderWizard: ({ previewLayer, mapColors }: RenderWizardArguments) => { + renderWizard: ({ previewLayers, mapColors }: RenderWizardArguments) => { const onSourceConfigChange = (sourceConfig: Partial) => { const sourceDescriptor = EMSFileSource.createDescriptor(sourceConfig); const layerDescriptor = VectorLayer.createDescriptor({ sourceDescriptor }, mapColors); - previewLayer(layerDescriptor); + previewLayers([layerDescriptor]); }; return ; }, diff --git a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx index 7a25609c6a5d1..60e67b1ae7053 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx @@ -22,12 +22,12 @@ export const emsBaseMapLayerWizardConfig: LayerWizard = { defaultMessage: 'Tile map service from Elastic Maps Service', }), icon: 'emsApp', - renderWizard: ({ previewLayer }: RenderWizardArguments) => { + renderWizard: ({ previewLayers }: RenderWizardArguments) => { const onSourceConfigChange = (sourceConfig: unknown) => { const layerDescriptor = VectorTileLayer.createDescriptor({ sourceDescriptor: EMSTMSSource.createDescriptor(sourceConfig), }); - previewLayer(layerDescriptor); + previewLayers([layerDescriptor]); }; return ; diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx index 84bdee2a64bd8..b9d5faa8e18f1 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx @@ -34,10 +34,10 @@ export const clustersLayerWizardConfig: LayerWizard = { defaultMessage: 'Geospatial data grouped in grids with metrics for each gridded cell', }), icon: 'logoElasticsearch', - renderWizard: ({ previewLayer }: RenderWizardArguments) => { + renderWizard: ({ previewLayers }: RenderWizardArguments) => { const onSourceConfigChange = (sourceConfig: Partial) => { if (!sourceConfig) { - previewLayer(null); + previewLayers([]); return; } @@ -93,7 +93,7 @@ export const clustersLayerWizardConfig: LayerWizard = { }, }), }); - previewLayer(layerDescriptor); + previewLayers([layerDescriptor]); }; return ( diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx index d0e45cb05ca06..79252c7febf8c 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx @@ -21,17 +21,17 @@ export const heatmapLayerWizardConfig: LayerWizard = { defaultMessage: 'Geospatial data grouped in grids to show density', }), icon: 'logoElasticsearch', - renderWizard: ({ previewLayer }: RenderWizardArguments) => { + renderWizard: ({ previewLayers }: RenderWizardArguments) => { const onSourceConfigChange = (sourceConfig: Partial) => { if (!sourceConfig) { - previewLayer(null); + previewLayers([]); return; } const layerDescriptor = HeatmapLayer.createDescriptor({ sourceDescriptor: ESGeoGridSource.createDescriptor(sourceConfig), }); - previewLayer(layerDescriptor); + previewLayers([layerDescriptor]); }; return ( diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx index 8d7bf0d2af661..5169af9bdddf2 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx @@ -28,10 +28,10 @@ export const point2PointLayerWizardConfig: LayerWizard = { defaultMessage: 'Aggregated data paths between the source and destination', }), icon: 'logoElasticsearch', - renderWizard: ({ previewLayer }: RenderWizardArguments) => { + renderWizard: ({ previewLayers }: RenderWizardArguments) => { const onSourceConfigChange = (sourceConfig: unknown) => { if (!sourceConfig) { - previewLayer(null); + previewLayers([]); return; } @@ -64,7 +64,7 @@ export const point2PointLayerWizardConfig: LayerWizard = { }, }), }); - previewLayer(layerDescriptor); + previewLayers([layerDescriptor]); }; return ; diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx index 8898735427ccb..888de2e7297cb 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx @@ -28,14 +28,14 @@ export const esDocumentsLayerWizardConfig: LayerWizard = { defaultMessage: 'Vector data from a Kibana index pattern', }), icon: 'logoElasticsearch', - renderWizard: ({ previewLayer, mapColors }: RenderWizardArguments) => { + renderWizard: ({ previewLayers, mapColors }: RenderWizardArguments) => { const onSourceConfigChange = (sourceConfig: unknown) => { if (!sourceConfig) { - previewLayer(null); + previewLayers([]); return; } - previewLayer(createDefaultLayerDescriptor(sourceConfig, mapColors)); + previewLayers([createDefaultLayerDescriptor(sourceConfig, mapColors)]); }; return ; }, diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.js b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.js index 072f952fb8a13..450894d81485c 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.js @@ -145,11 +145,8 @@ export class AbstractESSource extends AbstractVectorSource { return searchSource; } - async getBoundsForFilters({ sourceQuery, query, timeFilters, filters, applyGlobalQuery }) { - const searchSource = await this.makeSearchSource( - { sourceQuery, query, timeFilters, filters, applyGlobalQuery }, - 0 - ); + async getBoundsForFilters(boundsFilters, registerCancelCallback) { + const searchSource = await this.makeSearchSource(boundsFilters, 0); searchSource.setField('aggs', { fitToBounds: { geo_bounds: { @@ -160,13 +157,19 @@ export class AbstractESSource extends AbstractVectorSource { let esBounds; try { - const esResp = await searchSource.fetch(); + const abortController = new AbortController(); + registerCancelCallback(() => abortController.abort()); + const esResp = await searchSource.fetch({ abortSignal: abortController.signal }); if (!esResp.aggregations.fitToBounds.bounds) { // aggregations.fitToBounds is empty object when there are no matching documents return null; } esBounds = esResp.aggregations.fitToBounds.bounds; } catch (error) { + if (error.name === 'AbortError') { + throw new DataRequestAbortError(); + } + return null; } diff --git a/x-pack/plugins/maps/public/classes/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx index 309cb3abd83b2..b778dc0076459 100644 --- a/x-pack/plugins/maps/public/classes/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx @@ -24,11 +24,11 @@ export const kibanaRegionMapLayerWizardConfig: LayerWizard = { defaultMessage: 'Vector data from hosted GeoJSON configured in kibana.yml', }), icon: 'logoKibana', - renderWizard: ({ previewLayer, mapColors }: RenderWizardArguments) => { + renderWizard: ({ previewLayers, mapColors }: RenderWizardArguments) => { const onSourceConfigChange = (sourceConfig: unknown) => { const sourceDescriptor = KibanaRegionmapSource.createDescriptor(sourceConfig); const layerDescriptor = VectorLayer.createDescriptor({ sourceDescriptor }, mapColors); - previewLayer(layerDescriptor); + previewLayers([layerDescriptor]); }; return ; diff --git a/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx index 46513985ed1ab..227c0182b98de 100644 --- a/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx @@ -24,12 +24,12 @@ export const kibanaBasemapLayerWizardConfig: LayerWizard = { defaultMessage: 'Tile map service configured in kibana.yml', }), icon: 'logoKibana', - renderWizard: ({ previewLayer }: RenderWizardArguments) => { + renderWizard: ({ previewLayers }: RenderWizardArguments) => { const onSourceConfigChange = () => { const layerDescriptor = TileLayer.createDescriptor({ sourceDescriptor: KibanaTilemapSource.createDescriptor(), }); - previewLayer(layerDescriptor); + previewLayers([layerDescriptor]); }; return ; }, diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx index 86f8108d5e23b..c29302a2058b2 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx @@ -19,11 +19,11 @@ export const mvtVectorSourceWizardConfig: LayerWizard = { defaultMessage: 'Vector source wizard', }), icon: 'grid', - renderWizard: ({ previewLayer, mapColors }: RenderWizardArguments) => { + renderWizard: ({ previewLayers, mapColors }: RenderWizardArguments) => { const onSourceConfigChange = (sourceConfig: MVTSingleLayerVectorSourceConfig) => { const sourceDescriptor = MVTSingleLayerVectorSource.createDescriptor(sourceConfig); const layerDescriptor = TiledVectorLayer.createDescriptor({ sourceDescriptor }, mapColors); - previewLayer(layerDescriptor); + previewLayers([layerDescriptor]); }; return ; diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.ts b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.ts index 5f6061b38678c..86a1589a7a030 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/mvt_single_layer_vector_source.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import uuid from 'uuid/v4'; import { AbstractSource, ImmutableSourceProperty } from '../source'; -import { GeoJsonWithMeta, ITiledSingleLayerVectorSource } from '../vector_source'; +import { BoundsFilters, GeoJsonWithMeta, ITiledSingleLayerVectorSource } from '../vector_source'; import { MAX_ZOOM, MIN_ZOOM, SOURCE_TYPES } from '../../../../common/constants'; import { VECTOR_SHAPE_TYPES } from '../vector_feature_types'; import { IField } from '../../fields/field'; @@ -16,7 +16,6 @@ import { getDataSourceLabel, getUrlLabel } from '../../../../common/i18n_getters import { MapExtent, TiledSingleLayerVectorSourceDescriptor, - VectorSourceRequestMeta, VectorSourceSyncMeta, } from '../../../../common/descriptor_types'; import { MVTSingleLayerVectorSourceConfig } from './mvt_single_layer_vector_source_editor'; @@ -133,13 +132,11 @@ export class MVTSingleLayerVectorSource extends AbstractSource return this._descriptor.maxSourceZoom; } - getBoundsForFilters(searchFilters: VectorSourceRequestMeta): MapExtent { - return { - maxLat: 90, - maxLon: 180, - minLat: -90, - minLon: -180, - }; + getBoundsForFilters( + boundsFilters: BoundsFilters, + registerCancelCallback: (requestToken: symbol, callback: () => void) => void + ): MapExtent | null { + return null; } getFieldByName(fieldName: string): IField | null { diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts index 2dd6bcd858137..711b7d600d74d 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.d.ts @@ -6,11 +6,13 @@ /* eslint-disable @typescript-eslint/consistent-type-definitions */ import { FeatureCollection } from 'geojson'; +import { Filter, TimeRange } from 'src/plugins/data/public'; import { AbstractSource, ISource } from '../source'; import { IField } from '../../fields/field'; import { ESSearchSourceResponseMeta, MapExtent, + MapQuery, VectorSourceRequestMeta, VectorSourceSyncMeta, } from '../../../../common/descriptor_types'; @@ -24,9 +26,20 @@ export type GeoJsonWithMeta = { meta?: GeoJsonFetchMeta; }; +export type BoundsFilters = { + applyGlobalQuery: boolean; + filters: Filter[]; + query: MapQuery; + sourceQuery: MapQuery; + timeFilters: TimeRange; +}; + export interface IVectorSource extends ISource { filterAndFormatPropertiesToHtml(properties: unknown): Promise; - getBoundsForFilters(searchFilters: VectorSourceRequestMeta): MapExtent; + getBoundsForFilters( + boundsFilters: BoundsFilters, + registerCancelCallback: (requestToken: symbol, callback: () => void) => void + ): MapExtent | null; getGeoJsonWithMeta( layerName: 'string', searchFilters: unknown[], @@ -42,7 +55,10 @@ export interface IVectorSource extends ISource { export class AbstractVectorSource extends AbstractSource implements IVectorSource { filterAndFormatPropertiesToHtml(properties: unknown): Promise; - getBoundsForFilters(searchFilters: VectorSourceRequestMeta): MapExtent; + getBoundsForFilters( + boundsFilters: BoundsFilters, + registerCancelCallback: (requestToken: symbol, callback: () => void) => void + ): MapExtent | null; getGeoJsonWithMeta( layerName: 'string', searchFilters: unknown[], diff --git a/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx index 9261b8866d115..62eeef234f414 100644 --- a/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx @@ -18,17 +18,17 @@ export const wmsLayerWizardConfig: LayerWizard = { defaultMessage: 'Maps from OGC Standard WMS', }), icon: 'grid', - renderWizard: ({ previewLayer }: RenderWizardArguments) => { + renderWizard: ({ previewLayers }: RenderWizardArguments) => { const onSourceConfigChange = (sourceConfig: unknown) => { if (!sourceConfig) { - previewLayer(null); + previewLayers([]); return; } const layerDescriptor = TileLayer.createDescriptor({ sourceDescriptor: WMSSource.createDescriptor(sourceConfig), }); - previewLayer(layerDescriptor); + previewLayers([layerDescriptor]); }; return ; }, diff --git a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx index 574aaa262569f..b99b17c1d22d4 100644 --- a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx @@ -16,12 +16,12 @@ export const tmsLayerWizardConfig: LayerWizard = { defaultMessage: 'Tile map service configured in interface', }), icon: 'grid', - renderWizard: ({ previewLayer }: RenderWizardArguments) => { + renderWizard: ({ previewLayers }: RenderWizardArguments) => { const onSourceConfigChange = (sourceConfig: XYZTMSSourceConfig) => { const layerDescriptor = TileLayer.createDescriptor({ sourceDescriptor: XYZTMSSource.createDescriptor(sourceConfig), }); - previewLayer(layerDescriptor); + previewLayers([layerDescriptor]); }; return ; }, diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.js b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.js index 98d5d3feb60ea..15d0b3c4bf913 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.js @@ -7,7 +7,11 @@ import _ from 'lodash'; import { AbstractStyleProperty } from './style_property'; import { DEFAULT_SIGMA } from '../vector_style_defaults'; -import { STYLE_TYPE, SOURCE_META_ID_ORIGIN, FIELD_ORIGIN } from '../../../../../common/constants'; +import { + STYLE_TYPE, + SOURCE_META_DATA_REQUEST_ID, + FIELD_ORIGIN, +} from '../../../../../common/constants'; import React from 'react'; import { OrdinalFieldMetaPopover } from '../components/field_meta/ordinal_field_meta_popover'; import { CategoricalFieldMetaPopover } from '../components/field_meta/categorical_field_meta_popover'; @@ -30,7 +34,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty { _getStyleMetaDataRequestId(fieldName) { if (this.getFieldOrigin() === FIELD_ORIGIN.SOURCE) { - return SOURCE_META_ID_ORIGIN; + return SOURCE_META_DATA_REQUEST_ID; } const join = this._layer.getValidJoins().find((join) => { diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.js b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.js index f3ed18bd1302e..989ac268c0552 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.js @@ -13,7 +13,7 @@ import { GEO_JSON_TYPE, FIELD_ORIGIN, STYLE_TYPE, - SOURCE_FORMATTERS_ID_ORIGIN, + SOURCE_FORMATTERS_DATA_REQUEST_ID, LAYER_STYLE_TYPE, DEFAULT_ICON, VECTOR_STYLES, @@ -373,7 +373,7 @@ export class VectorStyle extends AbstractStyle { let dataRequestId; if (dynamicProp.getFieldOrigin() === FIELD_ORIGIN.SOURCE) { - dataRequestId = SOURCE_FORMATTERS_ID_ORIGIN; + dataRequestId = SOURCE_FORMATTERS_DATA_REQUEST_ID; } else { const join = this._layer.getValidJoins().find((join) => { return join.getRightJoinSource().hasMatchingMetricField(fieldName); diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/flyout_body.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/flyout_body.tsx index 75fb7a5bc4acc..b287064938ce5 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/flyout_body.tsx +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/flyout_body.tsx @@ -24,7 +24,7 @@ export const FlyoutBody = (props: Props) => { } const renderWizardArgs = { - previewLayer: props.previewLayer, + previewLayers: props.previewLayers, mapColors: props.mapColors, isIndexingTriggered: props.isIndexingTriggered, onRemove: props.onRemove, diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_footer/index.ts b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_footer/index.ts index 968429ce91226..470e83f2d8090 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_footer/index.ts +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_footer/index.ts @@ -7,22 +7,24 @@ import { AnyAction, Dispatch } from 'redux'; import { connect } from 'react-redux'; import { FlyoutFooter } from './view'; -import { getSelectedLayer } from '../../../selectors/map_selectors'; -import { clearTransientLayerStateAndCloseFlyout } from '../../../actions'; +import { hasPreviewLayers, isLoadingPreviewLayers } from '../../../selectors/map_selectors'; +import { removePreviewLayers, updateFlyout } from '../../../actions'; import { MapStoreState } from '../../../reducers/store'; +import { FLYOUT_STATE } from '../../../reducers/ui'; function mapStateToProps(state: MapStoreState) { - const selectedLayer = getSelectedLayer(state); - const hasLayerSelected = !!selectedLayer; return { - hasLayerSelected, - isLoading: hasLayerSelected && selectedLayer!.isLayerLoading(), + hasPreviewLayers: hasPreviewLayers(state), + isLoading: isLoadingPreviewLayers(state), }; } function mapDispatchToProps(dispatch: Dispatch) { return { - closeFlyout: () => dispatch(clearTransientLayerStateAndCloseFlyout()), + closeFlyout: () => { + dispatch(updateFlyout(FLYOUT_STATE.NONE)); + dispatch(removePreviewLayers()); + }, }; } diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_footer/view.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_footer/view.tsx index 6f4d25a9c6c3e..2e122324c50fb 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_footer/view.tsx +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_footer/view.tsx @@ -20,7 +20,7 @@ interface Props { disableNextButton: boolean; nextButtonText: string; closeFlyout: () => void; - hasLayerSelected: boolean; + hasPreviewLayers: boolean; isLoading: boolean; } @@ -30,14 +30,14 @@ export const FlyoutFooter = ({ disableNextButton, nextButtonText, closeFlyout, - hasLayerSelected, + hasPreviewLayers, isLoading, }: Props) => { const nextButton = showNextButton ? ( ) { return { - previewLayer: async (layerDescriptor: LayerDescriptor) => { - await dispatch(setSelectedLayer(null)); - await dispatch(removeTransientLayer()); - dispatch(addLayer(layerDescriptor)); - dispatch(setSelectedLayer(layerDescriptor.id)); - dispatch(setTransientLayer(layerDescriptor.id)); + addPreviewLayers: (layerDescriptors: LayerDescriptor[]) => { + dispatch(addPreviewLayers(layerDescriptors)); }, - removeTransientLayer: () => { - dispatch(setSelectedLayer(null)); - dispatch(removeTransientLayer()); - }, - selectLayerAndAdd: () => { - dispatch(setTransientLayer(null)); + promotePreviewLayers: () => { + dispatch(setFirstPreviewLayerToSelectedLayer()); dispatch(updateFlyout(FLYOUT_STATE.LAYER_PANEL)); + dispatch(promotePreviewLayers()); }, setIndexingTriggered: () => dispatch(updateIndexingStage(INDEXING_STAGE.TRIGGERED)), resetIndexing: () => dispatch(updateIndexingStage(null)), diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx index d382a4085fe19..c1b6dcc1e12a6 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx @@ -17,17 +17,15 @@ interface Props { isIndexingReady: boolean; isIndexingSuccess: boolean; isIndexingTriggered: boolean; - previewLayer: (layerDescriptor: LayerDescriptor) => void; - removeTransientLayer: () => void; + addPreviewLayers: (layerDescriptors: LayerDescriptor[]) => void; + promotePreviewLayers: () => void; resetIndexing: () => void; - selectLayerAndAdd: () => void; setIndexingTriggered: () => void; } interface State { importView: boolean; isIndexingSource: boolean; - layerDescriptor: LayerDescriptor | null; layerImportAddReady: boolean; layerWizard: LayerWizard | null; } @@ -37,7 +35,6 @@ export class AddLayerPanel extends Component { state = { layerWizard: null, - layerDescriptor: null, // TODO get this from redux store instead of storing locally isIndexingSource: false, importView: false, layerImportAddReady: false, @@ -57,21 +54,13 @@ export class AddLayerPanel extends Component { } } - _previewLayer = (layerDescriptor: LayerDescriptor | null, isIndexingSource?: boolean) => { + _previewLayers = (layerDescriptors: LayerDescriptor[], isIndexingSource?: boolean) => { if (!this._isMounted) { return; } - if (!layerDescriptor) { - this.setState({ - layerDescriptor: null, - isIndexingSource: false, - }); - this.props.removeTransientLayer(); - return; - } - this.setState({ layerDescriptor, isIndexingSource: !!isIndexingSource }); - this.props.previewLayer(layerDescriptor); + this.setState({ isIndexingSource: layerDescriptors.length ? !!isIndexingSource : false }); + this.props.addPreviewLayers(layerDescriptors); }; _clearLayerData = ({ keepSourceType = false }: { keepSourceType: boolean }) => { @@ -80,7 +69,6 @@ export class AddLayerPanel extends Component { } const newState: Partial = { - layerDescriptor: null, isIndexingSource: false, }; if (!keepSourceType) { @@ -90,7 +78,7 @@ export class AddLayerPanel extends Component { // @ts-ignore this.setState(newState); - this.props.removeTransientLayer(); + this.props.addPreviewLayers([]); }; _onWizardSelect = (layerWizard: LayerWizard) => { @@ -101,7 +89,7 @@ export class AddLayerPanel extends Component { if (this.state.isIndexingSource && !this.props.isIndexingTriggered) { this.props.setIndexingTriggered(); } else { - this.props.selectLayerAndAdd(); + this.props.promotePreviewLayers(); if (this.state.importView) { this.setState({ layerImportAddReady: false, @@ -126,7 +114,7 @@ export class AddLayerPanel extends Component { }); const isNextBtnEnabled = this.state.importView ? this.props.isIndexingReady || this.props.isIndexingSuccess - : !!this.state.layerDescriptor; + : true; return ( @@ -141,7 +129,7 @@ export class AddLayerPanel extends Component { onClear={() => this._clearLayerData({ keepSourceType: false })} onRemove={() => this._clearLayerData({ keepSourceType: true })} onWizardSelect={this._onWizardSelect} - previewLayer={this._previewLayer} + previewLayers={this._previewLayers} /> { - await dispatch(removeTransientLayer()); await dispatch(setSelectedLayer(layerId)); dispatch(updateFlyout(FLYOUT_STATE.LAYER_PANEL)); }, diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js index c0ce24fef9cd8..b17078ae37113 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js @@ -239,7 +239,8 @@ export class TOCEntry extends React.Component { 'mapTocEntry-isDragging': this.props.isDragging, 'mapTocEntry-isDraggingOver': this.props.isDraggingOver, 'mapTocEntry-isSelected': - this.props.selectedLayer && this.props.selectedLayer.getId() === this.props.layer.getId(), + this.props.layer.isPreviewLayer() || + (this.props.selectedLayer && this.props.selectedLayer.getId() === this.props.layer.getId()), }); return ( diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.test.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.test.js index 90d756484c47f..543be9395d0bc 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.test.js +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.test.js @@ -21,6 +21,9 @@ const mockLayer = { getDisplayName: () => { return 'layer 1'; }, + isPreviewLayer: () => { + return false; + }, isVisible: () => { return true; }, diff --git a/x-pack/plugins/maps/public/reducers/map.d.ts b/x-pack/plugins/maps/public/reducers/map.d.ts index 8fc655b2c837a..33794fcf8657d 100644 --- a/x-pack/plugins/maps/public/reducers/map.d.ts +++ b/x-pack/plugins/maps/public/reducers/map.d.ts @@ -66,7 +66,6 @@ export type MapState = { openTooltips: TooltipState[]; mapState: MapContext; selectedLayerId: string | null; - __transientLayerId: string | null; layerList: LayerDescriptor[]; waitingForMapReadyLayerList: LayerDescriptor[]; settings: MapSettings; diff --git a/x-pack/plugins/maps/public/reducers/map.js b/x-pack/plugins/maps/public/reducers/map.js index c5f3968b749f1..317c11eb7680c 100644 --- a/x-pack/plugins/maps/public/reducers/map.js +++ b/x-pack/plugins/maps/public/reducers/map.js @@ -6,7 +6,6 @@ import { SET_SELECTED_LAYER, - SET_TRANSIENT_LAYER, UPDATE_LAYER_ORDER, LAYER_DATA_LOAD_STARTED, LAYER_DATA_LOAD_ENDED, @@ -54,7 +53,7 @@ import { import { getDefaultMapSettings } from './default_map_settings'; import { copyPersistentState, TRACKED_LAYER_DESCRIPTOR } from './util'; -import { SOURCE_DATA_ID_ORIGIN } from '../../common/constants'; +import { SOURCE_DATA_REQUEST_ID } from '../../common/constants'; const getLayerIndex = (list, layerId) => list.findIndex(({ id }) => layerId === id); @@ -126,7 +125,6 @@ export const DEFAULT_MAP_STATE = { hideViewControl: false, }, selectedLayerId: null, - __transientLayerId: null, layerList: [], waitingForMapReadyLayerList: [], settings: getDefaultMapSettings(), @@ -285,9 +283,6 @@ export function map(state = DEFAULT_MAP_STATE, action) { case SET_SELECTED_LAYER: const selectedMatch = state.layerList.find((layer) => layer.id === action.selectedLayerId); return { ...state, selectedLayerId: selectedMatch ? action.selectedLayerId : null }; - case SET_TRANSIENT_LAYER: - const transientMatch = state.layerList.find((layer) => layer.id === action.transientLayerId); - return { ...state, __transientLayerId: transientMatch ? action.transientLayerId : null }; case UPDATE_LAYER_ORDER: return { ...state, @@ -448,7 +443,7 @@ function updateSourceDataRequest(state, action) { return state; } const dataRequest = layerDescriptor.__dataRequests.find((dataRequest) => { - return dataRequest.dataId === SOURCE_DATA_ID_ORIGIN; + return dataRequest.dataId === SOURCE_DATA_REQUEST_ID; }); if (!dataRequest) { return state; diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.ts b/x-pack/plugins/maps/public/selectors/map_selectors.ts index 0789222b0bf38..467f1074e88e7 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.ts @@ -26,7 +26,7 @@ import { getSourceByType } from '../classes/sources/source_registry'; import { GeojsonFileSource } from '../classes/sources/client_file_source'; import { LAYER_TYPE, - SOURCE_DATA_ID_ORIGIN, + SOURCE_DATA_REQUEST_ID, STYLE_TYPE, VECTOR_STYLES, SPATIAL_FILTERS_LAYER_ID, @@ -137,9 +137,6 @@ export const getSelectedLayerId = ({ map }: MapStoreState): string | null => { return !map.selectedLayerId || !map.layerList ? null : map.selectedLayerId; }; -export const getTransientLayerId = ({ map }: MapStoreState): string | null => - map.__transientLayerId; - export const getLayerListRaw = ({ map }: MapStoreState): LayerDescriptor[] => map.layerList ? map.layerList : []; @@ -266,7 +263,7 @@ export const getSpatialFiltersLayer = createSelector( alpha: settings.spatialFiltersAlpa, __dataRequests: [ { - dataId: SOURCE_DATA_ID_ORIGIN, + dataId: SOURCE_DATA_REQUEST_ID, data: featureCollection, }, ], @@ -331,15 +328,28 @@ export const getSelectedLayer = createSelector( } ); -export const getMapColors = createSelector( - getTransientLayerId, - getLayerListRaw, - (transientLayerId, layerList) => - layerList.reduce((accu: string[], layer: LayerDescriptor) => { - if (layer.id === transientLayerId) { - return accu; - } - const color: string | undefined = _.get(layer, 'style.properties.fillColor.options.color'); +export const hasPreviewLayers = createSelector(getLayerList, (layerList) => { + return layerList.some((layer) => { + return layer.isPreviewLayer(); + }); +}); + +export const isLoadingPreviewLayers = createSelector(getLayerList, (layerList) => { + return layerList.some((layer) => { + return layer.isPreviewLayer() && layer.isLayerLoading(); + }); +}); + +export const getMapColors = createSelector(getLayerListRaw, (layerList) => + layerList + .filter((layerDescriptor) => { + return !layerDescriptor.__isPreviewLayer; + }) + .reduce((accu: string[], layerDescriptor: LayerDescriptor) => { + const color: string | undefined = _.get( + layerDescriptor, + 'style.properties.fillColor.options.color' + ); if (color) accu.push(color); return accu; }, []) @@ -373,24 +383,20 @@ export const getQueryableUniqueIndexPatternIds = createSelector(getLayerList, (l return _.uniq(indexPatternIds); }); -export const hasDirtyState = createSelector( - getLayerListRaw, - getTransientLayerId, - (layerListRaw, transientLayerId) => { - if (transientLayerId) { +export const hasDirtyState = createSelector(getLayerListRaw, (layerListRaw) => { + return layerListRaw.some((layerDescriptor) => { + if (layerDescriptor.__isPreviewLayer) { return true; } - return layerListRaw.some((layerDescriptor) => { - const trackedState = layerDescriptor[TRACKED_LAYER_DESCRIPTOR]; - if (!trackedState) { - return false; - } - const currentState = copyPersistentState(layerDescriptor); - return !_.isEqual(currentState, trackedState); - }); - } -); + const trackedState = layerDescriptor[TRACKED_LAYER_DESCRIPTOR]; + if (!trackedState) { + return false; + } + const currentState = copyPersistentState(layerDescriptor); + return !_.isEqual(currentState, trackedState); + }); +}); export const areLayersLoaded = createSelector( getLayerList, diff --git a/x-pack/plugins/security/common/licensing/license_features.ts b/x-pack/plugins/security/common/licensing/license_features.ts index 571d2630b2b17..8576e4bbc3555 100644 --- a/x-pack/plugins/security/common/licensing/license_features.ts +++ b/x-pack/plugins/security/common/licensing/license_features.ts @@ -38,6 +38,11 @@ export interface SecurityLicenseFeatures { */ readonly allowAccessAgreement: boolean; + /** + * Indicates whether we allow logging of audit events. + */ + readonly allowAuditLogging: boolean; + /** * Indicates whether we allow users to define document level security in roles. */ diff --git a/x-pack/plugins/security/common/licensing/license_service.test.ts b/x-pack/plugins/security/common/licensing/license_service.test.ts index 89901d663d82a..77e6460b7669a 100644 --- a/x-pack/plugins/security/common/licensing/license_service.test.ts +++ b/x-pack/plugins/security/common/licensing/license_service.test.ts @@ -24,6 +24,7 @@ describe('license features', function () { layout: 'error-es-unavailable', allowRbac: false, allowSubFeaturePrivileges: false, + allowAuditLogging: false, }); }); @@ -44,6 +45,7 @@ describe('license features', function () { layout: 'error-xpack-unavailable', allowRbac: false, allowSubFeaturePrivileges: false, + allowAuditLogging: false, }); }); @@ -63,6 +65,7 @@ describe('license features', function () { Array [ Object { "allowAccessAgreement": false, + "allowAuditLogging": false, "allowLogin": false, "allowRbac": false, "allowRoleDocumentLevelSecurity": false, @@ -82,6 +85,7 @@ describe('license features', function () { Array [ Object { "allowAccessAgreement": true, + "allowAuditLogging": true, "allowLogin": true, "allowRbac": true, "allowRoleDocumentLevelSecurity": true, @@ -118,6 +122,7 @@ describe('license features', function () { allowRoleFieldLevelSecurity: false, allowRbac: true, allowSubFeaturePrivileges: false, + allowAuditLogging: false, }); expect(getFeatureSpy).toHaveBeenCalledTimes(1); expect(getFeatureSpy).toHaveBeenCalledWith('security'); @@ -141,6 +146,7 @@ describe('license features', function () { allowRoleFieldLevelSecurity: false, allowRbac: false, allowSubFeaturePrivileges: false, + allowAuditLogging: false, }); }); @@ -163,6 +169,7 @@ describe('license features', function () { allowRoleFieldLevelSecurity: false, allowRbac: true, allowSubFeaturePrivileges: true, + allowAuditLogging: true, }); }); @@ -185,6 +192,30 @@ describe('license features', function () { allowRoleFieldLevelSecurity: true, allowRbac: true, allowSubFeaturePrivileges: true, + allowAuditLogging: true, + }); + }); + + it('should allow all basic features + audit logging for standard license', () => { + const mockRawLicense = licensingMock.createLicense({ + license: { mode: 'standard', type: 'standard' }, + features: { security: { isEnabled: true, isAvailable: true } }, + }); + + const serviceSetup = new SecurityLicenseService().setup({ + license$: of(mockRawLicense), + }); + expect(serviceSetup.license.getFeatures()).toEqual({ + showLogin: true, + allowLogin: true, + showLinks: true, + showRoleMappingsManagement: false, + allowAccessAgreement: false, + allowRoleDocumentLevelSecurity: false, + allowRoleFieldLevelSecurity: false, + allowRbac: true, + allowSubFeaturePrivileges: false, + allowAuditLogging: true, }); }); }); diff --git a/x-pack/plugins/security/common/licensing/license_service.ts b/x-pack/plugins/security/common/licensing/license_service.ts index 53cae857e5d66..75c7670f28a67 100644 --- a/x-pack/plugins/security/common/licensing/license_service.ts +++ b/x-pack/plugins/security/common/licensing/license_service.ts @@ -72,6 +72,7 @@ export class SecurityLicenseService { showLinks: false, showRoleMappingsManagement: false, allowAccessAgreement: false, + allowAuditLogging: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, @@ -90,6 +91,7 @@ export class SecurityLicenseService { showLinks: false, showRoleMappingsManagement: false, allowAccessAgreement: false, + allowAuditLogging: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, @@ -97,6 +99,7 @@ export class SecurityLicenseService { }; } + const isLicenseStandardOrBetter = rawLicense.hasAtLeast('standard'); const isLicenseGoldOrBetter = rawLicense.hasAtLeast('gold'); const isLicensePlatinumOrBetter = rawLicense.hasAtLeast('platinum'); return { @@ -105,6 +108,7 @@ export class SecurityLicenseService { showLinks: true, showRoleMappingsManagement: isLicenseGoldOrBetter, allowAccessAgreement: isLicenseGoldOrBetter, + allowAuditLogging: isLicenseStandardOrBetter, allowSubFeaturePrivileges: isLicenseGoldOrBetter, // Only platinum and trial licenses are compliant with field- and document-level security. allowRoleDocumentLevelSecurity: isLicensePlatinumOrBetter, diff --git a/x-pack/plugins/security/server/audit/audit_service.test.ts b/x-pack/plugins/security/server/audit/audit_service.test.ts new file mode 100644 index 0000000000000..94a2ada8df1da --- /dev/null +++ b/x-pack/plugins/security/server/audit/audit_service.test.ts @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { AuditService } from './audit_service'; +import { loggingServiceMock } from 'src/core/server/mocks'; +import { licenseMock } from '../../common/licensing/index.mock'; +import { ConfigSchema, ConfigType } from '../config'; +import { SecurityLicenseFeatures } from '../../common/licensing'; +import { BehaviorSubject } from 'rxjs'; + +const createConfig = (settings: Partial) => { + return ConfigSchema.validate(settings); +}; + +const config = createConfig({ + enabled: true, +}); + +describe('#setup', () => { + it('returns the expected contract', () => { + const logger = loggingServiceMock.createLogger(); + const auditService = new AuditService(logger); + const license = licenseMock.create(); + expect(auditService.setup({ license, config })).toMatchInlineSnapshot(` + Object { + "getLogger": [Function], + } + `); + }); +}); + +test(`calls the underlying logger with the provided message and requisite tags`, () => { + const pluginId = 'foo'; + + const logger = loggingServiceMock.createLogger(); + const license = licenseMock.create(); + license.features$ = new BehaviorSubject({ + allowAuditLogging: true, + } as SecurityLicenseFeatures).asObservable(); + + const auditService = new AuditService(logger).setup({ license, config }); + + const auditLogger = auditService.getLogger(pluginId); + + const eventType = 'bar'; + const message = 'this is my audit message'; + auditLogger.log(eventType, message); + + expect(logger.info).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith(message, { + eventType, + tags: [pluginId, eventType], + }); +}); + +test(`calls the underlying logger with the provided metadata`, () => { + const pluginId = 'foo'; + + const logger = loggingServiceMock.createLogger(); + const license = licenseMock.create(); + license.features$ = new BehaviorSubject({ + allowAuditLogging: true, + } as SecurityLicenseFeatures).asObservable(); + + const auditService = new AuditService(logger).setup({ license, config }); + + const auditLogger = auditService.getLogger(pluginId); + + const eventType = 'bar'; + const message = 'this is my audit message'; + const metadata = Object.freeze({ + property1: 'value1', + property2: false, + property3: 123, + }); + auditLogger.log(eventType, message, metadata); + + expect(logger.info).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith(message, { + eventType, + tags: [pluginId, eventType], + property1: 'value1', + property2: false, + property3: 123, + }); +}); + +test(`does not call the underlying logger if license does not support audit logging`, () => { + const pluginId = 'foo'; + + const logger = loggingServiceMock.createLogger(); + const license = licenseMock.create(); + license.features$ = new BehaviorSubject({ + allowAuditLogging: false, + } as SecurityLicenseFeatures).asObservable(); + + const auditService = new AuditService(logger).setup({ license, config }); + + const auditLogger = auditService.getLogger(pluginId); + + const eventType = 'bar'; + const message = 'this is my audit message'; + auditLogger.log(eventType, message); + + expect(logger.info).not.toHaveBeenCalled(); +}); + +test(`does not call the underlying logger if security audit logging is not enabled`, () => { + const pluginId = 'foo'; + + const logger = loggingServiceMock.createLogger(); + const license = licenseMock.create(); + license.features$ = new BehaviorSubject({ + allowAuditLogging: true, + } as SecurityLicenseFeatures).asObservable(); + + const auditService = new AuditService(logger).setup({ + license, + config: createConfig({ + enabled: false, + }), + }); + + const auditLogger = auditService.getLogger(pluginId); + + const eventType = 'bar'; + const message = 'this is my audit message'; + auditLogger.log(eventType, message); + + expect(logger.info).not.toHaveBeenCalled(); +}); + +test(`calls the underlying logger after license upgrade`, () => { + const pluginId = 'foo'; + + const logger = loggingServiceMock.createLogger(); + const license = licenseMock.create(); + + const features$ = new BehaviorSubject({ + allowAuditLogging: false, + } as SecurityLicenseFeatures); + + license.features$ = features$.asObservable(); + + const auditService = new AuditService(logger).setup({ license, config }); + + const auditLogger = auditService.getLogger(pluginId); + + const eventType = 'bar'; + const message = 'this is my audit message'; + auditLogger.log(eventType, message); + + expect(logger.info).not.toHaveBeenCalled(); + + // perform license upgrade + features$.next({ + allowAuditLogging: true, + } as SecurityLicenseFeatures); + + auditLogger.log(eventType, message); + + expect(logger.info).toHaveBeenCalledTimes(1); +}); diff --git a/x-pack/plugins/security/server/audit/audit_service.ts b/x-pack/plugins/security/server/audit/audit_service.ts new file mode 100644 index 0000000000000..93e69fd2601e9 --- /dev/null +++ b/x-pack/plugins/security/server/audit/audit_service.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Subscription } from 'rxjs'; +import { Logger } from '../../../../../src/core/server'; +import { SecurityLicense } from '../../common/licensing'; +import { ConfigType } from '../config'; + +export interface AuditLogger { + log: (eventType: string, message: string, data?: Record) => void; +} + +export interface AuditServiceSetup { + getLogger: (id?: string) => AuditLogger; +} + +interface AuditServiceSetupParams { + license: SecurityLicense; + config: ConfigType['audit']; +} + +export class AuditService { + private licenseFeaturesSubscription?: Subscription; + private auditLoggingEnabled = false; + + constructor(private readonly logger: Logger) {} + + setup({ license, config }: AuditServiceSetupParams): AuditServiceSetup { + if (config.enabled) { + this.licenseFeaturesSubscription = license.features$.subscribe(({ allowAuditLogging }) => { + this.auditLoggingEnabled = allowAuditLogging; + }); + } + + return { + getLogger: (id?: string): AuditLogger => { + return { + log: (eventType: string, message: string, data?: Record) => { + if (!this.auditLoggingEnabled) { + return; + } + + this.logger.info(message, { + tags: id ? [id, eventType] : [eventType], + eventType, + ...data, + }); + }, + }; + }, + }; + } + + stop() { + if (this.licenseFeaturesSubscription) { + this.licenseFeaturesSubscription.unsubscribe(); + this.licenseFeaturesSubscription = undefined; + } + } +} diff --git a/x-pack/plugins/security/server/audit/index.mock.ts b/x-pack/plugins/security/server/audit/index.mock.ts index 888aa3361faf0..07341cc06e889 100644 --- a/x-pack/plugins/security/server/audit/index.mock.ts +++ b/x-pack/plugins/security/server/audit/index.mock.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SecurityAuditLogger } from './audit_logger'; +import { SecurityAuditLogger } from './security_audit_logger'; +import { AuditService } from './audit_service'; export const securityAuditLoggerMock = { create() { @@ -15,3 +16,11 @@ export const securityAuditLoggerMock = { } as unknown) as jest.Mocked; }, }; + +export const auditServiceMock = { + create() { + return { + getLogger: jest.fn(), + } as jest.Mocked>; + }, +}; diff --git a/x-pack/plugins/security/server/audit/index.ts b/x-pack/plugins/security/server/audit/index.ts index 3ab253151b805..3db160c703e34 100644 --- a/x-pack/plugins/security/server/audit/index.ts +++ b/x-pack/plugins/security/server/audit/index.ts @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SecurityAuditLogger } from './audit_logger'; +export { AuditService, AuditServiceSetup, AuditLogger } from './audit_service'; +export { SecurityAuditLogger } from './security_audit_logger'; diff --git a/x-pack/plugins/security/server/audit/audit_logger.test.ts b/x-pack/plugins/security/server/audit/security_audit_logger.test.ts similarity index 91% rename from x-pack/plugins/security/server/audit/audit_logger.test.ts rename to x-pack/plugins/security/server/audit/security_audit_logger.test.ts index 4dfd69a2ccb1f..c6883f681cf41 100644 --- a/x-pack/plugins/security/server/audit/audit_logger.test.ts +++ b/x-pack/plugins/security/server/audit/security_audit_logger.test.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { SecurityAuditLogger } from './audit_logger'; +import { SecurityAuditLogger } from './security_audit_logger'; const createMockAuditLogger = () => { return { @@ -14,7 +14,7 @@ const createMockAuditLogger = () => { describe(`#savedObjectsAuthorizationFailure`, () => { test('logs via auditLogger', () => { const auditLogger = createMockAuditLogger(); - const securityAuditLogger = new SecurityAuditLogger(() => auditLogger); + const securityAuditLogger = new SecurityAuditLogger(auditLogger); const username = 'foo-user'; const action = 'foo-action'; const types = ['foo-type-1', 'foo-type-2']; @@ -64,7 +64,7 @@ describe(`#savedObjectsAuthorizationFailure`, () => { describe(`#savedObjectsAuthorizationSuccess`, () => { test('logs via auditLogger', () => { const auditLogger = createMockAuditLogger(); - const securityAuditLogger = new SecurityAuditLogger(() => auditLogger); + const securityAuditLogger = new SecurityAuditLogger(auditLogger); const username = 'foo-user'; const action = 'foo-action'; const types = ['foo-type-1', 'foo-type-2']; @@ -96,7 +96,7 @@ describe(`#savedObjectsAuthorizationSuccess`, () => { describe(`#accessAgreementAcknowledged`, () => { test('logs via auditLogger', () => { const auditLogger = createMockAuditLogger(); - const securityAuditLogger = new SecurityAuditLogger(() => auditLogger); + const securityAuditLogger = new SecurityAuditLogger(auditLogger); const username = 'foo-user'; const provider = { type: 'saml', name: 'saml1' }; diff --git a/x-pack/plugins/security/server/audit/audit_logger.ts b/x-pack/plugins/security/server/audit/security_audit_logger.ts similarity index 89% rename from x-pack/plugins/security/server/audit/audit_logger.ts rename to x-pack/plugins/security/server/audit/security_audit_logger.ts index d7243ecbe13f8..87f7201f85665 100644 --- a/x-pack/plugins/security/server/audit/audit_logger.ts +++ b/x-pack/plugins/security/server/audit/security_audit_logger.ts @@ -5,10 +5,10 @@ */ import { AuthenticationProvider } from '../../common/types'; -import { LegacyAPI } from '../plugin'; +import { AuditLogger } from './audit_service'; export class SecurityAuditLogger { - constructor(private readonly getAuditLogger: () => LegacyAPI['auditLogger']) {} + constructor(private readonly logger: AuditLogger) {} savedObjectsAuthorizationFailure( username: string, @@ -23,7 +23,7 @@ export class SecurityAuditLogger { const missingString = missing .map(({ spaceId, privilege }) => `${spaceId ? `(${spaceId})` : ''}${privilege}`) .join(','); - this.getAuditLogger().log( + this.logger.log( 'saved_objects_authorization_failure', `${username} unauthorized to [${action}] [${typesString}]${spacesString}: missing [${missingString}]`, { @@ -46,7 +46,7 @@ export class SecurityAuditLogger { ) { const typesString = types.join(','); const spacesString = spaceIds.length ? ` in [${spaceIds.join(',')}]` : ''; - this.getAuditLogger().log( + this.logger.log( 'saved_objects_authorization_success', `${username} authorized to [${action}] [${typesString}]${spacesString}`, { @@ -60,7 +60,7 @@ export class SecurityAuditLogger { } accessAgreementAcknowledged(username: string, provider: AuthenticationProvider) { - this.getAuditLogger().log( + this.logger.log( 'access_agreement_acknowledged', `${username} acknowledged access agreement (${provider.type}/${provider.name}).`, { username, provider } diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index 0de86c72002c9..a0a06b537213d 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -27,6 +27,7 @@ export { SAMLLogin, OIDCLogin, } from './authentication'; +export { AuditLogger } from './audit'; export { SecurityPluginSetup }; export { AuthenticatedUser } from '../common/model'; diff --git a/x-pack/plugins/security/server/mocks.ts b/x-pack/plugins/security/server/mocks.ts index a6407366bbd3b..72a946d6c5155 100644 --- a/x-pack/plugins/security/server/mocks.ts +++ b/x-pack/plugins/security/server/mocks.ts @@ -9,10 +9,12 @@ import { SecurityPluginSetup } from './plugin'; import { authenticationMock } from './authentication/index.mock'; import { authorizationMock } from './authorization/index.mock'; import { licenseMock } from '../common/licensing/index.mock'; +import { auditServiceMock } from './audit/index.mock'; function createSetupMock() { const mockAuthz = authorizationMock.create(); return { + audit: auditServiceMock.create(), authc: authenticationMock.create(), authz: { actions: mockAuthz.actions, diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index d58c999ddccdf..fc49bdd9bc0c3 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -25,6 +25,7 @@ describe('Security Plugin', () => { idleTimeout: 1500, lifespan: null, }, + audit: { enabled: false }, authc: { selector: { enabled: false }, providers: ['saml', 'token'], @@ -50,9 +51,11 @@ describe('Security Plugin', () => { await expect(plugin.setup(mockCoreSetup, mockDependencies)).resolves.toMatchInlineSnapshot(` Object { "__legacyCompat": Object { - "registerLegacyAPI": [Function], "registerPrivilegesWithCluster": [Function], }, + "audit": Object { + "getLogger": [Function], + }, "authc": Object { "areAPIKeysEnabled": [Function], "createAPIKey": [Function], diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 89cffde92d564..cdfc6f0ae542f 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -24,7 +24,7 @@ import { ConfigSchema, createConfig } from './config'; import { defineRoutes } from './routes'; import { SecurityLicenseService, SecurityLicense } from '../common/licensing'; import { setupSavedObjects } from './saved_objects'; -import { SecurityAuditLogger } from './audit'; +import { AuditService, SecurityAuditLogger, AuditServiceSetup } from './audit'; import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; export type SpacesService = Pick< @@ -34,16 +34,6 @@ export type SpacesService = Pick< export type FeaturesService = Pick; -/** - * Describes a set of APIs that is available in the legacy platform only and required by this plugin - * to function properly. - */ -export interface LegacyAPI { - auditLogger: { - log: (eventType: string, message: string, data?: Record) => void; - }; -} - /** * Describes public Security plugin contract returned at the `setup` stage. */ @@ -60,6 +50,7 @@ export interface SecurityPluginSetup { >; authz: Pick; license: SecurityLicense; + audit: Pick; /** * If Spaces plugin is available it's supposed to register its SpacesService with Security plugin @@ -72,7 +63,6 @@ export interface SecurityPluginSetup { registerSpacesService: (service: SpacesService) => void; __legacyCompat: { - registerLegacyAPI: (legacyAPI: LegacyAPI) => void; registerPrivilegesWithCluster: () => void; }; } @@ -90,14 +80,7 @@ export class Plugin { private clusterClient?: ICustomClusterClient; private spacesService?: SpacesService | symbol = Symbol('not accessed'); private securityLicenseService?: SecurityLicenseService; - - private legacyAPI?: LegacyAPI; - private readonly getLegacyAPI = () => { - if (!this.legacyAPI) { - throw new Error('Legacy API is not registered!'); - } - return this.legacyAPI; - }; + private readonly auditService = new AuditService(this.initializerContext.logger.get('audit')); private readonly getSpacesService = () => { // Changing property value from Symbol to undefined denotes the fact that property was accessed. @@ -135,7 +118,9 @@ export class Plugin { license$: licensing.license$, }); - const auditLogger = new SecurityAuditLogger(() => this.getLegacyAPI().auditLogger); + const audit = this.auditService.setup({ license, config: config.audit }); + const auditLogger = new SecurityAuditLogger(audit.getLogger()); + const authc = await setupAuthentication({ auditLogger, http: core.http, @@ -178,6 +163,10 @@ export class Plugin { }); return deepFreeze({ + audit: { + getLogger: audit.getLogger, + }, + authc: { isAuthenticated: authc.isAuthenticated, getCurrentUser: authc.getCurrentUser, @@ -205,8 +194,6 @@ export class Plugin { }, __legacyCompat: { - registerLegacyAPI: (legacyAPI: LegacyAPI) => (this.legacyAPI = legacyAPI), - registerPrivilegesWithCluster: async () => await authz.registerPrivilegesWithCluster(), }, }); @@ -228,6 +215,8 @@ export class Plugin { this.securityLicenseService.stop(); this.securityLicenseService = undefined; } + + this.auditService.stop(); } private wasSpacesServiceAccessed() { diff --git a/x-pack/plugins/security/server/routes/views/login.test.ts b/x-pack/plugins/security/server/routes/views/login.test.ts index 5c41a48bf5ee4..fee3adbb19f97 100644 --- a/x-pack/plugins/security/server/routes/views/login.test.ts +++ b/x-pack/plugins/security/server/routes/views/login.test.ts @@ -172,6 +172,7 @@ describe('Login view routes', () => { showLinks: false, showRoleMappingsManagement: true, allowSubFeaturePrivileges: true, + allowAuditLogging: true, showLogin: true, }); diff --git a/x-pack/plugins/siem/kibana.json b/x-pack/plugins/siem/kibana.json index 1106781fd45e4..6b43b41df8eee 100644 --- a/x-pack/plugins/siem/kibana.json +++ b/x-pack/plugins/siem/kibana.json @@ -24,7 +24,8 @@ "newsfeed", "security", "spaces", - "usageCollection" + "usageCollection", + "lists" ], "server": true, "ui": true diff --git a/x-pack/plugins/siem/public/app/home/home_navigations.tsx b/x-pack/plugins/siem/public/app/home/home_navigations.tsx index 2eed64a2b26e5..bb9e99326182f 100644 --- a/x-pack/plugins/siem/public/app/home/home_navigations.tsx +++ b/x-pack/plugins/siem/public/app/home/home_navigations.tsx @@ -14,6 +14,7 @@ import { } from '../../common/components/link_to'; import * as i18n from './translations'; import { SiemPageName, SiemNavTab } from '../types'; +import { getManagementUrl } from '../../management'; export const navTabs: SiemNavTab = { [SiemPageName.overview]: { @@ -58,4 +59,11 @@ export const navTabs: SiemNavTab = { disabled: false, urlKey: 'case', }, + [SiemPageName.management]: { + id: SiemPageName.management, + name: i18n.MANAGEMENT, + href: getManagementUrl({ name: 'default' }), + disabled: false, + urlKey: SiemPageName.management, + }, }; diff --git a/x-pack/plugins/siem/public/app/home/translations.ts b/x-pack/plugins/siem/public/app/home/translations.ts index f2bcaa07b1a25..0cce45b4cef27 100644 --- a/x-pack/plugins/siem/public/app/home/translations.ts +++ b/x-pack/plugins/siem/public/app/home/translations.ts @@ -29,3 +29,7 @@ export const TIMELINES = i18n.translate('xpack.siem.navigation.timelines', { export const CASE = i18n.translate('xpack.siem.navigation.case', { defaultMessage: 'Cases', }); + +export const MANAGEMENT = i18n.translate('xpack.siem.navigation.management', { + defaultMessage: 'Management', +}); diff --git a/x-pack/plugins/siem/public/app/types.ts b/x-pack/plugins/siem/public/app/types.ts index 1fcbc5ba25f8f..444e0066c3c7b 100644 --- a/x-pack/plugins/siem/public/app/types.ts +++ b/x-pack/plugins/siem/public/app/types.ts @@ -5,7 +5,6 @@ */ import { Reducer, AnyAction, Middleware, Dispatch } from 'redux'; - import { NavTab } from '../common/components/navigation/types'; import { HostsState } from '../hosts/store'; import { NetworkState } from '../network/store'; @@ -15,7 +14,7 @@ import { Immutable } from '../../common/endpoint/types'; import { AlertListState } from '../../common/endpoint_alerts/types'; import { AppAction } from '../common/store/actions'; import { HostState } from '../endpoint_hosts/types'; -import { PolicyDetailsState, PolicyListState } from '../endpoint_policy/types'; +import { ManagementState } from '../management/store/types'; export enum SiemPageName { overview = 'overview', @@ -24,6 +23,7 @@ export enum SiemPageName { detections = 'detections', timelines = 'timelines', case = 'case', + management = 'management', } export type SiemNavTabKey = @@ -32,14 +32,15 @@ export type SiemNavTabKey = | SiemPageName.network | SiemPageName.detections | SiemPageName.timelines - | SiemPageName.case; + | SiemPageName.case + | SiemPageName.management; export type SiemNavTab = Record; export interface SecuritySubPluginStore { initialState: Record; reducer: Record>; - middleware?: Middleware<{}, State, Dispatch>>; + middleware?: Array>>>; } export interface SecuritySubPlugin { @@ -52,8 +53,7 @@ type SecuritySubPluginKeyStore = | 'timeline' | 'hostList' | 'alertList' - | 'policyDetails' - | 'policyList'; + | 'management'; export interface SecuritySubPluginWithStore extends SecuritySubPlugin { store: SecuritySubPluginStore; @@ -67,8 +67,7 @@ export interface SecuritySubPlugins extends SecuritySubPlugin { timeline: TimelineState; alertList: Immutable; hostList: Immutable; - policyDetails: Immutable; - policyList: Immutable; + management: ManagementState; }; reducer: { hosts: Reducer; @@ -76,8 +75,7 @@ export interface SecuritySubPlugins extends SecuritySubPlugin { timeline: Reducer; alertList: ImmutableReducer; hostList: ImmutableReducer; - policyDetails: ImmutableReducer; - policyList: ImmutableReducer; + management: ImmutableReducer; }; middlewares: Array>>>; }; diff --git a/x-pack/plugins/siem/public/cases/components/callout/index.test.tsx b/x-pack/plugins/siem/public/cases/components/callout/index.test.tsx index c830a6f5e10d5..ee3faeb2ceeb5 100644 --- a/x-pack/plugins/siem/public/cases/components/callout/index.test.tsx +++ b/x-pack/plugins/siem/public/cases/components/callout/index.test.tsx @@ -60,6 +60,43 @@ describe('CaseCallOut ', () => { ).toBeTruthy(); }); + it('it applies the correct color to button', () => { + const props = { + ...defaultProps, + messages: [ + { + ...defaultProps, + description:

    {'one'}

    , + errorType: 'danger' as 'primary' | 'success' | 'warning' | 'danger', + }, + { + ...defaultProps, + description:

    {'two'}

    , + errorType: 'success' as 'primary' | 'success' | 'warning' | 'danger', + }, + { + ...defaultProps, + description:

    {'three'}

    , + errorType: 'primary' as 'primary' | 'success' | 'warning' | 'danger', + }, + ], + }; + + const wrapper = mount(); + + expect(wrapper.find(`[data-test-subj="callout-dismiss-danger"]`).first().prop('color')).toBe( + 'danger' + ); + + expect(wrapper.find(`[data-test-subj="callout-dismiss-success"]`).first().prop('color')).toBe( + 'secondary' + ); + + expect(wrapper.find(`[data-test-subj="callout-dismiss-primary"]`).first().prop('color')).toBe( + 'primary' + ); + }); + it('Dismisses callout', () => { const props = { ...defaultProps, diff --git a/x-pack/plugins/siem/public/cases/components/callout/index.tsx b/x-pack/plugins/siem/public/cases/components/callout/index.tsx index 470b03637dc00..171c0508b9d92 100644 --- a/x-pack/plugins/siem/public/cases/components/callout/index.tsx +++ b/x-pack/plugins/siem/public/cases/components/callout/index.tsx @@ -66,7 +66,7 @@ const CaseCallOutComponent = ({ title, message, messages }: CaseCallOutProps) => )} {i18n.DISMISS_CALLOUT} diff --git a/x-pack/plugins/siem/public/cases/components/use_push_to_service/index.test.tsx b/x-pack/plugins/siem/public/cases/components/use_push_to_service/index.test.tsx index e3e627e3a136e..4391db1a0a0a1 100644 --- a/x-pack/plugins/siem/public/cases/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/siem/public/cases/components/use_push_to_service/index.test.tsx @@ -123,7 +123,27 @@ describe('usePushToService', () => { }); }); - it('Displays message when user does not have a connector configured', async () => { + it('Displays message when user does not have any connector configured', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...defaultArgs, + connectors: [], + caseConnectorId: 'none', + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + const errorsMsg = result.current.pushCallouts?.props.messages; + expect(errorsMsg).toHaveLength(1); + expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CONFIG_TITLE); + }); + }); + + it('Displays message when user does have a connector but is configured to none', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook( () => @@ -162,6 +182,27 @@ describe('usePushToService', () => { }); }); + it('Displays message when connector is deleted with empty connectors', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...defaultArgs, + connectors: [], + caseConnectorId: 'not-exist', + isValidConnector: false, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + const errorsMsg = result.current.pushCallouts?.props.messages; + expect(errorsMsg).toHaveLength(1); + expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE); + }); + }); + it('Displays message when case is closed', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook( diff --git a/x-pack/plugins/siem/public/cases/components/use_push_to_service/index.tsx b/x-pack/plugins/siem/public/cases/components/use_push_to_service/index.tsx index 5d238b623eb4a..63b808eed3c92 100644 --- a/x-pack/plugins/siem/public/cases/components/use_push_to_service/index.tsx +++ b/x-pack/plugins/siem/public/cases/components/use_push_to_service/index.tsx @@ -75,7 +75,7 @@ export const usePushToService = ({ if (actionLicense != null && !actionLicense.enabledInLicense) { errors = [...errors, getLicenseError()]; } - if (connectors.length === 0 && !loadingLicense) { + if (connectors.length === 0 && caseConnectorId === 'none' && !loadingLicense) { errors = [ ...errors, { diff --git a/x-pack/plugins/siem/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap b/x-pack/plugins/siem/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap index 35a42acf7e1fb..5d077dba447fa 100644 --- a/x-pack/plugins/siem/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap +++ b/x-pack/plugins/siem/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap @@ -21,6 +21,10 @@ exports[`PageView component should display body header custom element 1`] = ` background: none; } +.c0 .endpoint-navTabs { + margin-left: 24px; +} + @@ -112,6 +116,10 @@ exports[`PageView component should display body header wrapped in EuiTitle 1`] = background: none; } +.c0 .endpoint-navTabs { + margin-left: 24px; +} + @@ -383,6 +399,10 @@ exports[`PageView component should display only header left 1`] = ` background: none; } +.c0 .endpoint-navTabs { + margin-left: 24px; +} + diff --git a/x-pack/plugins/siem/public/common/components/endpoint/page_view.tsx b/x-pack/plugins/siem/public/common/components/endpoint/page_view.tsx index ecc480fc97293..759274e3a4ffa 100644 --- a/x-pack/plugins/siem/public/common/components/endpoint/page_view.tsx +++ b/x-pack/plugins/siem/public/common/components/endpoint/page_view.tsx @@ -14,10 +14,13 @@ import { EuiPageHeader, EuiPageHeaderSection, EuiPageProps, + EuiTab, + EuiTabs, EuiTitle, } from '@elastic/eui'; -import React, { memo, ReactNode } from 'react'; +import React, { memo, MouseEventHandler, ReactNode, useMemo } from 'react'; import styled from 'styled-components'; +import { EuiTabProps } from '@elastic/eui/src/components/tabs/tab'; const StyledEuiPage = styled(EuiPage)` &.endpoint--isListView { @@ -39,6 +42,9 @@ const StyledEuiPage = styled(EuiPage)` background: none; } } + .endpoint-navTabs { + margin-left: ${(props) => props.theme.eui.euiSizeL}; + } `; const isStringOrNumber = /(string|number)/; @@ -74,69 +80,94 @@ export const PageViewBodyHeaderTitle = memo<{ children: ReactNode }>( ); PageViewBodyHeaderTitle.displayName = 'PageViewBodyHeaderTitle'; +export type PageViewProps = EuiPageProps & { + /** + * The type of view + */ + viewType: 'list' | 'details'; + /** + * content to be placed on the left side of the header. If a `string` is used, then it will + * be wrapped with `

    `, else it will just be used as is. + */ + headerLeft?: ReactNode; + /** Content for the right side of the header */ + headerRight?: ReactNode; + /** + * body (sub-)header section. If a `string` is used, then it will be wrapped with + * `

    ` + */ + bodyHeader?: ReactNode; + /** + * The list of tab navigation items + */ + tabs?: Array< + EuiTabProps & { + name: ReactNode; + id: string; + href?: string; + onClick?: MouseEventHandler; + } + >; + children?: ReactNode; +}; + /** * Page View layout for use in Endpoint */ -export const PageView = memo< - EuiPageProps & { - /** - * The type of view - */ - viewType: 'list' | 'details'; - /** - * content to be placed on the left side of the header. If a `string` is used, then it will - * be wrapped with `

    `, else it will just be used as is. - */ - headerLeft?: ReactNode; - /** Content for the right side of the header */ - headerRight?: ReactNode; - /** - * body (sub-)header section. If a `string` is used, then it will be wrapped with - * `

    ` - */ - bodyHeader?: ReactNode; - children?: ReactNode; - } ->(({ viewType, children, headerLeft, headerRight, bodyHeader, ...otherProps }) => { - return ( - - - {(headerLeft || headerRight) && ( - - - {isStringOrNumber.test(typeof headerLeft) ? ( - {headerLeft} - ) : ( - headerLeft - )} - - {headerRight && ( - - {headerRight} - - )} - - )} - - {bodyHeader && ( - - - {isStringOrNumber.test(typeof bodyHeader) ? ( - {bodyHeader} +export const PageView = memo( + ({ viewType, children, headerLeft, headerRight, bodyHeader, tabs, ...otherProps }) => { + const tabComponents = useMemo(() => { + if (!tabs) { + return []; + } + return tabs.map(({ name, id, ...otherEuiTabProps }) => ( + + {name} + + )); + }, [tabs]); + + return ( + + + {(headerLeft || headerRight) && ( + + + {isStringOrNumber.test(typeof headerLeft) ? ( + {headerLeft} ) : ( - bodyHeader + headerLeft )} - - + + {headerRight && ( + + {headerRight} + + )} + )} - {children} - - - - ); -}); + {tabs && {tabComponents}} + + {bodyHeader && ( + + + {isStringOrNumber.test(typeof bodyHeader) ? ( + {bodyHeader} + ) : ( + bodyHeader + )} + + + )} + {children} + + + + ); + } +); PageView.displayName = 'PageView'; diff --git a/x-pack/plugins/siem/public/common/components/header_global/index.test.tsx b/x-pack/plugins/siem/public/common/components/header_global/index.test.tsx index 0f6c5c2e139a7..809f0eeb811f4 100644 --- a/x-pack/plugins/siem/public/common/components/header_global/index.test.tsx +++ b/x-pack/plugins/siem/public/common/components/header_global/index.test.tsx @@ -18,6 +18,7 @@ jest.mock('react-router-dom', () => ({ state: '', }), withRouter: () => jest.fn(), + generatePath: jest.fn(), })); // Test will fail because we will to need to mock some core services to make the test work diff --git a/x-pack/plugins/siem/public/common/components/link_to/link_to.tsx b/x-pack/plugins/siem/public/common/components/link_to/link_to.tsx index 77636af8bc4a4..8151291679e32 100644 --- a/x-pack/plugins/siem/public/common/components/link_to/link_to.tsx +++ b/x-pack/plugins/siem/public/common/components/link_to/link_to.tsx @@ -27,6 +27,7 @@ import { } from './redirect_to_case'; import { DetectionEngineTab } from '../../../alerts/pages/detection_engine/types'; import { TimelineType } from '../../../../common/types/timeline'; +import { RedirectToManagementPage } from './redirect_to_management'; interface LinkToPageProps { match: RouteMatch<{}>; @@ -120,6 +121,10 @@ export const LinkToPage = React.memo(({ match }) => ( component={RedirectToTimelinesPage} path={`${match.url}/:pageName(${SiemPageName.timelines})/:tabName(${TimelineType.default}|${TimelineType.template})`} /> + )); diff --git a/x-pack/plugins/siem/public/common/components/link_to/redirect_to_management.tsx b/x-pack/plugins/siem/public/common/components/link_to/redirect_to_management.tsx new file mode 100644 index 0000000000000..595c203993bb7 --- /dev/null +++ b/x-pack/plugins/siem/public/common/components/link_to/redirect_to_management.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { RedirectWrapper } from './redirect_wrapper'; +import { SiemPageName } from '../../../app/types'; + +export const RedirectToManagementPage = memo(() => { + return ; +}); + +RedirectToManagementPage.displayName = 'RedirectToManagementPage'; diff --git a/x-pack/plugins/siem/public/common/components/navigation/index.test.tsx b/x-pack/plugins/siem/public/common/components/navigation/index.test.tsx index ff3f9ba0694a9..fd96885e5bc10 100644 --- a/x-pack/plugins/siem/public/common/components/navigation/index.test.tsx +++ b/x-pack/plugins/siem/public/common/components/navigation/index.test.tsx @@ -72,6 +72,13 @@ describe('SIEM Navigation', () => { name: 'Cases', urlKey: 'case', }, + management: { + disabled: false, + href: '#/management', + id: 'management', + name: 'Management', + urlKey: 'management', + }, detections: { disabled: false, href: '#/link-to/detections', @@ -111,6 +118,7 @@ describe('SIEM Navigation', () => { pageName: 'hosts', pathName: '/hosts', search: '', + state: undefined, tabName: 'authentications', query: { query: '', language: 'kuery' }, filters: [], @@ -179,6 +187,13 @@ describe('SIEM Navigation', () => { name: 'Hosts', urlKey: 'host', }, + management: { + disabled: false, + href: '#/management', + id: 'management', + name: 'Management', + urlKey: 'management', + }, network: { disabled: false, href: '#/link-to/network', diff --git a/x-pack/plugins/siem/public/common/components/url_state/constants.ts b/x-pack/plugins/siem/public/common/components/url_state/constants.ts index b6ef3c8ccd4e9..1faff2594ce80 100644 --- a/x-pack/plugins/siem/public/common/components/url_state/constants.ts +++ b/x-pack/plugins/siem/public/common/components/url_state/constants.ts @@ -12,6 +12,7 @@ export enum CONSTANTS { filters = 'filters', hostsDetails = 'hosts.details', hostsPage = 'hosts.page', + management = 'management', networkDetails = 'network.details', networkPage = 'network.page', overviewPage = 'overview.page', @@ -22,4 +23,11 @@ export enum CONSTANTS { unknown = 'unknown', } -export type UrlStateType = 'case' | 'detections' | 'host' | 'network' | 'overview' | 'timeline'; +export type UrlStateType = + | 'case' + | 'detections' + | 'host' + | 'network' + | 'overview' + | 'timeline' + | 'management'; diff --git a/x-pack/plugins/siem/public/common/components/url_state/types.ts b/x-pack/plugins/siem/public/common/components/url_state/types.ts index 56578d84e12e4..8881a82e5cd1c 100644 --- a/x-pack/plugins/siem/public/common/components/url_state/types.ts +++ b/x-pack/plugins/siem/public/common/components/url_state/types.ts @@ -8,10 +8,10 @@ import ApolloClient from 'apollo-client'; import * as H from 'history'; import { ActionCreator } from 'typescript-fsa'; import { - IIndexPattern, - Query, Filter, FilterManager, + IIndexPattern, + Query, SavedQueryService, } from 'src/plugins/data/public'; @@ -46,6 +46,7 @@ export const URL_STATE_KEYS: Record = { CONSTANTS.timerange, CONSTANTS.timeline, ], + management: [], network: [ CONSTANTS.appQuery, CONSTANTS.filters, diff --git a/x-pack/plugins/siem/public/common/mock/endpoint/app_context_render.tsx b/x-pack/plugins/siem/public/common/mock/endpoint/app_context_render.tsx index 9a7048efd4d0e..e62f36c2ec782 100644 --- a/x-pack/plugins/siem/public/common/mock/endpoint/app_context_render.tsx +++ b/x-pack/plugins/siem/public/common/mock/endpoint/app_context_render.tsx @@ -15,11 +15,10 @@ import { depsStartMock } from './dependencies_start_mock'; import { MiddlewareActionSpyHelper, createSpyMiddleware } from '../../store/test_utils'; import { apolloClientObservable } from '../test_providers'; import { createStore, State, substateMiddlewareFactory } from '../../store'; -import { hostMiddlewareFactory } from '../../../endpoint_hosts/store/middleware'; -import { policyListMiddlewareFactory } from '../../../endpoint_policy/store/policy_list/middleware'; -import { policyDetailsMiddlewareFactory } from '../../../endpoint_policy/store/policy_details/middleware'; +import { hostMiddlewareFactory } from '../../../endpoint_hosts/store'; import { alertMiddlewareFactory } from '../../../endpoint_alerts/store/middleware'; import { AppRootProvider } from './app_root_provider'; +import { managementMiddlewareFactory } from '../../../management/store'; import { SUB_PLUGINS_REDUCER, mockGlobalState } from '..'; type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; @@ -63,18 +62,11 @@ export const createAppRootMockRenderer = (): AppContextTestRender => { (globalState) => globalState.hostList, hostMiddlewareFactory(coreStart, depsStart) ), - substateMiddlewareFactory( - (globalState) => globalState.policyList, - policyListMiddlewareFactory(coreStart, depsStart) - ), - substateMiddlewareFactory( - (globalState) => globalState.policyDetails, - policyDetailsMiddlewareFactory(coreStart, depsStart) - ), substateMiddlewareFactory( (globalState) => globalState.alertList, alertMiddlewareFactory(coreStart, depsStart) ), + ...managementMiddlewareFactory(coreStart, depsStart), middlewareSpy.actionSpyMiddleware, ]); diff --git a/x-pack/plugins/siem/public/common/mock/global_state.ts b/x-pack/plugins/siem/public/common/mock/global_state.ts index da49ebe6552f3..c96f67a39dbfe 100644 --- a/x-pack/plugins/siem/public/common/mock/global_state.ts +++ b/x-pack/plugins/siem/public/common/mock/global_state.ts @@ -25,15 +25,13 @@ import { } from '../../../common/constants'; import { networkModel } from '../../network/store'; import { TimelineType, TimelineStatus } from '../../../common/types/timeline'; -import { initialPolicyListState } from '../../endpoint_policy/store/policy_list/reducer'; import { initialAlertListState } from '../../endpoint_alerts/store/reducer'; -import { initialPolicyDetailsState } from '../../endpoint_policy/store/policy_details/reducer'; import { initialHostListState } from '../../endpoint_hosts/store/reducer'; +import { getManagementInitialState } from '../../management/store'; -const policyList = initialPolicyListState(); const alertList = initialAlertListState(); -const policyDetails = initialPolicyDetailsState(); const hostList = initialHostListState(); +const management = getManagementInitialState(); export const mockGlobalState: State = { app: { @@ -237,6 +235,5 @@ export const mockGlobalState: State = { }, alertList, hostList, - policyList, - policyDetails, + management, }; diff --git a/x-pack/plugins/siem/public/common/mock/utils.ts b/x-pack/plugins/siem/public/common/mock/utils.ts index 68c52e493898f..532637acab767 100644 --- a/x-pack/plugins/siem/public/common/mock/utils.ts +++ b/x-pack/plugins/siem/public/common/mock/utils.ts @@ -9,8 +9,7 @@ import { networkReducer } from '../../network/store'; import { timelineReducer } from '../../timelines/store/timeline/reducer'; import { hostListReducer } from '../../endpoint_hosts/store'; import { alertListReducer } from '../../endpoint_alerts/store'; -import { policyListReducer } from '../../endpoint_policy/store/policy_list'; -import { policyDetailsReducer } from '../../endpoint_policy/store/policy_details'; +import { managementReducer } from '../../management/store'; interface Global extends NodeJS.Global { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -25,6 +24,5 @@ export const SUB_PLUGINS_REDUCER = { timeline: timelineReducer, hostList: hostListReducer, alertList: alertListReducer, - policyList: policyListReducer, - policyDetails: policyDetailsReducer, + management: managementReducer, }; diff --git a/x-pack/plugins/siem/public/common/store/actions.ts b/x-pack/plugins/siem/public/common/store/actions.ts index a51b075dc7514..58e4e2f363e92 100644 --- a/x-pack/plugins/siem/public/common/store/actions.ts +++ b/x-pack/plugins/siem/public/common/store/actions.ts @@ -6,8 +6,8 @@ import { HostAction } from '../../endpoint_hosts/store/action'; import { AlertAction } from '../../endpoint_alerts/store/action'; -import { PolicyListAction } from '../../endpoint_policy/store/policy_list'; -import { PolicyDetailsAction } from '../../endpoint_policy/store/policy_details'; +import { PolicyListAction } from '../../management/pages/policy/store/policy_list'; +import { PolicyDetailsAction } from '../../management/pages/policy/store/policy_details'; export { appActions } from './app'; export { dragAndDropActions } from './drag_and_drop'; diff --git a/x-pack/plugins/siem/public/common/store/reducer.ts b/x-pack/plugins/siem/public/common/store/reducer.ts index 570e851a3aa5e..e06543b8d7181 100644 --- a/x-pack/plugins/siem/public/common/store/reducer.ts +++ b/x-pack/plugins/siem/public/common/store/reducer.ts @@ -18,14 +18,8 @@ import { EndpointAlertsPluginReducer, } from '../../endpoint_alerts/store'; import { EndpointHostsPluginState, EndpointHostsPluginReducer } from '../../endpoint_hosts/store'; -import { - EndpointPolicyDetailsStatePluginState, - EndpointPolicyDetailsStatePluginReducer, -} from '../../endpoint_policy/store/policy_details'; -import { - EndpointPolicyListStatePluginState, - EndpointPolicyListStatePluginReducer, -} from '../../endpoint_policy/store/policy_list'; + +import { ManagementPluginReducer, ManagementPluginState } from '../../management/store/types'; export interface State extends HostsPluginState, @@ -33,8 +27,7 @@ export interface State TimelinePluginState, EndpointAlertsPluginState, EndpointHostsPluginState, - EndpointPolicyDetailsStatePluginState, - EndpointPolicyListStatePluginState { + ManagementPluginState { app: AppState; dragAndDrop: DragAndDropState; inputs: InputsState; @@ -51,15 +44,14 @@ type SubPluginsInitState = HostsPluginState & TimelinePluginState & EndpointAlertsPluginState & EndpointHostsPluginState & - EndpointPolicyDetailsStatePluginState & - EndpointPolicyListStatePluginState; + ManagementPluginState; + export type SubPluginsInitReducer = HostsPluginReducer & NetworkPluginReducer & TimelinePluginReducer & EndpointAlertsPluginReducer & EndpointHostsPluginReducer & - EndpointPolicyDetailsStatePluginReducer & - EndpointPolicyListStatePluginReducer; + ManagementPluginReducer; export const createInitialState = (pluginsInitState: SubPluginsInitState): State => ({ ...initialState, diff --git a/x-pack/plugins/siem/public/common/store/types.ts b/x-pack/plugins/siem/public/common/store/types.ts index 0a1010ea87fca..a4bfdeb30b438 100644 --- a/x-pack/plugins/siem/public/common/store/types.ts +++ b/x-pack/plugins/siem/public/common/store/types.ts @@ -61,6 +61,17 @@ export type ImmutableMiddlewareFactory = ( depsStart: Pick ) => ImmutableMiddleware; +/** + * Takes application-standard middleware dependencies + * and returns an array of redux middleware. + * Middleware will be of the `ImmutableMiddleware` variety. Not able to directly + * change actions or state. + */ +export type ImmutableMultipleMiddlewareFactory = ( + coreStart: CoreStart, + depsStart: Pick +) => Array>; + /** * Simple type for a redux selector. */ diff --git a/x-pack/plugins/siem/public/endpoint_alerts/index.ts b/x-pack/plugins/siem/public/endpoint_alerts/index.ts index 8b7e13c118fd0..6380edbde6958 100644 --- a/x-pack/plugins/siem/public/endpoint_alerts/index.ts +++ b/x-pack/plugins/siem/public/endpoint_alerts/index.ts @@ -22,10 +22,12 @@ export class EndpointAlerts { plugins: StartPlugins ): SecuritySubPluginWithStore<'alertList', Immutable> { const { data, ingestManager } = plugins; - const middleware = substateMiddlewareFactory( - (globalState) => globalState.alertList, - alertMiddlewareFactory(core, { data, ingestManager }) - ); + const middleware = [ + substateMiddlewareFactory( + (globalState) => globalState.alertList, + alertMiddlewareFactory(core, { data, ingestManager }) + ), + ]; return { routes: getEndpointAlertsRoutes(), diff --git a/x-pack/plugins/siem/public/endpoint_hosts/index.ts b/x-pack/plugins/siem/public/endpoint_hosts/index.ts index c86078ef4b475..1c2649ec5cf91 100644 --- a/x-pack/plugins/siem/public/endpoint_hosts/index.ts +++ b/x-pack/plugins/siem/public/endpoint_hosts/index.ts @@ -22,10 +22,12 @@ export class EndpointHosts { plugins: StartPlugins ): SecuritySubPluginWithStore<'hostList', Immutable> { const { data, ingestManager } = plugins; - const middleware = substateMiddlewareFactory( - (globalState) => globalState.hostList, - hostMiddlewareFactory(core, { data, ingestManager }) - ); + const middleware = [ + substateMiddlewareFactory( + (globalState) => globalState.hostList, + hostMiddlewareFactory(core, { data, ingestManager }) + ), + ]; return { routes: getEndpointHostsRoutes(), store: { diff --git a/x-pack/plugins/siem/public/endpoint_policy/details.ts b/x-pack/plugins/siem/public/endpoint_policy/details.ts deleted file mode 100644 index 1375d851067b4..0000000000000 --- a/x-pack/plugins/siem/public/endpoint_policy/details.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { SecuritySubPluginWithStore } from '../app/types'; -import { getPolicyDetailsRoutes } from './routes'; -import { PolicyDetailsState } from './types'; -import { Immutable } from '../../common/endpoint/types'; -import { initialPolicyDetailsState, policyDetailsReducer } from './store/policy_details/reducer'; -import { policyDetailsMiddlewareFactory } from './store/policy_details/middleware'; -import { CoreStart } from '../../../../../src/core/public'; -import { StartPlugins } from '../types'; -import { substateMiddlewareFactory } from '../common/store'; - -export class EndpointPolicyDetails { - public setup() {} - - public start( - core: CoreStart, - plugins: StartPlugins - ): SecuritySubPluginWithStore<'policyDetails', Immutable> { - const { data, ingestManager } = plugins; - const middleware = substateMiddlewareFactory( - (globalState) => globalState.policyDetails, - policyDetailsMiddlewareFactory(core, { data, ingestManager }) - ); - - return { - routes: getPolicyDetailsRoutes(), - store: { - initialState: { - policyDetails: initialPolicyDetailsState(), - }, - reducer: { policyDetails: policyDetailsReducer }, - middleware, - }, - }; - } -} diff --git a/x-pack/plugins/siem/public/endpoint_policy/list.ts b/x-pack/plugins/siem/public/endpoint_policy/list.ts deleted file mode 100644 index 5dad5fac895e0..0000000000000 --- a/x-pack/plugins/siem/public/endpoint_policy/list.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { SecuritySubPluginWithStore } from '../app/types'; -import { getPolicyListRoutes } from './routes'; -import { PolicyListState } from './types'; -import { Immutable } from '../../common/endpoint/types'; -import { initialPolicyListState, policyListReducer } from './store/policy_list/reducer'; -import { policyListMiddlewareFactory } from './store/policy_list/middleware'; -import { CoreStart } from '../../../../../src/core/public'; -import { StartPlugins } from '../types'; -import { substateMiddlewareFactory } from '../common/store'; - -export class EndpointPolicyList { - public setup() {} - - public start( - core: CoreStart, - plugins: StartPlugins - ): SecuritySubPluginWithStore<'policyList', Immutable> { - const { data, ingestManager } = plugins; - const middleware = substateMiddlewareFactory( - (globalState) => globalState.policyList, - policyListMiddlewareFactory(core, { data, ingestManager }) - ); - - return { - routes: getPolicyListRoutes(), - store: { - initialState: { - policyList: initialPolicyListState(), - }, - reducer: { policyList: policyListReducer }, - middleware, - }, - }; - } -} diff --git a/x-pack/plugins/siem/public/endpoint_policy/routes.tsx b/x-pack/plugins/siem/public/endpoint_policy/routes.tsx deleted file mode 100644 index be820f3f2c5dc..0000000000000 --- a/x-pack/plugins/siem/public/endpoint_policy/routes.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { Route } from 'react-router-dom'; - -import { PolicyList, PolicyDetails } from './view'; - -export const getPolicyListRoutes = () => [ - , -]; - -export const getPolicyDetailsRoutes = () => [ - , -]; diff --git a/x-pack/plugins/siem/public/endpoint_policy/view/policy_hooks.ts b/x-pack/plugins/siem/public/endpoint_policy/view/policy_hooks.ts deleted file mode 100644 index 9fadba85c5245..0000000000000 --- a/x-pack/plugins/siem/public/endpoint_policy/view/policy_hooks.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useSelector } from 'react-redux'; -import { PolicyListState, PolicyDetailsState } from '../types'; -import { State } from '../../common/store'; - -export function usePolicyListSelector(selector: (state: PolicyListState) => TSelected) { - return useSelector((state: State) => selector(state.policyList as PolicyListState)); -} - -export function usePolicyDetailsSelector( - selector: (state: PolicyDetailsState) => TSelected -) { - return useSelector((state: State) => selector(state.policyDetails as PolicyDetailsState)); -} diff --git a/x-pack/plugins/siem/public/management/common/constants.ts b/x-pack/plugins/siem/public/management/common/constants.ts new file mode 100644 index 0000000000000..9ec6817c0bfce --- /dev/null +++ b/x-pack/plugins/siem/public/management/common/constants.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { SiemPageName } from '../../app/types'; +import { ManagementStoreGlobalNamespace, ManagementSubTab } from '../types'; + +// --[ ROUTING ]--------------------------------------------------------------------------- +export const MANAGEMENT_ROUTING_ROOT_PATH = `/:pageName(${SiemPageName.management})`; +export const MANAGEMENT_ROUTING_ENDPOINTS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${ManagementSubTab.endpoints})`; +export const MANAGEMENT_ROUTING_POLICIES_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${ManagementSubTab.policies})`; +export const MANAGEMENT_ROUTING_POLICY_DETAILS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${ManagementSubTab.policies})/:policyId`; + +// --[ STORE ]--------------------------------------------------------------------------- +/** The SIEM global store namespace where the management state will be mounted */ +export const MANAGEMENT_STORE_GLOBAL_NAMESPACE: ManagementStoreGlobalNamespace = 'management'; +/** Namespace within the Management state where policy list state is maintained */ +export const MANAGEMENT_STORE_POLICY_LIST_NAMESPACE = 'policyList'; +/** Namespace within the Management state where policy details state is maintained */ +export const MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE = 'policyDetails'; diff --git a/x-pack/plugins/siem/public/management/common/routing.ts b/x-pack/plugins/siem/public/management/common/routing.ts new file mode 100644 index 0000000000000..e64fcf0c5f68a --- /dev/null +++ b/x-pack/plugins/siem/public/management/common/routing.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { generatePath } from 'react-router-dom'; +import { + MANAGEMENT_ROUTING_ENDPOINTS_PATH, + MANAGEMENT_ROUTING_POLICIES_PATH, + MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, + MANAGEMENT_ROUTING_ROOT_PATH, +} from './constants'; +import { ManagementSubTab } from '../types'; +import { SiemPageName } from '../../app/types'; + +export type GetManagementUrlProps = { + /** + * Exclude the URL prefix (everything to the left of where the router was mounted. + * This may be needed when interacting with react-router (ex. to do `history.push()` or + * validations against matched path) + */ + excludePrefix?: boolean; +} & ( + | { name: 'default' } + | { name: 'endpointList' } + | { name: 'policyList' } + | { name: 'policyDetails'; policyId: string } +); + +// Prefix is (almost) everything to the left of where the Router was mounted. In SIEM, since +// we're using Hash router, thats the `#`. +const URL_PREFIX = '#'; + +/** + * Returns a URL string for a given Management page view + * @param props + */ +export const getManagementUrl = (props: GetManagementUrlProps): string => { + let url = props.excludePrefix ? '' : URL_PREFIX; + + switch (props.name) { + case 'default': + url += generatePath(MANAGEMENT_ROUTING_ROOT_PATH, { + pageName: SiemPageName.management, + }); + break; + case 'endpointList': + url += generatePath(MANAGEMENT_ROUTING_ENDPOINTS_PATH, { + pageName: SiemPageName.management, + tabName: ManagementSubTab.endpoints, + }); + break; + case 'policyList': + url += generatePath(MANAGEMENT_ROUTING_POLICIES_PATH, { + pageName: SiemPageName.management, + tabName: ManagementSubTab.policies, + }); + break; + case 'policyDetails': + url += generatePath(MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, { + pageName: SiemPageName.management, + tabName: ManagementSubTab.policies, + policyId: props.policyId, + }); + break; + } + + return url; +}; diff --git a/x-pack/plugins/siem/public/management/components/management_page_view.tsx b/x-pack/plugins/siem/public/management/components/management_page_view.tsx new file mode 100644 index 0000000000000..13d8525e15e15 --- /dev/null +++ b/x-pack/plugins/siem/public/management/components/management_page_view.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useParams } from 'react-router-dom'; +import { PageView, PageViewProps } from '../../common/components/endpoint/page_view'; +import { ManagementSubTab } from '../types'; +import { getManagementUrl } from '..'; + +export const ManagementPageView = memo>((options) => { + const { tabName } = useParams<{ tabName: ManagementSubTab }>(); + const tabs = useMemo((): PageViewProps['tabs'] => { + return [ + { + name: i18n.translate('xpack.siem.managementTabs.endpoints', { + defaultMessage: 'Endpoints', + }), + id: ManagementSubTab.endpoints, + isSelected: tabName === ManagementSubTab.endpoints, + href: getManagementUrl({ name: 'endpointList' }), + }, + { + name: i18n.translate('xpack.siem.managementTabs.policies', { defaultMessage: 'Policies' }), + id: ManagementSubTab.policies, + isSelected: tabName === ManagementSubTab.policies, + href: getManagementUrl({ name: 'policyList' }), + }, + ]; + }, [tabName]); + return ; +}); + +ManagementPageView.displayName = 'ManagementPageView'; diff --git a/x-pack/plugins/siem/public/management/index.ts b/x-pack/plugins/siem/public/management/index.ts new file mode 100644 index 0000000000000..86522df110dfb --- /dev/null +++ b/x-pack/plugins/siem/public/management/index.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreStart } from 'kibana/public'; +import { managementReducer, getManagementInitialState, managementMiddlewareFactory } from './store'; +import { getManagementRoutes } from './routes'; +import { StartPlugins } from '../types'; +import { MANAGEMENT_STORE_GLOBAL_NAMESPACE } from './common/constants'; +import { SecuritySubPluginWithStore } from '../app/types'; +import { Immutable } from '../../common/endpoint/types'; +import { ManagementStoreGlobalNamespace } from './types'; +import { ManagementState } from './store/types'; + +export { getManagementUrl } from './common/routing'; + +export class Management { + public setup() {} + + public start( + core: CoreStart, + plugins: StartPlugins + ): SecuritySubPluginWithStore> { + return { + routes: getManagementRoutes(), + store: { + initialState: { + [MANAGEMENT_STORE_GLOBAL_NAMESPACE]: getManagementInitialState(), + }, + reducer: { + [MANAGEMENT_STORE_GLOBAL_NAMESPACE]: managementReducer, + }, + middleware: managementMiddlewareFactory(core, plugins), + }, + }; + } +} diff --git a/x-pack/plugins/siem/public/management/pages/index.tsx b/x-pack/plugins/siem/public/management/pages/index.tsx new file mode 100644 index 0000000000000..aba482db86519 --- /dev/null +++ b/x-pack/plugins/siem/public/management/pages/index.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { SpyRoute } from '../../common/utils/route/spy_routes'; +import { PolicyContainer } from './policy'; +import { + MANAGEMENT_ROUTING_ENDPOINTS_PATH, + MANAGEMENT_ROUTING_POLICIES_PATH, + MANAGEMENT_ROUTING_ROOT_PATH, +} from '../common/constants'; +import { ManagementPageView } from '../components/management_page_view'; +import { NotFoundPage } from '../../app/404'; + +const TmpEndpoints = () => { + return ( + +

    {'Endpoints will go here'}

    + +
    + ); +}; + +export const ManagementContainer = memo(() => { + return ( + + + + } + /> + + + ); +}); + +ManagementContainer.displayName = 'ManagementContainer'; diff --git a/x-pack/plugins/siem/public/management/pages/policy/index.tsx b/x-pack/plugins/siem/public/management/pages/policy/index.tsx new file mode 100644 index 0000000000000..5122bbcd5d55d --- /dev/null +++ b/x-pack/plugins/siem/public/management/pages/policy/index.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { Route, Switch } from 'react-router-dom'; +import { PolicyDetails, PolicyList } from './view'; +import { + MANAGEMENT_ROUTING_POLICIES_PATH, + MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, +} from '../../common/constants'; +import { NotFoundPage } from '../../../app/404'; + +export const PolicyContainer = memo(() => { + return ( + + + + + + ); +}); + +PolicyContainer.displayName = 'PolicyContainer'; diff --git a/x-pack/plugins/siem/public/endpoint_policy/models/policy_details_config.ts b/x-pack/plugins/siem/public/management/pages/policy/models/policy_details_config.ts similarity index 96% rename from x-pack/plugins/siem/public/endpoint_policy/models/policy_details_config.ts rename to x-pack/plugins/siem/public/management/pages/policy/models/policy_details_config.ts index 44be5ddcc003f..7c67dffb8a663 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/models/policy_details_config.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/models/policy_details_config.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { UIPolicyConfig } from '../../../common/endpoint/types'; +import { UIPolicyConfig } from '../../../../../common/endpoint/types'; /** * A typed Object.entries() function where the keys and values are typed based on the given object diff --git a/x-pack/plugins/siem/public/endpoint_policy/store/policy_details/action.ts b/x-pack/plugins/siem/public/management/pages/policy/store/policy_details/action.ts similarity index 87% rename from x-pack/plugins/siem/public/endpoint_policy/store/policy_details/action.ts rename to x-pack/plugins/siem/public/management/pages/policy/store/policy_details/action.ts index ceb62a9f9ace9..f729dfbd9a29a 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/store/policy_details/action.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/store/policy_details/action.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { GetAgentStatusResponse } from '../../../../../ingest_manager/common/types/rest_spec'; -import { PolicyData, UIPolicyConfig } from '../../../../common/endpoint/types'; -import { ServerApiError } from '../../../common/types'; +import { GetAgentStatusResponse } from '../../../../../../../ingest_manager/common/types/rest_spec'; +import { PolicyData, UIPolicyConfig } from '../../../../../../common/endpoint/types'; +import { ServerApiError } from '../../../../../common/types'; import { PolicyDetailsState } from '../../types'; interface ServerReturnedPolicyDetailsData { diff --git a/x-pack/plugins/siem/public/endpoint_policy/store/policy_details/index.test.ts b/x-pack/plugins/siem/public/management/pages/policy/store/policy_details/index.test.ts similarity index 96% rename from x-pack/plugins/siem/public/endpoint_policy/store/policy_details/index.test.ts rename to x-pack/plugins/siem/public/management/pages/policy/store/policy_details/index.test.ts index 01a824ecc7b8e..469b71854dfcc 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/store/policy_details/index.test.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/store/policy_details/index.test.ts @@ -9,7 +9,7 @@ import { createStore, Dispatch, Store } from 'redux'; import { policyDetailsReducer, PolicyDetailsAction } from './index'; import { policyConfig } from './selectors'; import { clone } from '../../models/policy_details_config'; -import { factory as policyConfigFactory } from '../../../../common/endpoint/models/policy_config'; +import { factory as policyConfigFactory } from '../../../../../../common/endpoint/models/policy_config'; describe('policy details: ', () => { let store: Store; diff --git a/x-pack/plugins/siem/public/endpoint_policy/store/policy_details/index.ts b/x-pack/plugins/siem/public/management/pages/policy/store/policy_details/index.ts similarity index 77% rename from x-pack/plugins/siem/public/endpoint_policy/store/policy_details/index.ts rename to x-pack/plugins/siem/public/management/pages/policy/store/policy_details/index.ts index 88f090301cfa3..9ccc47f250e4e 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/store/policy_details/index.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/store/policy_details/index.ts @@ -5,9 +5,9 @@ */ import { PolicyDetailsState } from '../../types'; -import { ImmutableReducer } from '../../../common/store'; -import { AppAction } from '../../../common/store/actions'; -import { Immutable } from '../../../../common/endpoint/types'; +import { ImmutableReducer } from '../../../../../common/store'; +import { AppAction } from '../../../../../common/store/actions'; +import { Immutable } from '../../../../../../common/endpoint/types'; export { policyDetailsMiddlewareFactory } from './middleware'; export { PolicyDetailsAction } from './action'; diff --git a/x-pack/plugins/siem/public/endpoint_policy/store/policy_details/middleware.ts b/x-pack/plugins/siem/public/management/pages/policy/store/policy_details/middleware.ts similarity index 93% rename from x-pack/plugins/siem/public/endpoint_policy/store/policy_details/middleware.ts rename to x-pack/plugins/siem/public/management/pages/policy/store/policy_details/middleware.ts index 883d8e780ea67..97cdcac0fcae9 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/store/policy_details/middleware.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/store/policy_details/middleware.ts @@ -16,9 +16,9 @@ import { sendGetFleetAgentStatusForConfig, sendPutDatasource, } from '../policy_list/services/ingest'; -import { NewPolicyData, PolicyData, Immutable } from '../../../../common/endpoint/types'; -import { factory as policyConfigFactory } from '../../../../common/endpoint/models/policy_config'; -import { ImmutableMiddlewareFactory } from '../../../common/store'; +import { NewPolicyData, PolicyData, Immutable } from '../../../../../../common/endpoint/types'; +import { factory as policyConfigFactory } from '../../../../../../common/endpoint/models/policy_config'; +import { ImmutableMiddlewareFactory } from '../../../../../common/store'; export const policyDetailsMiddlewareFactory: ImmutableMiddlewareFactory { return { diff --git a/x-pack/plugins/siem/public/endpoint_policy/store/policy_details/selectors.ts b/x-pack/plugins/siem/public/management/pages/policy/store/policy_details/selectors.ts similarity index 87% rename from x-pack/plugins/siem/public/endpoint_policy/store/policy_details/selectors.ts rename to x-pack/plugins/siem/public/management/pages/policy/store/policy_details/selectors.ts index 3c943986a72e4..d2a5c1b7e14a3 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/store/policy_details/selectors.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/store/policy_details/selectors.ts @@ -5,14 +5,17 @@ */ import { createSelector } from 'reselect'; +import { matchPath } from 'react-router-dom'; import { PolicyDetailsState } from '../../types'; import { Immutable, NewPolicyData, PolicyConfig, UIPolicyConfig, -} from '../../../../common/endpoint/types'; -import { factory as policyConfigFactory } from '../../../../common/endpoint/models/policy_config'; +} from '../../../../../../common/endpoint/types'; +import { factory as policyConfigFactory } from '../../../../../../common/endpoint/models/policy_config'; +import { MANAGEMENT_ROUTING_POLICY_DETAILS_PATH } from '../../../../common/constants'; +import { ManagementRoutePolicyDetailsParams } from '../../../../types'; /** Returns the policy details */ export const policyDetails = (state: Immutable) => state.policyItem; @@ -31,22 +34,24 @@ export const policyDetailsForUpdate: ( /** Returns a boolean of whether the user is on the policy details page or not */ export const isOnPolicyDetailsPage = (state: Immutable) => { - if (state.location) { - const pathnameParts = state.location.pathname.split('/'); - return pathnameParts[1] === 'policy' && pathnameParts[2]; - } else { - return false; - } + return ( + matchPath(state.location?.pathname ?? '', { + path: MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, + exact: true, + }) !== null + ); }; /** Returns the policyId from the url */ export const policyIdFromParams: (state: Immutable) => string = createSelector( (state) => state.location, (location: PolicyDetailsState['location']) => { - if (location) { - return location.pathname.split('/')[2]; - } - return ''; + return ( + matchPath(location?.pathname ?? '', { + path: MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, + exact: true, + })?.params?.policyId ?? '' + ); } ); diff --git a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/action.ts b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/action.ts similarity index 83% rename from x-pack/plugins/siem/public/endpoint_policy/store/policy_list/action.ts rename to x-pack/plugins/siem/public/management/pages/policy/store/policy_list/action.ts index bedbcdae3306f..6866bcbf31f89 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/action.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/action.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PolicyData } from '../../../../common/endpoint/types'; -import { ServerApiError } from '../../../common/types'; +import { PolicyData } from '../../../../../../common/endpoint/types'; +import { ServerApiError } from '../../../../../common/types'; interface ServerReturnedPolicyListData { type: 'serverReturnedPolicyListData'; diff --git a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/index.test.ts b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/index.test.ts similarity index 90% rename from x-pack/plugins/siem/public/endpoint_policy/store/policy_list/index.test.ts rename to x-pack/plugins/siem/public/management/pages/policy/store/policy_list/index.test.ts index 9b56062879583..c796edff8aabc 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/index.test.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/index.test.ts @@ -7,19 +7,24 @@ import { PolicyListState } from '../../types'; import { Store, applyMiddleware, createStore } from 'redux'; -import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { DATASOURCE_SAVED_OBJECT_TYPE } from '../../../../../ingest_manager/common'; +import { coreMock } from '../../../../../../../../../src/core/public/mocks'; +import { DATASOURCE_SAVED_OBJECT_TYPE } from '../../../../../../../ingest_manager/common'; import { policyListReducer, initialPolicyListState } from './reducer'; import { policyListMiddlewareFactory } from './middleware'; import { isOnPolicyListPage, selectIsLoading, urlSearchParams } from './selectors'; -import { DepsStartMock, depsStartMock } from '../../../common/mock/endpoint'; +import { DepsStartMock, depsStartMock } from '../../../../../common/mock/endpoint'; import { setPolicyListApiMockImplementation } from './test_mock_utils'; import { INGEST_API_DATASOURCES } from './services/ingest'; -import { createSpyMiddleware, MiddlewareActionSpyHelper } from '../../../common/store/test_utils'; +import { + createSpyMiddleware, + MiddlewareActionSpyHelper, +} from '../../../../../common/store/test_utils'; +import { getManagementUrl } from '../../../../common/routing'; describe('policy list store concerns', () => { + const policyListPathUrl = getManagementUrl({ name: 'policyList', excludePrefix: true }); let fakeCoreStart: ReturnType; let depsStart: DepsStartMock; let store: Store; @@ -57,7 +62,7 @@ describe('policy list store concerns', () => { store.dispatch({ type: 'userChangedUrl', payload: { - pathname: '/policy', + pathname: policyListPathUrl, search: '', hash: '', }, @@ -70,7 +75,7 @@ describe('policy list store concerns', () => { store.dispatch({ type: 'userChangedUrl', payload: { - pathname: '/policy', + pathname: policyListPathUrl, search: '', hash: '', }, @@ -84,7 +89,7 @@ describe('policy list store concerns', () => { store.dispatch({ type: 'userChangedUrl', payload: { - pathname: '/policy', + pathname: policyListPathUrl, search: '', hash: '', }, @@ -112,7 +117,7 @@ describe('policy list store concerns', () => { store.dispatch({ type: 'userChangedUrl', payload: { - pathname: '/policy', + pathname: policyListPathUrl, search: '', hash: '', }, @@ -132,7 +137,7 @@ describe('policy list store concerns', () => { store.dispatch({ type: 'userChangedUrl', payload: { - pathname: '/policy', + pathname: policyListPathUrl, search: searchParams, hash: '', }, diff --git a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/index.ts b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/index.ts similarity index 76% rename from x-pack/plugins/siem/public/endpoint_policy/store/policy_list/index.ts rename to x-pack/plugins/siem/public/management/pages/policy/store/policy_list/index.ts index a4f51fcf0ec66..e09f80883d888 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/index.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/index.ts @@ -5,9 +5,9 @@ */ import { PolicyListState } from '../../types'; -import { ImmutableReducer } from '../../../common/store'; -import { AppAction } from '../../../common/store/actions'; -import { Immutable } from '../../../../common/endpoint/types'; +import { ImmutableReducer } from '../../../../../common/store'; +import { AppAction } from '../../../../../common/store/actions'; +import { Immutable } from '../../../../../../common/endpoint/types'; export { policyListReducer } from './reducer'; export { PolicyListAction } from './action'; export { policyListMiddlewareFactory } from './middleware'; diff --git a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/middleware.ts b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/middleware.ts similarity index 91% rename from x-pack/plugins/siem/public/endpoint_policy/store/policy_list/middleware.ts rename to x-pack/plugins/siem/public/management/pages/policy/store/policy_list/middleware.ts index 8602ab8170565..6054ec34b2d01 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/middleware.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/middleware.ts @@ -7,8 +7,8 @@ import { GetPolicyListResponse, PolicyListState } from '../../types'; import { sendGetEndpointSpecificDatasources } from './services/ingest'; import { isOnPolicyListPage, urlSearchParams } from './selectors'; -import { ImmutableMiddlewareFactory } from '../../../common/store'; -import { Immutable } from '../../../../common/endpoint/types'; +import { ImmutableMiddlewareFactory } from '../../../../../common/store'; +import { Immutable } from '../../../../../../common/endpoint/types'; export const policyListMiddlewareFactory: ImmutableMiddlewareFactory> = ( coreStart diff --git a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/reducer.ts b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/reducer.ts similarity index 89% rename from x-pack/plugins/siem/public/endpoint_policy/store/policy_list/reducer.ts rename to x-pack/plugins/siem/public/management/pages/policy/store/policy_list/reducer.ts index 80e890602c921..028e46936b293 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/reducer.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/reducer.ts @@ -6,9 +6,9 @@ import { PolicyListState } from '../../types'; import { isOnPolicyListPage } from './selectors'; -import { ImmutableReducer } from '../../../common/store'; -import { AppAction } from '../../../common/store/actions'; -import { Immutable } from '../../../../common/endpoint/types'; +import { ImmutableReducer } from '../../../../../common/store'; +import { AppAction } from '../../../../../common/store/actions'; +import { Immutable } from '../../../../../../common/endpoint/types'; export const initialPolicyListState = (): PolicyListState => { return { diff --git a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/selectors.ts b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/selectors.ts similarity index 87% rename from x-pack/plugins/siem/public/endpoint_policy/store/policy_list/selectors.ts rename to x-pack/plugins/siem/public/management/pages/policy/store/policy_list/selectors.ts index cd6230a6ed3be..c900ceb186f69 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/selectors.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/selectors.ts @@ -6,8 +6,10 @@ import { createSelector } from 'reselect'; import { parse } from 'query-string'; +import { matchPath } from 'react-router-dom'; import { PolicyListState, PolicyListUrlSearchParams } from '../../types'; -import { Immutable } from '../../../../common/endpoint/types'; +import { Immutable } from '../../../../../../common/endpoint/types'; +import { MANAGEMENT_ROUTING_POLICIES_PATH } from '../../../../common/constants'; const PAGE_SIZES = Object.freeze([10, 20, 50]); @@ -24,7 +26,12 @@ export const selectIsLoading = (state: Immutable) => state.isLo export const selectApiError = (state: Immutable) => state.apiError; export const isOnPolicyListPage = (state: Immutable) => { - return state.location?.pathname === '/policy'; + return ( + matchPath(state.location?.pathname ?? '', { + path: MANAGEMENT_ROUTING_POLICIES_PATH, + exact: true, + }) !== null + ); }; const routeLocation = (state: Immutable) => state.location; diff --git a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/services/ingest.test.ts b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/services/ingest.test.ts similarity index 94% rename from x-pack/plugins/siem/public/endpoint_policy/store/policy_list/services/ingest.test.ts rename to x-pack/plugins/siem/public/management/pages/policy/store/policy_list/services/ingest.test.ts index df61bbe893c58..cbbc5c3c6fdbe 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/services/ingest.test.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/services/ingest.test.ts @@ -5,8 +5,8 @@ */ import { sendGetDatasource, sendGetEndpointSpecificDatasources } from './ingest'; -import { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; -import { DATASOURCE_SAVED_OBJECT_TYPE } from '../../../../../../ingest_manager/common'; +import { httpServiceMock } from '../../../../../../../../../../src/core/public/mocks'; +import { DATASOURCE_SAVED_OBJECT_TYPE } from '../../../../../../../../ingest_manager/common'; describe('ingest service', () => { let http: ReturnType; diff --git a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/services/ingest.ts b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/services/ingest.ts similarity index 95% rename from x-pack/plugins/siem/public/endpoint_policy/store/policy_list/services/ingest.ts rename to x-pack/plugins/siem/public/management/pages/policy/store/policy_list/services/ingest.ts index 312a3f7491ab2..db482e2a6bdb6 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/services/ingest.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/services/ingest.ts @@ -9,9 +9,9 @@ import { GetDatasourcesRequest, GetAgentStatusResponse, DATASOURCE_SAVED_OBJECT_TYPE, -} from '../../../../../../ingest_manager/common'; +} from '../../../../../../../../ingest_manager/common'; import { GetPolicyListResponse, GetPolicyResponse, UpdatePolicyResponse } from '../../../types'; -import { NewPolicyData } from '../../../../../common/endpoint/types'; +import { NewPolicyData } from '../../../../../../../common/endpoint/types'; const INGEST_API_ROOT = `/api/ingest_manager`; export const INGEST_API_DATASOURCES = `${INGEST_API_ROOT}/datasources`; diff --git a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/test_mock_utils.ts b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/test_mock_utils.ts similarity index 93% rename from x-pack/plugins/siem/public/endpoint_policy/store/policy_list/test_mock_utils.ts rename to x-pack/plugins/siem/public/management/pages/policy/store/policy_list/test_mock_utils.ts index b8fac21b76a26..2c495202dc75b 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/store/policy_list/test_mock_utils.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/store/policy_list/test_mock_utils.ts @@ -6,7 +6,7 @@ import { HttpStart } from 'kibana/public'; import { INGEST_API_DATASOURCES } from './services/ingest'; -import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { EndpointDocGenerator } from '../../../../../../common/endpoint/generate_data'; import { GetPolicyListResponse } from '../../types'; const generator = new EndpointDocGenerator('policy-list'); diff --git a/x-pack/plugins/siem/public/endpoint_policy/types.ts b/x-pack/plugins/siem/public/management/pages/policy/types.ts similarity index 96% rename from x-pack/plugins/siem/public/endpoint_policy/types.ts rename to x-pack/plugins/siem/public/management/pages/policy/types.ts index ba42140589789..f8cc0d5cd0508 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/types.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/types.ts @@ -10,14 +10,14 @@ import { MalwareFields, UIPolicyConfig, AppLocation, -} from '../../common/endpoint/types'; -import { ServerApiError } from '../common/types'; +} from '../../../../common/endpoint/types'; +import { ServerApiError } from '../../../common/types'; import { GetAgentStatusResponse, GetDatasourcesResponse, GetOneDatasourceResponse, UpdateDatasourceResponse, -} from '../../../ingest_manager/common'; +} from '../../../../../ingest_manager/common'; /** * Policy list store state diff --git a/x-pack/plugins/siem/public/endpoint_policy/view/agents_summary.tsx b/x-pack/plugins/siem/public/management/pages/policy/view/agents_summary.tsx similarity index 100% rename from x-pack/plugins/siem/public/endpoint_policy/view/agents_summary.tsx rename to x-pack/plugins/siem/public/management/pages/policy/view/agents_summary.tsx diff --git a/x-pack/plugins/siem/public/endpoint_policy/view/index.ts b/x-pack/plugins/siem/public/management/pages/policy/view/index.ts similarity index 100% rename from x-pack/plugins/siem/public/endpoint_policy/view/index.ts rename to x-pack/plugins/siem/public/management/pages/policy/view/index.ts diff --git a/x-pack/plugins/siem/public/endpoint_policy/view/policy_details.test.tsx b/x-pack/plugins/siem/public/management/pages/policy/view/policy_details.test.tsx similarity index 90% rename from x-pack/plugins/siem/public/endpoint_policy/view/policy_details.test.tsx rename to x-pack/plugins/siem/public/management/pages/policy/view/policy_details.test.tsx index 5d736da4e5635..01e12e6c767a6 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/view/policy_details.test.tsx +++ b/x-pack/plugins/siem/public/management/pages/policy/view/policy_details.test.tsx @@ -8,12 +8,20 @@ import React from 'react'; import { mount } from 'enzyme'; import { PolicyDetails } from './policy_details'; -import { EndpointDocGenerator } from '../../../common/endpoint/generate_data'; -import { createAppRootMockRenderer } from '../../common/mock/endpoint'; +import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; +import { createAppRootMockRenderer } from '../../../../common/mock/endpoint'; +import { getManagementUrl } from '../../../common/routing'; describe('Policy Details', () => { type FindReactWrapperResponse = ReturnType['find']>; + const policyDetailsPathUrl = getManagementUrl({ + name: 'policyDetails', + policyId: '1', + excludePrefix: true, + }); + const policyListPathUrl = getManagementUrl({ name: 'policyList', excludePrefix: true }); + const policyListPathUrlWithPrefix = getManagementUrl({ name: 'policyList' }); const sleep = (ms = 100) => new Promise((wakeup) => setTimeout(wakeup, ms)); const generator = new EndpointDocGenerator(); const { history, AppWrapper, coreStart } = createAppRootMockRenderer(); @@ -33,7 +41,7 @@ describe('Policy Details', () => { describe('when displayed with invalid id', () => { beforeEach(() => { http.get.mockReturnValue(Promise.reject(new Error('policy not found'))); - history.push('/policy/1'); + history.push(policyDetailsPathUrl); policyView = render(); }); @@ -77,7 +85,7 @@ describe('Policy Details', () => { return Promise.reject(new Error('unknown API call!')); }); - history.push('/policy/1'); + history.push(policyDetailsPathUrl); policyView = render(); }); @@ -89,7 +97,7 @@ describe('Policy Details', () => { const backToListButton = pageHeaderLeft.find('EuiButtonEmpty'); expect(backToListButton.prop('iconType')).toBe('arrowLeft'); - expect(backToListButton.prop('href')).toBe('/mock/app/endpoint/policy'); + expect(backToListButton.prop('href')).toBe(policyListPathUrlWithPrefix); expect(backToListButton.text()).toBe('Back to policy list'); const pageTitle = pageHeaderLeft.find('[data-test-subj="pageViewHeaderLeftTitle"]'); @@ -101,9 +109,9 @@ describe('Policy Details', () => { const backToListButton = policyView.find( 'EuiPageHeaderSection[data-test-subj="pageViewHeaderLeft"] EuiButtonEmpty' ); - expect(history.location.pathname).toEqual('/policy/1'); + expect(history.location.pathname).toEqual(policyDetailsPathUrl); backToListButton.simulate('click', { button: 0 }); - expect(history.location.pathname).toEqual('/policy'); + expect(history.location.pathname).toEqual(policyListPathUrl); }); it('should display agent stats', async () => { await asyncActions; @@ -130,9 +138,9 @@ describe('Policy Details', () => { const cancelbutton = policyView.find( 'EuiButtonEmpty[data-test-subj="policyDetailsCancelButton"]' ); - expect(history.location.pathname).toEqual('/policy/1'); + expect(history.location.pathname).toEqual(policyDetailsPathUrl); cancelbutton.simulate('click', { button: 0 }); - expect(history.location.pathname).toEqual('/policy'); + expect(history.location.pathname).toEqual(policyListPathUrl); }); it('should display save button', async () => { await asyncActions; diff --git a/x-pack/plugins/siem/public/endpoint_policy/view/policy_details.tsx b/x-pack/plugins/siem/public/management/pages/policy/view/policy_details.tsx similarity index 90% rename from x-pack/plugins/siem/public/endpoint_policy/view/policy_details.tsx rename to x-pack/plugins/siem/public/management/pages/policy/view/policy_details.tsx index c928a374502a5..bddbd378f9427 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/view/policy_details.tsx +++ b/x-pack/plugins/siem/public/management/pages/policy/view/policy_details.tsx @@ -28,18 +28,21 @@ import { isLoading, apiError, } from '../store/policy_details/selectors'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { AgentsSummary } from './agents_summary'; import { VerticalDivider } from './vertical_divider'; import { WindowsEvents, MacEvents, LinuxEvents } from './policy_forms/events'; import { MalwareProtections } from './policy_forms/protections/malware'; -import { AppAction } from '../../common/store/actions'; -import { useNavigateByRouterEventHandler } from '../../common/hooks/endpoint/use_navigate_by_router_event_handler'; -import { PageView, PageViewHeaderTitle } from '../../common/components/endpoint/page_view'; +import { AppAction } from '../../../../common/store/actions'; +import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; +import { PageViewHeaderTitle } from '../../../../common/components/endpoint/page_view'; +import { ManagementPageView } from '../../../components/management_page_view'; +import { SpyRoute } from '../../../../common/utils/route/spy_routes'; +import { getManagementUrl } from '../../../common/routing'; export const PolicyDetails = React.memo(() => { const dispatch = useDispatch<(action: AppAction) => void>(); - const { notifications, services } = useKibana(); + const { notifications } = useKibana(); // Store values const policyItem = usePolicyDetailsSelector(policyDetails); @@ -81,7 +84,9 @@ export const PolicyDetails = React.memo(() => { } }, [notifications.toasts, policyName, policyUpdateStatus]); - const handleBackToListOnClick = useNavigateByRouterEventHandler('/policy'); + const handleBackToListOnClick = useNavigateByRouterEventHandler( + getManagementUrl({ name: 'policyList', excludePrefix: true }) + ); const handleSaveOnClick = useCallback(() => { setShowConfirm(true); @@ -103,7 +108,7 @@ export const PolicyDetails = React.memo(() => { // Else, if we have an error, then show error on the page. if (!policyItem) { return ( - + {isPolicyLoading ? ( ) : policyApiError ? ( @@ -111,7 +116,8 @@ export const PolicyDetails = React.memo(() => { {policyApiError?.message} ) : null} - + + ); } @@ -122,7 +128,7 @@ export const PolicyDetails = React.memo(() => { iconType="arrowLeft" contentProps={{ style: { paddingLeft: '0' } }} onClick={handleBackToListOnClick} - href={`${services.http.basePath.get()}/app/endpoint/policy`} + href={getManagementUrl({ name: 'policyList' })} > { onConfirm={handleSaveConfirmation} /> )} - { - + + ); }); diff --git a/x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/config_form.tsx b/x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/config_form.tsx similarity index 100% rename from x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/config_form.tsx rename to x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/config_form.tsx diff --git a/x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/events/checkbox.tsx b/x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/events/checkbox.tsx similarity index 95% rename from x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/events/checkbox.tsx rename to x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/events/checkbox.tsx index fe062526c8d3c..e5f3b2c7e8b7e 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/events/checkbox.tsx +++ b/x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/events/checkbox.tsx @@ -11,7 +11,7 @@ import { useDispatch } from 'react-redux'; import { usePolicyDetailsSelector } from '../../policy_hooks'; import { policyConfig } from '../../../store/policy_details/selectors'; import { PolicyDetailsAction } from '../../../store/policy_details'; -import { UIPolicyConfig } from '../../../../../common/endpoint/types'; +import { UIPolicyConfig } from '../../../../../../../common/endpoint/types'; export const EventsCheckbox = React.memo(function ({ name, diff --git a/x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/events/index.tsx b/x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/events/index.tsx similarity index 100% rename from x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/events/index.tsx rename to x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/events/index.tsx diff --git a/x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/events/linux.tsx b/x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/events/linux.tsx similarity index 97% rename from x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/events/linux.tsx rename to x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/events/linux.tsx index ff7296ad5a44e..a4f5bb83b6ef3 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/events/linux.tsx +++ b/x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/events/linux.tsx @@ -14,7 +14,7 @@ import { usePolicyDetailsSelector } from '../../policy_hooks'; import { selectedLinuxEvents, totalLinuxEvents } from '../../../store/policy_details/selectors'; import { ConfigForm } from '../config_form'; import { getIn, setIn } from '../../../models/policy_details_config'; -import { UIPolicyConfig } from '../../../../../common/endpoint/types'; +import { UIPolicyConfig } from '../../../../../../../common/endpoint/types'; export const LinuxEvents = React.memo(() => { const selected = usePolicyDetailsSelector(selectedLinuxEvents); diff --git a/x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/events/mac.tsx b/x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/events/mac.tsx similarity index 97% rename from x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/events/mac.tsx rename to x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/events/mac.tsx index 1c6d96e555cef..af28a4803518c 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/events/mac.tsx +++ b/x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/events/mac.tsx @@ -14,7 +14,7 @@ import { usePolicyDetailsSelector } from '../../policy_hooks'; import { selectedMacEvents, totalMacEvents } from '../../../store/policy_details/selectors'; import { ConfigForm } from '../config_form'; import { getIn, setIn } from '../../../models/policy_details_config'; -import { UIPolicyConfig } from '../../../../../common/endpoint/types'; +import { UIPolicyConfig } from '../../../../../../../common/endpoint/types'; export const MacEvents = React.memo(() => { const selected = usePolicyDetailsSelector(selectedMacEvents); diff --git a/x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/events/windows.tsx b/x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/events/windows.tsx similarity index 98% rename from x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/events/windows.tsx rename to x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/events/windows.tsx index 8add5bed23a29..feddf78cd9c5f 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/events/windows.tsx +++ b/x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/events/windows.tsx @@ -14,7 +14,7 @@ import { usePolicyDetailsSelector } from '../../policy_hooks'; import { selectedWindowsEvents, totalWindowsEvents } from '../../../store/policy_details/selectors'; import { ConfigForm } from '../config_form'; import { setIn, getIn } from '../../../models/policy_details_config'; -import { UIPolicyConfig, Immutable } from '../../../../../common/endpoint/types'; +import { UIPolicyConfig, Immutable } from '../../../../../../../common/endpoint/types'; export const WindowsEvents = React.memo(() => { const selected = usePolicyDetailsSelector(selectedWindowsEvents); diff --git a/x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/protections/malware.tsx b/x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/protections/malware.tsx similarity index 98% rename from x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/protections/malware.tsx rename to x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/protections/malware.tsx index 69c0faf6e800e..e60713ca32d5b 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/view/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/siem/public/management/pages/policy/view/policy_forms/protections/malware.tsx @@ -11,7 +11,7 @@ import { EuiRadio, EuiSwitch, EuiTitle, EuiSpacer, htmlIdGenerator } from '@elas import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Immutable, ProtectionModes } from '../../../../../common/endpoint/types'; +import { Immutable, ProtectionModes } from '../../../../../../../common/endpoint/types'; import { OS, MalwareProtectionOSes } from '../../../types'; import { ConfigForm } from '../config_form'; import { policyConfig } from '../../../store/policy_details/selectors'; diff --git a/x-pack/plugins/siem/public/management/pages/policy/view/policy_hooks.ts b/x-pack/plugins/siem/public/management/pages/policy/view/policy_hooks.ts new file mode 100644 index 0000000000000..97436064eebe2 --- /dev/null +++ b/x-pack/plugins/siem/public/management/pages/policy/view/policy_hooks.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useSelector } from 'react-redux'; +import { PolicyListState, PolicyDetailsState } from '../types'; +import { State } from '../../../../common/store'; +import { + MANAGEMENT_STORE_GLOBAL_NAMESPACE, + MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, + MANAGEMENT_STORE_POLICY_LIST_NAMESPACE, +} from '../../../common/constants'; + +/** + * Narrows global state down to the PolicyListState before calling the provided Policy List Selector + * @param selector + */ +export function usePolicyListSelector(selector: (state: PolicyListState) => TSelected) { + return useSelector((state: State) => { + return selector( + state[MANAGEMENT_STORE_GLOBAL_NAMESPACE][ + MANAGEMENT_STORE_POLICY_LIST_NAMESPACE + ] as PolicyListState + ); + }); +} + +/** + * Narrows global state down to the PolicyDetailsState before calling the provided Policy Details Selector + * @param selector + */ +export function usePolicyDetailsSelector( + selector: (state: PolicyDetailsState) => TSelected +) { + return useSelector((state: State) => + selector( + state[MANAGEMENT_STORE_GLOBAL_NAMESPACE][ + MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE + ] as PolicyDetailsState + ) + ); +} diff --git a/x-pack/plugins/siem/public/endpoint_policy/view/policy_list.tsx b/x-pack/plugins/siem/public/management/pages/policy/view/policy_list.tsx similarity index 84% rename from x-pack/plugins/siem/public/endpoint_policy/view/policy_list.tsx rename to x-pack/plugins/siem/public/management/pages/policy/view/policy_list.tsx index a9aea57239ed1..3a8004aa2ec6d 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/view/policy_list.tsx +++ b/x-pack/plugins/siem/public/management/pages/policy/view/policy_list.tsx @@ -20,11 +20,13 @@ import { } from '../store/policy_list/selectors'; import { usePolicyListSelector } from './policy_hooks'; import { PolicyListAction } from '../store/policy_list'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { Immutable, PolicyData } from '../../../common/endpoint/types'; -import { useNavigateByRouterEventHandler } from '../../common/hooks/endpoint/use_navigate_by_router_event_handler'; -import { PageView } from '../../common/components/endpoint/page_view'; -import { LinkToApp } from '../../common/components/endpoint/link_to_app'; +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { Immutable, PolicyData } from '../../../../../common/endpoint/types'; +import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; +import { LinkToApp } from '../../../../common/components/endpoint/link_to_app'; +import { ManagementPageView } from '../../../components/management_page_view'; +import { SpyRoute } from '../../../../common/utils/route/spy_routes'; +import { getManagementUrl } from '../../../common/routing'; interface TableChangeCallbackArguments { page: { index: number; size: number }; @@ -93,14 +95,13 @@ export const PolicyList = React.memo(() => { }), // eslint-disable-next-line react/display-name render: (value: string, item: Immutable) => { - const routeUri = `/policy/${item.id}`; - return ( - - ); + const routePath = getManagementUrl({ + name: 'policyDetails', + policyId: item.id, + excludePrefix: true, + }); + const routeUrl = getManagementUrl({ name: 'policyDetails', policyId: item.id }); + return ; }, truncateText: true, }, @@ -150,7 +151,7 @@ export const PolicyList = React.memo(() => { ); return ( - { onChange={handleTableChange} data-test-subj="policyTable" /> - + + ); }); diff --git a/x-pack/plugins/siem/public/endpoint_policy/view/vertical_divider.ts b/x-pack/plugins/siem/public/management/pages/policy/view/vertical_divider.ts similarity index 92% rename from x-pack/plugins/siem/public/endpoint_policy/view/vertical_divider.ts rename to x-pack/plugins/siem/public/management/pages/policy/view/vertical_divider.ts index dd74980add7e0..6a3aecb4a6503 100644 --- a/x-pack/plugins/siem/public/endpoint_policy/view/vertical_divider.ts +++ b/x-pack/plugins/siem/public/management/pages/policy/view/vertical_divider.ts @@ -5,7 +5,7 @@ */ import styled from 'styled-components'; -import { EuiTheme } from '../../../../../legacy/common/eui_styled_components'; +import { EuiTheme } from '../../../../../../../legacy/common/eui_styled_components'; type SpacingOptions = keyof EuiTheme['eui']['spacerSizes']; diff --git a/x-pack/plugins/siem/public/management/routes.tsx b/x-pack/plugins/siem/public/management/routes.tsx new file mode 100644 index 0000000000000..fbcea37c76962 --- /dev/null +++ b/x-pack/plugins/siem/public/management/routes.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { Route } from 'react-router-dom'; +import { ManagementContainer } from './pages'; +import { MANAGEMENT_ROUTING_ROOT_PATH } from './common/constants'; + +/** + * Returns the React Router Routes for the management area + */ +export const getManagementRoutes = () => [ + // Mounts the Management interface on `/management` + , +]; diff --git a/x-pack/plugins/siem/public/management/store/index.ts b/x-pack/plugins/siem/public/management/store/index.ts new file mode 100644 index 0000000000000..50049f9828082 --- /dev/null +++ b/x-pack/plugins/siem/public/management/store/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { managementReducer, getManagementInitialState } from './reducer'; +export { managementMiddlewareFactory } from './middleware'; diff --git a/x-pack/plugins/siem/public/management/store/middleware.ts b/x-pack/plugins/siem/public/management/store/middleware.ts new file mode 100644 index 0000000000000..f73736e04a5b7 --- /dev/null +++ b/x-pack/plugins/siem/public/management/store/middleware.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ImmutableMultipleMiddlewareFactory, substateMiddlewareFactory } from '../../common/store'; +import { policyListMiddlewareFactory } from '../pages/policy/store/policy_list'; +import { policyDetailsMiddlewareFactory } from '../pages/policy/store/policy_details'; +import { + MANAGEMENT_STORE_GLOBAL_NAMESPACE, + MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, + MANAGEMENT_STORE_POLICY_LIST_NAMESPACE, +} from '../common/constants'; + +// @ts-ignore +export const managementMiddlewareFactory: ImmutableMultipleMiddlewareFactory = ( + coreStart, + depsStart +) => { + return [ + substateMiddlewareFactory( + (globalState) => + globalState[MANAGEMENT_STORE_GLOBAL_NAMESPACE][MANAGEMENT_STORE_POLICY_LIST_NAMESPACE], + policyListMiddlewareFactory(coreStart, depsStart) + ), + substateMiddlewareFactory( + (globalState) => + globalState[MANAGEMENT_STORE_GLOBAL_NAMESPACE][MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE], + policyDetailsMiddlewareFactory(coreStart, depsStart) + ), + ]; +}; diff --git a/x-pack/plugins/siem/public/management/store/reducer.ts b/x-pack/plugins/siem/public/management/store/reducer.ts new file mode 100644 index 0000000000000..ba7927684ad3d --- /dev/null +++ b/x-pack/plugins/siem/public/management/store/reducer.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { combineReducers as reduxCombineReducers } from 'redux'; +import { + initialPolicyDetailsState, + policyDetailsReducer, +} from '../pages/policy/store/policy_details/reducer'; +import { + initialPolicyListState, + policyListReducer, +} from '../pages/policy/store/policy_list/reducer'; +import { + MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, + MANAGEMENT_STORE_POLICY_LIST_NAMESPACE, +} from '../common/constants'; +import { ImmutableCombineReducers } from '../../common/store'; +import { AppAction } from '../../common/store/actions'; +import { ManagementState } from './types'; + +// Change the type of `combinerReducers` locally +const combineReducers: ImmutableCombineReducers = reduxCombineReducers; + +/** + * Returns the initial state of the store for the SIEM Management section + */ +export const getManagementInitialState = (): ManagementState => { + return { + [MANAGEMENT_STORE_POLICY_LIST_NAMESPACE]: initialPolicyListState(), + [MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]: initialPolicyDetailsState(), + }; +}; + +/** + * Redux store reducer for the SIEM Management section + */ +export const managementReducer = combineReducers({ + // @ts-ignore + [MANAGEMENT_STORE_POLICY_LIST_NAMESPACE]: policyListReducer, + // @ts-ignore + [MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]: policyDetailsReducer, +}); diff --git a/x-pack/plugins/siem/public/management/store/types.ts b/x-pack/plugins/siem/public/management/store/types.ts new file mode 100644 index 0000000000000..884724982fa8f --- /dev/null +++ b/x-pack/plugins/siem/public/management/store/types.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Immutable } from '../../../common/endpoint/types'; +import { PolicyDetailsState, PolicyListState } from '../pages/policy/types'; +import { ImmutableReducer } from '../../common/store'; +import { AppAction } from '../../common/store/actions'; + +/** + * Redux store state for the Management section + */ +export interface ManagementState { + policyDetails: Immutable; + policyList: Immutable; +} + +export interface ManagementPluginState { + management: ManagementState; +} + +export interface ManagementPluginReducer { + management: ImmutableReducer; +} diff --git a/x-pack/plugins/siem/public/management/types.ts b/x-pack/plugins/siem/public/management/types.ts new file mode 100644 index 0000000000000..5ee16bcd434e3 --- /dev/null +++ b/x-pack/plugins/siem/public/management/types.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SiemPageName } from '../app/types'; + +/** + * The type for the management store global namespace. Used mostly internally to reference + * the type while defining more complex interfaces/types + */ +export type ManagementStoreGlobalNamespace = 'management'; + +/** + * The management list of sub-tabs. Changes to these will impact the Router routes. + */ +export enum ManagementSubTab { + endpoints = 'endpoints', + policies = 'policy', +} + +/** + * The URL route params for the Management Policy List section + */ +export interface ManagementRoutePolicyListParams { + pageName: SiemPageName.management; + tabName: ManagementSubTab.policies; +} + +/** + * The URL route params for the Management Policy Details section + */ +export interface ManagementRoutePolicyDetailsParams extends ManagementRoutePolicyListParams { + policyId: string; +} diff --git a/x-pack/plugins/siem/public/plugin.tsx b/x-pack/plugins/siem/public/plugin.tsx index 9bea776220720..4b8fc078fc016 100644 --- a/x-pack/plugins/siem/public/plugin.tsx +++ b/x-pack/plugins/siem/public/plugin.tsx @@ -64,13 +64,8 @@ export class Plugin implements IPlugin { test('Build KQL query with two data provider', () => { const dataProviders = mockDataProviders.slice(0, 2); const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); - expect(cleanUpKqlQuery(kqlQuery)).toEqual('(name : "Provider 1") or (name : "Provider 2" )'); + expect(cleanUpKqlQuery(kqlQuery)).toEqual('(name : "Provider 1" ) or (name : "Provider 2" )'); }); test('Build KQL query with one data provider and one and', () => { @@ -113,6 +113,23 @@ describe('Build KQL Query', () => { '(name : "Provider 1" and name : "Provider 3" and name : "Provider 4") or (name : "Provider 2" and name : "Provider 5")' ); }); + + test('Build KQL query with all data provider', () => { + const kqlQuery = buildGlobalQuery(mockDataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual( + '(name : "Provider 1" ) or (name : "Provider 2" ) or (name : "Provider 3" ) or (name : "Provider 4" ) or (name : "Provider 5" ) or (name : "Provider 6" ) or (name : "Provider 7" ) or (name : "Provider 8" ) or (name : "Provider 9" ) or (name : "Provider 10" )' + ); + }); + + test('Build complex KQL query with and and or', () => { + const dataProviders = cloneDeep(mockDataProviders); + dataProviders[0].and = mockDataProviders.slice(2, 4); + dataProviders[1].and = mockDataProviders.slice(4, 5); + const kqlQuery = buildGlobalQuery(dataProviders, mockBrowserFields); + expect(cleanUpKqlQuery(kqlQuery)).toEqual( + '(name : "Provider 1" and name : "Provider 3" and name : "Provider 4") or (name : "Provider 2" and name : "Provider 5") or (name : "Provider 3" ) or (name : "Provider 4" ) or (name : "Provider 5" ) or (name : "Provider 6" ) or (name : "Provider 7" ) or (name : "Provider 8" ) or (name : "Provider 9" ) or (name : "Provider 10" )' + ); + }); }); describe('Combined Queries', () => { diff --git a/x-pack/plugins/siem/public/timelines/components/timeline/helpers.tsx b/x-pack/plugins/siem/public/timelines/components/timeline/helpers.tsx index 776ff114734d9..da74d22575f85 100644 --- a/x-pack/plugins/siem/public/timelines/components/timeline/helpers.tsx +++ b/x-pack/plugins/siem/public/timelines/components/timeline/helpers.tsx @@ -79,9 +79,9 @@ const buildQueryForAndProvider = ( export const buildGlobalQuery = (dataProviders: DataProvider[], browserFields: BrowserFields) => dataProviders .reduce((query, dataProvider: DataProvider, i) => { - const prepend = (q: string) => `${q !== '' ? `(${q}) or ` : ''}`; - const openParen = i > 0 ? '(' : ''; - const closeParen = i > 0 ? ')' : ''; + const prepend = (q: string) => `${q !== '' ? `${q} or ` : ''}`; + const openParen = i >= 0 && dataProviders.length > 1 ? '(' : ''; + const closeParen = i >= 0 && dataProviders.length > 1 ? ')' : ''; return dataProvider.enabled ? `${prepend(query)}${openParen}${buildQueryMatch(dataProvider, browserFields)} ${ diff --git a/x-pack/plugins/siem/server/lib/detection_engine/README.md b/x-pack/plugins/siem/server/lib/detection_engine/README.md index 610e82fd5f6ee..695165e1990a9 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/README.md +++ b/x-pack/plugins/siem/server/lib/detection_engine/README.md @@ -165,3 +165,12 @@ go about doing so. `./signals/set_status_with_id.sh open` will update the status of the sample signal to open `./signals/set_status_with_query.sh closed` will update the status of the signals in the result of the query to closed. `./signals/set_status_with_query.sh open` will update the status of the signals in the result of the query to open. + +### Large List Exceptions + +To test out the functionality of large lists with rules, the user will need to import a list and post a rule with a reference to that exception list. The following outlines an example using the sample json rule provided in the repo. + +* First, set the appropriate env var in order to enable exceptions features`export ELASTIC_XPACK_SIEM_LISTS_FEATURE=true` and `export ELASTIC_XPACK_SIEM_EXCEPTIONS_LISTS=true` and start kibana +* Second, import a list of ips from a file called `ci-badguys.txt`. The command should look like this: +`cd $HOME/kibana/x-pack/plugins/lists/server/scripts && ./import_list_items_by_filename.sh ip ~/ci-badguys.txt` +* Then, from the detection engine scripts folder (`cd kibana/x-pack/plugins/siem/server/lib/detection_engine/scripts`) run `./post_rule.sh rules/queries/lists/query_with_list_plugin.json` diff --git a/x-pack/plugins/siem/server/lib/detection_engine/scripts/rules/queries/lists/query_with_list_plugin.json b/x-pack/plugins/siem/server/lib/detection_engine/scripts/rules/queries/lists/query_with_list_plugin.json new file mode 100644 index 0000000000000..fa6fe6ac71117 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/scripts/rules/queries/lists/query_with_list_plugin.json @@ -0,0 +1,24 @@ +{ + "name": "Query with a list", + "description": "Query with a list only generate signals if source.ip is not in list", + "rule_id": "query-with-list", + "risk_score": 2, + "severity": "high", + "type": "query", + "query": "host.name: *", + "interval": "30s", + "language": "kuery", + "exceptions_list": [ + { + "field": "source.ip", + "values_operator": "excluded", + "values_type": "list", + "values": [ + { + "id": "ci-badguys.txt", + "name": "ip" + } + ] + } + ] +} diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts index 251a1e6d118ff..2d75ba4f42d12 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -101,7 +101,10 @@ export const sampleDocNoSortIdNoVersion = (someUuid: string = sampleIdGuid): Sig }, }); -export const sampleDocWithSortId = (someUuid: string = sampleIdGuid): SignalSourceHit => ({ +export const sampleDocWithSortId = ( + someUuid: string = sampleIdGuid, + ip?: string +): SignalSourceHit => ({ _index: 'myFakeSignalIndex', _type: 'doc', _score: 100, @@ -110,6 +113,9 @@ export const sampleDocWithSortId = (someUuid: string = sampleIdGuid): SignalSour _source: { someKey: 'someValue', '@timestamp': '2020-04-20T21:27:45+0000', + source: { + ip: ip ?? '127.0.0.1', + }, }, sort: ['1234567891111'], }); @@ -313,7 +319,8 @@ export const sampleDocSearchResultsNoSortIdNoHits = ( export const repeatedSearchResultsWithSortId = ( total: number, pageSize: number, - guids: string[] + guids: string[], + ips?: string[] ) => ({ took: 10, timed_out: false, @@ -327,7 +334,7 @@ export const repeatedSearchResultsWithSortId = ( total, max_score: 100, hits: Array.from({ length: pageSize }).map((x, index) => ({ - ...sampleDocWithSortId(guids[index]), + ...sampleDocWithSortId(guids[index], ips ? ips[index] : '127.0.0.1'), })), }, }); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts index 9ac4d4087016a..5862e6c481431 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts @@ -86,5 +86,5 @@ export const bulkCreateMlSignals = async ( const anomalyResults = params.someResult; const ecsResults = transformAnomalyResultsToEcs(anomalyResults); - return singleBulkCreate({ ...params, someResult: ecsResults }); + return singleBulkCreate({ ...params, filteredEvents: ecsResults }); }; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/filter_events_with_list.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/filter_events_with_list.test.ts new file mode 100644 index 0000000000000..86efdb6603493 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/filter_events_with_list.test.ts @@ -0,0 +1,242 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; +import { filterEventsAgainstList } from './filter_events_with_list'; +import { mockLogger, repeatedSearchResultsWithSortId } from './__mocks__/es_results'; + +import { ListClient } from '../../../../../lists/server'; + +const someGuids = Array.from({ length: 13 }).map((x) => uuid.v4()); + +describe('filterEventsAgainstList', () => { + it('should respond with eventSearchResult if exceptionList is empty', async () => { + const res = await filterEventsAgainstList({ + logger: mockLogger, + listClient: ({ + getListItemByValues: async () => [], + } as unknown) as ListClient, + exceptionsList: undefined, + eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ + '1.1.1.1', + '2.2.2.2', + '3.3.3.3', + '7.7.7.7', + ]), + }); + expect(res.hits.hits.length).toEqual(4); + }); + + it('should throw an error if malformed exception list present', async () => { + let message = ''; + try { + await filterEventsAgainstList({ + logger: mockLogger, + listClient: ({ + getListItemByValues: async () => [], + } as unknown) as ListClient, + exceptionsList: [ + { + field: 'source.ip', + values_operator: 'excluded', + values_type: 'list', + values: undefined, + }, + ], + eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ + '1.1.1.1', + '2.2.2.2', + '3.3.3.3', + '7.7.7.7', + ]), + }); + } catch (exc) { + message = exc.message; + } + expect(message).toEqual( + 'Failed to query lists index. Reason: Malformed exception list provided' + ); + }); + + it('should throw an error if unsupported exception type', async () => { + let message = ''; + try { + await filterEventsAgainstList({ + logger: mockLogger, + listClient: ({ + getListItemByValues: async () => [], + } as unknown) as ListClient, + exceptionsList: [ + { + field: 'source.ip', + values_operator: 'excluded', + values_type: 'list', + values: [ + { + id: 'ci-badguys.txt', + name: 'unsupportedListPluginType', + }, + ], + }, + ], + eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ + '1.1.1.1', + '2.2.2.2', + '3.3.3.3', + '7.7.7.7', + ]), + }); + } catch (exc) { + message = exc.message; + } + expect(message).toEqual( + 'Failed to query lists index. Reason: Unsupported list type used, please use one of ip,keyword' + ); + }); + + describe('operator_type is includes', () => { + it('should respond with same list if no items match value list', async () => { + const res = await filterEventsAgainstList({ + logger: mockLogger, + listClient: ({ + getListItemByValues: async () => [], + } as unknown) as ListClient, + exceptionsList: [ + { + field: 'source.ip', + values_operator: 'included', + values_type: 'list', + values: [ + { + id: 'ci-badguys.txt', + name: 'ip', + }, + ], + }, + ], + eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3)), + }); + expect(res.hits.hits.length).toEqual(4); + }); + it('should respond with less items in the list if some values match', async () => { + let outerType = ''; + let outerListId = ''; + const res = await filterEventsAgainstList({ + logger: mockLogger, + listClient: ({ + getListItemByValues: async ({ + value, + type, + listId, + }: { + type: string; + listId: string; + value: string[]; + }) => { + outerType = type; + outerListId = listId; + return value.slice(0, 2).map((item) => ({ + value: item, + })); + }, + } as unknown) as ListClient, + exceptionsList: [ + { + field: 'source.ip', + values_operator: 'included', + values_type: 'list', + values: [ + { + id: 'ci-badguys.txt', + name: 'ip', + }, + ], + }, + ], + eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ + '1.1.1.1', + '2.2.2.2', + '3.3.3.3', + '7.7.7.7', + ]), + }); + expect(outerType).toEqual('ip'); + expect(outerListId).toEqual('ci-badguys.txt'); + expect(res.hits.hits.length).toEqual(2); + }); + }); + describe('operator type is excluded', () => { + it('should respond with empty list if no items match value list', async () => { + const res = await filterEventsAgainstList({ + logger: mockLogger, + listClient: ({ + getListItemByValues: async () => [], + } as unknown) as ListClient, + exceptionsList: [ + { + field: 'source.ip', + values_operator: 'excluded', + values_type: 'list', + values: [ + { + id: 'ci-badguys.txt', + name: 'ip', + }, + ], + }, + ], + eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3)), + }); + expect(res.hits.hits.length).toEqual(0); + }); + it('should respond with less items in the list if some values match', async () => { + let outerType = ''; + let outerListId = ''; + const res = await filterEventsAgainstList({ + logger: mockLogger, + listClient: ({ + getListItemByValues: async ({ + value, + type, + listId, + }: { + type: string; + listId: string; + value: string[]; + }) => { + outerType = type; + outerListId = listId; + return value.slice(0, 2).map((item) => ({ + value: item, + })); + }, + } as unknown) as ListClient, + exceptionsList: [ + { + field: 'source.ip', + values_operator: 'excluded', + values_type: 'list', + values: [ + { + id: 'ci-badguys.txt', + name: 'ip', + }, + ], + }, + ], + eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ + '1.1.1.1', + '2.2.2.2', + '3.3.3.3', + '7.7.7.7', + ]), + }); + expect(outerType).toEqual('ip'); + expect(outerListId).toEqual('ci-badguys.txt'); + expect(res.hits.hits.length).toEqual(2); + }); + }); +}); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/filter_events_with_list.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/filter_events_with_list.ts new file mode 100644 index 0000000000000..400bb5dda46e7 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/filter_events_with_list.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { get } from 'lodash/fp'; +import { Logger } from 'src/core/server'; + +import { type } from '../../../../../lists/common/schemas/common'; +import { ListClient } from '../../../../../lists/server'; +import { SignalSearchResponse, SearchTypes } from './types'; +import { RuleAlertParams } from '../types'; +import { List } from '../routes/schemas/types/lists_default_array'; + +interface FilterEventsAgainstList { + listClient: ListClient; + exceptionsList: RuleAlertParams['exceptions_list']; + logger: Logger; + eventSearchResult: SignalSearchResponse; +} + +export const filterEventsAgainstList = async ({ + listClient, + exceptionsList, + logger, + eventSearchResult, +}: FilterEventsAgainstList): Promise => { + try { + if (exceptionsList == null || exceptionsList.length === 0) { + return eventSearchResult; + } + + // narrow unioned type to be single + const isStringableType = (val: SearchTypes) => + ['string', 'number', 'boolean'].includes(typeof val); + // grab the signals with values found in the given exception lists. + const filteredHitsPromises = exceptionsList + .filter((exceptionItem: List) => exceptionItem.values_type === 'list') + .map(async (exceptionItem: List) => { + if (exceptionItem.values == null || exceptionItem.values.length === 0) { + throw new Error('Malformed exception list provided'); + } + if (!type.is(exceptionItem.values[0].name)) { + throw new Error( + `Unsupported list type used, please use one of ${Object.keys(type.keys).join()}` + ); + } + if (!exceptionItem.values[0].id) { + throw new Error(`Missing list id for exception on field ${exceptionItem.field}`); + } + // acquire the list values we are checking for. + const valuesOfGivenType = eventSearchResult.hits.hits.reduce((acc, searchResultItem) => { + const valueField = get(exceptionItem.field, searchResultItem._source); + if (valueField != null && isStringableType(valueField)) { + acc.add(valueField.toString()); + } + return acc; + }, new Set()); + + // matched will contain any list items that matched with the + // values passed in from the Set. + const matchedListItems = await listClient.getListItemByValues({ + listId: exceptionItem.values[0].id, + type: exceptionItem.values[0].name, + value: [...valuesOfGivenType], + }); + + // create a set of list values that were a hit - easier to work with + const matchedListItemsSet = new Set( + matchedListItems.map((item) => item.value) + ); + + // do a single search after with these values. + // painless script to do nested query in elasticsearch + // filter out the search results that match with the values found in the list. + const operator = exceptionItem.values_operator; + const filteredEvents = eventSearchResult.hits.hits.filter((item) => { + const eventItem = get(exceptionItem.field, item._source); + if (operator === 'included') { + if (eventItem != null) { + return !matchedListItemsSet.has(eventItem); + } + } else if (operator === 'excluded') { + if (eventItem != null) { + return matchedListItemsSet.has(eventItem); + } + } + return false; + }); + const diff = eventSearchResult.hits.hits.length - filteredEvents.length; + logger.debug(`Lists filtered out ${diff} events`); + return filteredEvents; + }); + + const filteredHits = await Promise.all(filteredHitsPromises); + const toReturn: SignalSearchResponse = { + took: eventSearchResult.took, + timed_out: eventSearchResult.timed_out, + _shards: eventSearchResult._shards, + hits: { + total: filteredHits.length, + max_score: eventSearchResult.hits.max_score, + hits: filteredHits.flat(), + }, + }; + + return toReturn; + } catch (exc) { + throw new Error(`Failed to query lists index. Reason: ${exc.message}`); + } +}; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 208f0e680722d..7479ab54af6e6 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -10,58 +10,28 @@ import { sampleRuleGuid, mockLogger, repeatedSearchResultsWithSortId, - sampleBulkCreateDuplicateResult, - sampleDocSearchResultsNoSortId, - sampleDocSearchResultsNoSortIdNoHits, } from './__mocks__/es_results'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; import { DEFAULT_SIGNALS_INDEX } from '../../../../common/constants'; import { alertsMock, AlertServicesMock } from '../../../../../alerting/server/mocks'; import uuid from 'uuid'; +import { ListClient } from '../../../../../lists/server'; +import { ListItemArraySchema } from '../../../../../lists/common/schemas'; describe('searchAfterAndBulkCreate', () => { let mockService: AlertServicesMock; let inputIndexPattern: string[] = []; + const someGuids = Array.from({ length: 13 }).map(() => uuid.v4()); beforeEach(() => { jest.clearAllMocks(); inputIndexPattern = ['auditbeat-*']; mockService = alertsMock.createAlertServices(); }); - test('if successful with empty search results', async () => { - const sampleParams = sampleRuleAlertParams(); - const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - someResult: sampleEmptyDocSearchResults(), - ruleParams: sampleParams, - services: mockService, - logger: mockLogger, - id: sampleRuleGuid, - inputIndexPattern, - signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, - pageSize: 1, - filter: undefined, - refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); - expect(mockService.callCluster).toHaveBeenCalledTimes(0); - expect(success).toEqual(true); - expect(createdSignalsCount).toEqual(0); - expect(lastLookBackDate).toBeNull(); - }); - - test('if successful iteration of while loop with maxDocs', async () => { + test('should return success with number of searches less than max signals', async () => { const sampleParams = sampleRuleAlertParams(30); - const someGuids = Array.from({ length: 13 }).map((x) => uuid.v4()); mockService.callCluster + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) .mockResolvedValueOnce({ took: 100, errors: false, @@ -76,7 +46,7 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(3, 1, someGuids.slice(0, 3))) + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(3, 6))) .mockResolvedValueOnce({ took: 100, errors: false, @@ -91,7 +61,22 @@ describe('searchAfterAndBulkCreate', () => { }, ], }) - .mockResolvedValueOnce(repeatedSearchResultsWithSortId(3, 1, someGuids.slice(3, 6))) + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(6, 9))) + .mockResolvedValueOnce({ + took: 100, + errors: false, + items: [ + { + fakeItemValue: 'fakeItemKey', + }, + { + create: { + status: 201, + }, + }, + ], + }) + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(9, 12))) .mockResolvedValueOnce({ took: 100, errors: false, @@ -107,8 +92,23 @@ describe('searchAfterAndBulkCreate', () => { ], }); const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - someResult: repeatedSearchResultsWithSortId(3, 1, someGuids.slice(6, 9)), ruleParams: sampleParams, + listClient: ({ + getListItemByValues: async () => [], + } as unknown) as ListClient, + exceptionsList: [ + { + field: 'source.ip', + values_operator: 'included', + values_type: 'list', + values: [ + { + id: 'ci-badguys.txt', + name: 'ip', + }, + ], + }, + ], services: mockService, logger: mockLogger, id: sampleRuleGuid, @@ -128,63 +128,63 @@ describe('searchAfterAndBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: 'no_actions', }); - expect(mockService.callCluster).toHaveBeenCalledTimes(5); expect(success).toEqual(true); - expect(createdSignalsCount).toEqual(3); + expect(mockService.callCluster).toHaveBeenCalledTimes(8); + expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); - test('if unsuccessful first bulk create', async () => { - const someGuids = Array.from({ length: 4 }).map((x) => uuid.v4()); - const sampleParams = sampleRuleAlertParams(10); - mockService.callCluster.mockResolvedValue(sampleBulkCreateDuplicateResult); + test('should return success when no search results are in the allowlist', async () => { + const sampleParams = sampleRuleAlertParams(30); + mockService.callCluster + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3))) + .mockResolvedValueOnce({ + took: 100, + errors: false, + items: [ + { + fakeItemValue: 'fakeItemKey', + }, + { + create: { + status: 201, + }, + }, + { + create: { + status: 201, + }, + }, + { + create: { + status: 201, + }, + }, + { + create: { + status: 201, + }, + }, + ], + }); const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - someResult: repeatedSearchResultsWithSortId(4, 1, someGuids), ruleParams: sampleParams, - services: mockService, - logger: mockLogger, - id: sampleRuleGuid, - inputIndexPattern, - signalsIndex: DEFAULT_SIGNALS_INDEX, - name: 'rule-name', - actions: [], - createdAt: '2020-01-28T15:58:34.810Z', - updatedAt: '2020-01-28T15:59:14.004Z', - createdBy: 'elastic', - updatedBy: 'elastic', - interval: '5m', - enabled: true, - pageSize: 1, - filter: undefined, - refresh: false, - tags: ['some fake tag 1', 'some fake tag 2'], - throttle: 'no_actions', - }); - expect(mockLogger.error).toHaveBeenCalled(); - expect(success).toEqual(false); - expect(createdSignalsCount).toEqual(1); - expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); - }); - - test('if unsuccessful iteration of searchAfterAndBulkCreate due to empty sort ids', async () => { - const sampleParams = sampleRuleAlertParams(); - mockService.callCluster.mockResolvedValueOnce({ - took: 100, - errors: false, - items: [ + listClient: ({ + getListItemByValues: async () => [], + } as unknown) as ListClient, + exceptionsList: [ { - fakeItemValue: 'fakeItemKey', - }, - { - create: { - status: 201, - }, + field: 'source.ip', + values_operator: 'included', + values_type: 'list', + values: [ + { + id: 'ci-badguys.txt', + name: 'ip', + }, + ], }, ], - }); - const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - someResult: sampleDocSearchResultsNoSortId(), - ruleParams: sampleParams, services: mockService, logger: mockLogger, id: sampleRuleGuid, @@ -204,31 +204,59 @@ describe('searchAfterAndBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: 'no_actions', }); - expect(mockLogger.error).toHaveBeenCalled(); - expect(success).toEqual(false); - expect(createdSignalsCount).toEqual(1); + expect(success).toEqual(true); + expect(mockService.callCluster).toHaveBeenCalledTimes(2); + expect(createdSignalsCount).toEqual(4); // should not create any signals because all events were in the allowlist expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); - test('if unsuccessful iteration of searchAfterAndBulkCreate due to empty sort ids and 0 total hits', async () => { - const sampleParams = sampleRuleAlertParams(); - mockService.callCluster.mockResolvedValueOnce({ - took: 100, - errors: false, - items: [ - { - fakeItemValue: 'fakeItemKey', - }, - { - create: { - status: 201, + test('should return success when no exceptions list provided', async () => { + const sampleParams = sampleRuleAlertParams(30); + mockService.callCluster + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3))) + .mockResolvedValueOnce({ + took: 100, + errors: false, + items: [ + { + fakeItemValue: 'fakeItemKey', }, - }, - ], - }); + { + create: { + status: 201, + }, + }, + { + create: { + status: 201, + }, + }, + { + create: { + status: 201, + }, + }, + { + create: { + status: 201, + }, + }, + ], + }); const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - someResult: sampleDocSearchResultsNoSortIdNoHits(), ruleParams: sampleParams, + listClient: ({ + getListItemByValues: async ({ + value, + }: { + type: string; + listId: string; + value: string[]; + }) => { + return value.map((item) => ({ value: item })); + }, + } as unknown) as ListClient, + exceptionsList: undefined, services: mockService, logger: mockLogger, id: sampleRuleGuid, @@ -249,31 +277,35 @@ describe('searchAfterAndBulkCreate', () => { throttle: 'no_actions', }); expect(success).toEqual(true); - expect(createdSignalsCount).toEqual(1); + expect(mockService.callCluster).toHaveBeenCalledTimes(2); + expect(createdSignalsCount).toEqual(4); // should not create any signals because all events were in the allowlist expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); - test('if successful iteration of while loop with maxDocs and search after returns results with no sort ids', async () => { + test('if unsuccessful first bulk create', async () => { const sampleParams = sampleRuleAlertParams(10); - const someGuids = Array.from({ length: 4 }).map((x) => uuid.v4()); mockService.callCluster - .mockResolvedValueOnce({ - took: 100, - errors: false, - items: [ - { - fakeItemValue: 'fakeItemKey', - }, - { - create: { - status: 201, - }, - }, - ], - }) - .mockResolvedValueOnce(sampleDocSearchResultsNoSortId()); + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) + .mockRejectedValue(new Error('bulk failed')); // Added this recently const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - someResult: repeatedSearchResultsWithSortId(4, 1, someGuids), + listClient: ({ + getListItemByValues: async () => { + return ([] as unknown) as ListItemArraySchema; + }, + } as unknown) as ListClient, + exceptionsList: [ + { + field: 'source.ip', + values_operator: 'included', + values_type: 'list', + values: [ + { + id: 'ci-badguys.txt', + name: 'ip', + }, + ], + }, + ], ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -294,32 +326,40 @@ describe('searchAfterAndBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: 'no_actions', }); - expect(success).toEqual(true); - expect(createdSignalsCount).toEqual(1); + expect(mockLogger.error).toHaveBeenCalled(); + expect(success).toEqual(false); + expect(createdSignalsCount).toEqual(0); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); - test('if successful iteration of while loop with maxDocs and search after returns empty results with no sort ids', async () => { - const sampleParams = sampleRuleAlertParams(10); - const someGuids = Array.from({ length: 4 }).map((x) => uuid.v4()); - mockService.callCluster - .mockResolvedValueOnce({ - took: 100, - errors: false, - items: [ - { - fakeItemValue: 'fakeItemKey', - }, - { - create: { - status: 201, - }, - }, - ], - }) - .mockResolvedValueOnce(sampleEmptyDocSearchResults()); + test('should return success with 0 total hits', async () => { + const sampleParams = sampleRuleAlertParams(); + mockService.callCluster.mockResolvedValueOnce(sampleEmptyDocSearchResults()); const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - someResult: repeatedSearchResultsWithSortId(4, 1, someGuids), + listClient: ({ + getListItemByValues: async ({ + value, + }: { + type: string; + listId: string; + value: string[]; + }) => { + return value.map((item) => ({ value: item })); + }, + } as unknown) as ListClient, + exceptionsList: [ + { + field: 'source.ip', + values_operator: 'included', + values_type: 'list', + values: [ + { + id: 'ci-badguys.txt', + name: 'ip', + }, + ], + }, + ], ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -341,13 +381,12 @@ describe('searchAfterAndBulkCreate', () => { throttle: 'no_actions', }); expect(success).toEqual(true); - expect(createdSignalsCount).toEqual(1); - expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); + expect(createdSignalsCount).toEqual(0); + expect(lastLookBackDate).toEqual(null); }); test('if returns false when singleSearchAfter throws an exception', async () => { const sampleParams = sampleRuleAlertParams(10); - const someGuids = Array.from({ length: 4 }).map((x) => uuid.v4()); mockService.callCluster .mockResolvedValueOnce({ took: 100, @@ -367,7 +406,30 @@ describe('searchAfterAndBulkCreate', () => { throw Error('Fake Error'); }); const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ - someResult: repeatedSearchResultsWithSortId(4, 1, someGuids), + listClient: ({ + getListItemByValues: async ({ + value, + }: { + type: string; + listId: string; + value: string[]; + }) => { + return value.map((item) => ({ value: item })); + }, + } as unknown) as ListClient, + exceptionsList: [ + { + field: 'source.ip', + values_operator: 'included', + values_type: 'list', + values: [ + { + id: 'ci-badguys.txt', + name: 'ip', + }, + ], + }, + ], ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -389,7 +451,7 @@ describe('searchAfterAndBulkCreate', () => { throttle: 'no_actions', }); expect(success).toEqual(false); - expect(createdSignalsCount).toEqual(1); - expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); + expect(createdSignalsCount).toEqual(0); // should not create signals if search threw error + expect(lastLookBackDate).toEqual(null); }); }); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts index acf3e9bfb055c..05cdccedbc2c1 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -4,18 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ListClient } from '../../../../../lists/server'; import { AlertServices } from '../../../../../alerting/server'; import { RuleAlertAction } from '../../../../common/detection_engine/types'; -import { RuleTypeParams, RefreshTypes } from '../types'; +import { RuleTypeParams, RefreshTypes, RuleAlertParams } from '../types'; import { Logger } from '../../../../../../../src/core/server'; import { singleSearchAfter } from './single_search_after'; import { singleBulkCreate } from './single_bulk_create'; import { SignalSearchResponse } from './types'; +import { filterEventsAgainstList } from './filter_events_with_list'; interface SearchAfterAndBulkCreateParams { - someResult: SignalSearchResponse; ruleParams: RuleTypeParams; services: AlertServices; + listClient: ListClient | undefined; // TODO: undefined is for temporary development, remove before merged + exceptionsList: RuleAlertParams['exceptions_list']; logger: Logger; id: string; inputIndexPattern: string[]; @@ -45,9 +48,10 @@ export interface SearchAfterAndBulkCreateReturnType { // search_after through documents and re-index using bulk endpoint. export const searchAfterAndBulkCreate = async ({ - someResult, ruleParams, + exceptionsList, services, + listClient, logger, id, inputIndexPattern, @@ -73,71 +77,31 @@ export const searchAfterAndBulkCreate = async ({ lastLookBackDate: null, createdSignalsCount: 0, }; - if (someResult.hits.hits.length === 0) { - toReturn.success = true; - return toReturn; - } - logger.debug('[+] starting bulk insertion'); - const { bulkCreateDuration, createdItemsCount } = await singleBulkCreate({ - someResult, - ruleParams, - services, - logger, - id, - signalsIndex, - actions, - name, - createdAt, - createdBy, - updatedAt, - updatedBy, - interval, - enabled, - refresh, - tags, - throttle, - }); + let sortId; // tells us where to start our next search_after query + let searchResultSize = 0; - if (createdItemsCount > 0) { - toReturn.createdSignalsCount = createdItemsCount; - toReturn.lastLookBackDate = - someResult.hits.hits.length > 0 - ? new Date(someResult.hits.hits[someResult.hits.hits.length - 1]?._source['@timestamp']) - : null; - } + /* + The purpose of `maxResults` is to ensure we do not perform + extra search_after's. This will be reset on each + iteration, although it really only matters for the first + iteration of the loop. + e.g. if maxSignals = 100 but our search result only yields + 27 documents, there is no point in performing another search + since we know there are no more events that match our rule, + and thus, no more signals we could possibly generate. + However, if maxSignals = 500 and our search yields a total + of 3050 results we don't want to make 3050 signals, + we only want 500. So maxResults will help us control how + many times we perform a search_after + */ + let maxResults = ruleParams.maxSignals; - if (bulkCreateDuration) { - toReturn.bulkCreateTimes.push(bulkCreateDuration); - } - const totalHits = - typeof someResult.hits.total === 'number' ? someResult.hits.total : someResult.hits.total.value; - // maxTotalHitsSize represents the total number of docs to - // query for, no matter the size of each individual page of search results. - // If the total number of hits for the overall search result is greater than - // maxSignals, default to requesting a total of maxSignals, otherwise use the - // totalHits in the response from the searchAfter query. - const maxTotalHitsSize = Math.min(totalHits, ruleParams.maxSignals); + // Get - // number of docs in the current search result - let hitsSize = someResult.hits.hits.length; - logger.debug(`first size: ${hitsSize}`); - let sortIds = someResult.hits.hits[0].sort; - if (sortIds == null && totalHits > 0) { - logger.error('sortIds was empty on first search but expected more'); - toReturn.success = false; - return toReturn; - } else if (sortIds == null && totalHits === 0) { - toReturn.success = true; - return toReturn; - } - let sortId; - if (sortIds != null) { - sortId = sortIds[0]; - } - while (hitsSize < maxTotalHitsSize && hitsSize !== 0) { + while (searchResultSize < maxResults) { try { - logger.debug(`sortIds: ${sortIds}`); + logger.debug(`sortIds: ${sortId}`); const { searchResult, searchDuration, @@ -152,25 +116,60 @@ export const searchAfterAndBulkCreate = async ({ pageSize, // maximum number of docs to receive per search result. }); toReturn.searchAfterTimes.push(searchDuration); + toReturn.lastLookBackDate = + searchResult.hits.hits.length > 0 + ? new Date( + searchResult.hits.hits[searchResult.hits.hits.length - 1]?._source['@timestamp'] + ) + : null; + const totalHits = + typeof searchResult.hits.total === 'number' + ? searchResult.hits.total + : searchResult.hits.total.value; + logger.debug(`totalHits: ${totalHits}`); + + // re-calculate maxResults to ensure if our search results + // are less than max signals, we are not attempting to + // create more signals than there are total search results. + maxResults = Math.min(totalHits, ruleParams.maxSignals); + searchResultSize += searchResult.hits.hits.length; if (searchResult.hits.hits.length === 0) { toReturn.success = true; return toReturn; } - hitsSize += searchResult.hits.hits.length; - logger.debug(`size adjusted: ${hitsSize}`); - sortIds = searchResult.hits.hits[0].sort; - if (sortIds == null) { - logger.debug('sortIds was empty on search'); + + // filter out the search results that match with the values found in the list. + // the resulting set are valid signals that are not on the allowlist. + const filteredEvents = + listClient != null + ? await filterEventsAgainstList({ + listClient, + exceptionsList, + logger, + eventSearchResult: searchResult, + }) + : searchResult; + + if (filteredEvents.hits.hits.length === 0) { + // everything in the events were allowed, so no need to generate signals toReturn.success = true; - return toReturn; // no more search results + return toReturn; + } + + // cap max signals created to be no more than maxSignals + if (toReturn.createdSignalsCount + filteredEvents.hits.hits.length > ruleParams.maxSignals) { + const tempSignalsToIndex = filteredEvents.hits.hits.slice( + 0, + ruleParams.maxSignals - toReturn.createdSignalsCount + ); + filteredEvents.hits.hits = tempSignalsToIndex; } - sortId = sortIds[0]; logger.debug('next bulk index'); const { bulkCreateDuration: bulkDuration, createdItemsCount: createdCount, } = await singleBulkCreate({ - someResult: searchResult, + filteredEvents, ruleParams, services, logger, @@ -189,17 +188,25 @@ export const searchAfterAndBulkCreate = async ({ throttle, }); logger.debug('finished next bulk index'); + logger.debug(`created ${createdCount} signals`); toReturn.createdSignalsCount += createdCount; if (bulkDuration) { toReturn.bulkCreateTimes.push(bulkDuration); } + + if (filteredEvents.hits.hits[0].sort == null) { + logger.debug('sortIds was empty on search'); + toReturn.success = true; + return toReturn; // no more search results + } + sortId = filteredEvents.hits.hits[0].sort[0]; } catch (exc) { logger.error(`[-] search_after and bulk threw an error ${exc}`); toReturn.success = false; return toReturn; } } - logger.debug(`[+] completed bulk index of ${maxTotalHitsSize}`); + logger.debug(`[+] completed bulk index of ${toReturn.createdSignalsCount}`); toReturn.success = true; return toReturn; }; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index 0c7f0839f8daf..ea7255b8a925a 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -17,6 +17,7 @@ import { scheduleNotificationActions } from '../notifications/schedule_notificat import { RuleAlertType } from '../rules/types'; import { findMlSignals } from './find_ml_signals'; import { bulkCreateMlSignals } from './bulk_create_ml_signals'; +import { ListPluginSetup } from '../../../../../lists/server/types'; jest.mock('./rule_status_saved_objects_client'); jest.mock('./rule_status_service'); @@ -68,6 +69,11 @@ describe('rules_notification_alert_type', () => { modulesProvider: jest.fn(), resultsServiceProvider: jest.fn(), }; + const listMock = { + getListClient: () => ({ + getListItemByValues: () => [], + }), + }; let payload: jest.Mocked; let alert: ReturnType; let logger: ReturnType; @@ -110,6 +116,7 @@ describe('rules_notification_alert_type', () => { logger, version, ml: mlMock, + lists: (listMock as unknown) as ListPluginSetup, }); }); @@ -199,6 +206,7 @@ describe('rules_notification_alert_type', () => { logger, version, ml: undefined, + lists: undefined, }); await alert.executor(payload); expect(logger.error).toHaveBeenCalled(); @@ -358,7 +366,7 @@ describe('rules_notification_alert_type', () => { }); it('when error was thrown', async () => { - (searchAfterAndBulkCreate as jest.Mock).mockResolvedValue({}); + (searchAfterAndBulkCreate as jest.Mock).mockRejectedValue({}); await alert.executor(payload); expect(logger.error).toHaveBeenCalled(); expect(logger.error.mock.calls[0][0]).toContain('An error occurred during rule execution'); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 8cef4c8ea0e6a..6885b4c814679 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -4,14 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { performance } from 'perf_hooks'; +/* eslint-disable complexity */ + import { Logger, KibanaRequest } from 'src/core/server'; import { SIGNALS_ID, DEFAULT_SEARCH_AFTER_PAGE_SIZE } from '../../../../common/constants'; import { isJobStarted, isMlRule } from '../../../../common/machine_learning/helpers'; import { SetupPlugins } from '../../../plugin'; -import { buildEventsSearchQuery } from './build_events_query'; +import { ListClient } from '../../../../../lists/server'; + import { getInputIndex } from './get_input_output_index'; import { searchAfterAndBulkCreate, @@ -19,7 +21,7 @@ import { } from './search_after_bulk_create'; import { getFilter } from './get_filter'; import { SignalRuleAlertTypeDefinition, RuleAlertAttributes } from './types'; -import { getGapBetweenRuns, makeFloatString, parseScheduleDates } from './utils'; +import { getGapBetweenRuns, parseScheduleDates } from './utils'; import { signalParamsSchema } from './signal_params_schema'; import { siemRuleActionGroups } from './siem_rule_action_groups'; import { findMlSignals } from './find_ml_signals'; @@ -32,15 +34,18 @@ import { ruleStatusServiceFactory } from './rule_status_service'; import { buildRuleMessageFactory } from './rule_messages'; import { ruleStatusSavedObjectsClientFactory } from './rule_status_saved_objects_client'; import { getNotificationResultsLink } from '../notifications/utils'; +import { hasListsFeature } from '../feature_flags'; export const signalRulesAlertType = ({ logger, version, ml, + lists, }: { logger: Logger; version: string; ml: SetupPlugins['ml']; + lists: SetupPlugins['lists'] | undefined; }): SignalRuleAlertTypeDefinition => { return { id: SIGNALS_ID, @@ -51,7 +56,14 @@ export const signalRulesAlertType = ({ params: signalParamsSchema(), }, producer: 'siem', - async executor({ previousStartedAt, alertId, services, params }) { + async executor({ + previousStartedAt, + alertId, + services, + params, + spaceId, + updatedBy: updatedByUser, + }) { const { anomalyThreshold, from, @@ -67,7 +79,7 @@ export const signalRulesAlertType = ({ query, to, type, - exceptions_list, + exceptions_list: exceptionsList, } = params; const searchAfterSize = Math.min(maxSignals, DEFAULT_SEARCH_AFTER_PAGE_SIZE); let hasError: boolean = false; @@ -123,7 +135,6 @@ export const signalRulesAlertType = ({ hasError = true; await ruleStatusService.error(gapMessage, { gap: gapString }); } - try { if (isMlRule(type)) { if (ml == null) { @@ -199,6 +210,18 @@ export const signalRulesAlertType = ({ result.bulkCreateTimes.push(bulkCreateDuration); } } else { + let listClient: ListClient | undefined; + if (hasListsFeature()) { + if (lists == null) { + throw new Error('lists plugin unavailable during rule execution'); + } + listClient = await lists.getListClient( + services.callCluster, + spaceId, + updatedByUser ?? 'elastic' + ); + } + const inputIndex = await getInputIndex(services, version, index); const esFilter = await getFilter({ type, @@ -208,34 +231,13 @@ export const signalRulesAlertType = ({ savedId, services, index: inputIndex, - lists: exceptions_list, + // temporary filter out list type + lists: exceptionsList?.filter((item) => item.values_type !== 'list'), }); - const noReIndex = buildEventsSearchQuery({ - index: inputIndex, - from, - to, - filter: esFilter, - size: searchAfterSize, - searchAfterSortId: undefined, - }); - - logger.debug(buildRuleMessage('[+] Initial search call')); - const start = performance.now(); - const noReIndexResult = await services.callCluster('search', noReIndex); - const end = performance.now(); - - const signalCount = noReIndexResult.hits.total.value; - if (signalCount !== 0) { - logger.info( - buildRuleMessage( - `Found ${signalCount} signals from the indexes of "[${inputIndex.join(', ')}]"` - ) - ); - } - result = await searchAfterAndBulkCreate({ - someResult: noReIndexResult, + listClient, + exceptionsList, ruleParams: params, services, logger, @@ -256,7 +258,6 @@ export const signalRulesAlertType = ({ tags, throttle, }); - result.searchAfterTimes.push(makeFloatString(end - start)); } if (result.success) { @@ -293,6 +294,11 @@ export const signalRulesAlertType = ({ } logger.debug(buildRuleMessage('[+] Signal Rule execution completed.')); + logger.debug( + buildRuleMessage( + `[+] Finished indexing ${result.createdSignalsCount} signals into ${outputIndex}` + ) + ); if (!hasError) { await ruleStatusService.success('succeeded', { bulkCreateTimeDurations: result.bulkCreateTimes, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts index 6f3cc6e708fce..265f986533134 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts @@ -141,7 +141,7 @@ describe('singleBulkCreate', () => { ], }); const { success, createdItemsCount } = await singleBulkCreate({ - someResult: sampleDocSearchResultsNoSortId(), + filteredEvents: sampleDocSearchResultsNoSortId(), ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -175,7 +175,7 @@ describe('singleBulkCreate', () => { ], }); const { success, createdItemsCount } = await singleBulkCreate({ - someResult: sampleDocSearchResultsNoSortIdNoVersion(), + filteredEvents: sampleDocSearchResultsNoSortIdNoVersion(), ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -201,7 +201,7 @@ describe('singleBulkCreate', () => { const sampleParams = sampleRuleAlertParams(); mockService.callCluster.mockResolvedValue(false); const { success, createdItemsCount } = await singleBulkCreate({ - someResult: sampleEmptyDocSearchResults(), + filteredEvents: sampleEmptyDocSearchResults(), ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -228,7 +228,7 @@ describe('singleBulkCreate', () => { const sampleSearchResult = sampleDocSearchResultsNoSortId; mockService.callCluster.mockResolvedValue(sampleBulkCreateDuplicateResult); const { success, createdItemsCount } = await singleBulkCreate({ - someResult: sampleSearchResult(), + filteredEvents: sampleSearchResult(), ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -257,7 +257,7 @@ describe('singleBulkCreate', () => { const sampleSearchResult = sampleDocSearchResultsNoSortId; mockService.callCluster.mockResolvedValue(sampleBulkCreateErrorResult); const { success, createdItemsCount } = await singleBulkCreate({ - someResult: sampleSearchResult(), + filteredEvents: sampleSearchResult(), ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -352,7 +352,7 @@ describe('singleBulkCreate', () => { const sampleParams = sampleRuleAlertParams(); mockService.callCluster.mockResolvedValue(sampleBulkCreateDuplicateResult); const { success, createdItemsCount } = await singleBulkCreate({ - someResult: sampleDocSearchResultsNoSortId(), + filteredEvents: sampleDocSearchResultsNoSortId(), ruleParams: sampleParams, services: mockService, logger: mockLogger, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts index c162c8855b091..39aecde470e0b 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts @@ -15,7 +15,7 @@ import { buildBulkBody } from './build_bulk_body'; import { Logger } from '../../../../../../../src/core/server'; interface SingleBulkCreateParams { - someResult: SignalSearchResponse; + filteredEvents: SignalSearchResponse; ruleParams: RuleTypeParams; services: AlertServices; logger: Logger; @@ -64,7 +64,7 @@ export interface SingleBulkCreateResponse { // Bulk Index documents. export const singleBulkCreate = async ({ - someResult, + filteredEvents, ruleParams, services, logger, @@ -82,8 +82,8 @@ export const singleBulkCreate = async ({ tags, throttle, }: SingleBulkCreateParams): Promise => { - someResult.hits.hits = filterDuplicateRules(id, someResult); - if (someResult.hits.hits.length === 0) { + filteredEvents.hits.hits = filterDuplicateRules(id, filteredEvents); + if (filteredEvents.hits.hits.length === 0) { return { success: true, createdItemsCount: 0 }; } // index documents after creating an ID based on the @@ -95,7 +95,7 @@ export const singleBulkCreate = async ({ // while preventing duplicates from being added to the // signals index if rules are re-run over the same time // span. Also allow for versioning. - const bulkBody = someResult.hits.hits.flatMap((doc) => [ + const bulkBody = filteredEvents.hits.hits.flatMap((doc) => [ { create: { _index: signalsIndex, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts index 580080966457e..2aa42234460d8 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts @@ -22,18 +22,17 @@ describe('singleSearchAfter', () => { test('if singleSearchAfter works without a given sort id', async () => { let searchAfterSortId; mockService.callCluster.mockResolvedValue(sampleDocSearchResultsNoSortId); - await expect( - singleSearchAfter({ - searchAfterSortId, - index: [], - from: 'now-360s', - to: 'now', - services: mockService, - logger: mockLogger, - pageSize: 1, - filter: undefined, - }) - ).rejects.toThrow('Attempted to search after with empty sort id'); + const { searchResult } = await singleSearchAfter({ + searchAfterSortId, + index: [], + from: 'now-360s', + to: 'now', + services: mockService, + logger: mockLogger, + pageSize: 1, + filter: undefined, + }); + expect(searchResult).toEqual(sampleDocSearchResultsNoSortId); }); test('if singleSearchAfter works with a given sort id', async () => { const searchAfterSortId = '1234567891111'; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/single_search_after.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/single_search_after.ts index 8071c18713c19..a7086a4fb229e 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/signals/single_search_after.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/single_search_after.ts @@ -36,9 +36,6 @@ export const singleSearchAfter = async ({ searchResult: SignalSearchResponse; searchDuration: string; }> => { - if (searchAfterSortId == null) { - throw Error('Attempted to search after with empty sort id'); - } try { const searchAfterQuery = buildEventsSearchQuery({ index, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/types.ts index b493bab8b4610..32b13c5251a6b 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/types.ts @@ -100,6 +100,7 @@ export interface GetResponse { _source: SearchTypes; } +export type EventSearchResponse = SearchResponse; export type SignalSearchResponse = SearchResponse; export type SignalSourceHit = SignalSearchResponse['hits']['hits'][number]; diff --git a/x-pack/plugins/siem/server/plugin.ts b/x-pack/plugins/siem/server/plugin.ts index 3c336991f3d9d..5a47efd458888 100644 --- a/x-pack/plugins/siem/server/plugin.ts +++ b/x-pack/plugins/siem/server/plugin.ts @@ -19,6 +19,7 @@ import { PluginSetupContract as AlertingSetup } from '../../alerting/server'; import { SecurityPluginSetup as SecuritySetup } from '../../security/server'; import { PluginSetupContract as FeaturesSetup } from '../../features/server'; import { MlPluginSetup as MlSetup } from '../../ml/server'; +import { ListPluginSetup } from '../../lists/server'; import { EncryptedSavedObjectsPluginSetup as EncryptedSavedObjectsSetup } from '../../encrypted_saved_objects/server'; import { SpacesPluginSetup as SpacesSetup } from '../../spaces/server'; import { LicensingPluginSetup } from '../../licensing/server'; @@ -52,6 +53,7 @@ export interface SetupPlugins { security?: SecuritySetup; spaces?: SpacesSetup; ml?: MlSetup; + lists?: ListPluginSetup; } export interface StartPlugins { @@ -194,6 +196,7 @@ export class Plugin implements IPlugin { http: (http as unknown) as CoreSetup['http'], getStartServices: async () => [coreStart, {}, {}], authorization: securityMock.createSetup().authz, - getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + auditLogger: {} as SpacesAuditLogger, config$: Rx.of(spacesConfig), }); diff --git a/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts index 1a32e861b22e1..0abf545fa7493 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts @@ -41,7 +41,7 @@ describe('createSpacesTutorialContextFactory', () => { http: coreMock.createSetup().http, getStartServices: async () => [coreMock.createStart(), {}, {}], authorization: securityMock.createSetup().authz, - getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + auditLogger: {} as SpacesAuditLogger, config$: Rx.of(spacesConfig), }); const contextFactory = createSpacesTutorialContextFactory(spacesService); diff --git a/x-pack/plugins/spaces/server/plugin.test.ts b/x-pack/plugins/spaces/server/plugin.test.ts index 7126f96f4f829..a82f2370cc124 100644 --- a/x-pack/plugins/spaces/server/plugin.test.ts +++ b/x-pack/plugins/spaces/server/plugin.test.ts @@ -23,9 +23,6 @@ describe('Spaces Plugin', () => { const spacesSetup = await plugin.setup(core, { features, licensing }); expect(spacesSetup).toMatchInlineSnapshot(` Object { - "__legacyCompat": Object { - "registerLegacyAPI": [Function], - }, "spacesService": Object { "getActiveSpace": [Function], "getBasePath": [Function], diff --git a/x-pack/plugins/spaces/server/plugin.ts b/x-pack/plugins/spaces/server/plugin.ts index 36809bf0e9e7a..af54effcaeca6 100644 --- a/x-pack/plugins/spaces/server/plugin.ts +++ b/x-pack/plugins/spaces/server/plugin.ts @@ -14,8 +14,6 @@ import { } from '../../features/server'; import { SecurityPluginSetup } from '../../security/server'; import { LicensingPluginSetup } from '../../licensing/server'; -// @ts-ignore -import { AuditLogger } from '../../../../server/lib/audit_logger'; import { SpacesAuditLogger } from './lib/audit_logger'; import { createSpacesTutorialContextFactory } from './lib/spaces_tutorial_context_factory'; import { registerSpacesUsageCollector } from './usage_collection'; @@ -31,16 +29,6 @@ import { SpacesSavedObjectsService } from './saved_objects'; import { DefaultSpaceService } from './default_space'; import { SpacesLicenseService } from '../common/licensing'; -/** - * Describes a set of APIs that is available in the legacy platform only and required by this plugin - * to function properly. - */ -export interface LegacyAPI { - auditLogger: { - create: (pluginId: string) => AuditLogger; - }; -} - export interface PluginsSetup { features: FeaturesPluginSetup; licensing: LicensingPluginSetup; @@ -55,9 +43,6 @@ export interface PluginsStart { export interface SpacesPluginSetup { spacesService: SpacesServiceSetup; - __legacyCompat: { - registerLegacyAPI: (legacyAPI: LegacyAPI) => void; - }; } export class Plugin { @@ -73,24 +58,6 @@ export class Plugin { private defaultSpaceService?: DefaultSpaceService; - private legacyAPI?: LegacyAPI; - private readonly getLegacyAPI = () => { - if (!this.legacyAPI) { - throw new Error('Legacy API is not registered!'); - } - return this.legacyAPI; - }; - - private spacesAuditLogger?: SpacesAuditLogger; - private readonly getSpacesAuditLogger = () => { - if (!this.spacesAuditLogger) { - this.spacesAuditLogger = new SpacesAuditLogger( - this.getLegacyAPI().auditLogger.create(this.pluginId) - ); - } - return this.spacesAuditLogger; - }; - constructor(initializerContext: PluginInitializerContext) { this.config$ = initializerContext.config.create(); this.kibanaIndexConfig$ = initializerContext.config.legacy.globalConfig$; @@ -109,7 +76,7 @@ export class Plugin { http: core.http, getStartServices: core.getStartServices, authorization: plugins.security ? plugins.security.authz : null, - getSpacesAuditLogger: this.getSpacesAuditLogger, + auditLogger: new SpacesAuditLogger(plugins.security?.audit.getLogger(this.pluginId)), config$: this.config$, }); @@ -177,11 +144,6 @@ export class Plugin { return { spacesService, - __legacyCompat: { - registerLegacyAPI: (legacyAPI: LegacyAPI) => { - this.legacyAPI = legacyAPI; - }, - }, }; } diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts index 632e64156291c..09fc990e9935c 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -78,7 +78,7 @@ describe('copy to space', () => { http: (httpService as unknown) as CoreSetup['http'], getStartServices: async () => [coreStart, {}, {}], authorization: securityMock.createSetup().authz, - getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + auditLogger: {} as SpacesAuditLogger, config$: Rx.of(spacesConfig), }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts index 511e9676940d2..774b794d77e29 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts @@ -50,7 +50,7 @@ describe('Spaces Public API', () => { http: (httpService as unknown) as CoreSetup['http'], getStartServices: async () => [coreStart, {}, {}], authorization: securityMock.createSetup().authz, - getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + auditLogger: {} as SpacesAuditLogger, config$: Rx.of(spacesConfig), }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts index 3eb9b676bcc61..19f9b81baa0b0 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts @@ -43,7 +43,7 @@ describe('GET space', () => { http: (httpService as unknown) as CoreSetup['http'], getStartServices: async () => [coreStart, {}, {}], authorization: securityMock.createSetup().authz, - getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + auditLogger: {} as SpacesAuditLogger, config$: Rx.of(spacesConfig), }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts index 5847b3f84f41d..380cc9dbe5abf 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts @@ -43,7 +43,7 @@ describe('GET /spaces/space', () => { http: (httpService as unknown) as CoreSetup['http'], getStartServices: async () => [coreStart, {}, {}], authorization: securityMock.createSetup().authz, - getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + auditLogger: {} as SpacesAuditLogger, config$: Rx.of(spacesConfig), }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts index 51fcfbfeaa95d..ca3afc04b9798 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts @@ -43,7 +43,7 @@ describe('Spaces Public API', () => { http: (httpService as unknown) as CoreSetup['http'], getStartServices: async () => [coreStart, {}, {}], authorization: securityMock.createSetup().authz, - getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + auditLogger: {} as SpacesAuditLogger, config$: Rx.of(spacesConfig), }); diff --git a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts index 3575d89b151e8..62444fd3e4dfd 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts @@ -44,7 +44,7 @@ describe('PUT /api/spaces/space', () => { http: (httpService as unknown) as CoreSetup['http'], getStartServices: async () => [coreStart, {}, {}], authorization: securityMock.createSetup().authz, - getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + auditLogger: {} as SpacesAuditLogger, config$: Rx.of(spacesConfig), }); diff --git a/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.test.ts b/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.test.ts index 82de102e119c7..086d5f5bc94bb 100644 --- a/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/internal/get_active_space.test.ts @@ -24,7 +24,7 @@ describe('GET /internal/spaces/_active_space', () => { http: (httpService as unknown) as CoreSetup['http'], getStartServices: async () => [coreStart, {}, {}], authorization: null, - getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + auditLogger: {} as SpacesAuditLogger, config$: Rx.of(spacesConfig), }); diff --git a/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts index 3ea1da1c835b2..3e1a849a9bdfa 100644 --- a/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts +++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts @@ -71,7 +71,7 @@ const createService = async (serverBasePath: string = '') => { getStartServices: async () => [coreStart, {}, {}], config$: Rx.of(spacesConfig), authorization: securityMock.createSetup().authz, - getSpacesAuditLogger: () => new SpacesAuditLogger({}), + auditLogger: new SpacesAuditLogger(), }); return spacesServiceSetup; diff --git a/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts index c2cc26d85fcfb..759b0606a5e8b 100644 --- a/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts +++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts @@ -15,6 +15,7 @@ import { getSpaceIdFromPath, addSpaceIdToPath } from '../../common/lib/spaces_ur import { DEFAULT_SPACE_ID } from '../../common/constants'; import { spaceIdToNamespace, namespaceToSpaceId } from '../lib/utils/namespace'; import { Space } from '../../common/model/space'; +import { SpacesAuditLogger } from '../lib/audit_logger'; type RequestFacade = KibanaRequest | Legacy.Request; @@ -39,7 +40,7 @@ interface SpacesServiceDeps { getStartServices: CoreSetup['getStartServices']; authorization: SecurityPluginSetup['authz'] | null; config$: Observable; - getSpacesAuditLogger(): any; + auditLogger: SpacesAuditLogger; } export class SpacesService { @@ -52,7 +53,7 @@ export class SpacesService { getStartServices, authorization, config$, - getSpacesAuditLogger, + auditLogger, }: SpacesServiceDeps): Promise { const getSpaceId = (request: RequestFacade) => { // Currently utilized by reporting @@ -81,7 +82,7 @@ export class SpacesService { ); return new SpacesClient( - getSpacesAuditLogger(), + auditLogger, (message: string) => { this.log.debug(message); }, diff --git a/x-pack/test/detection_engine_api_integration/common/config.ts b/x-pack/test/detection_engine_api_integration/common/config.ts index d59ab4a3adc4b..0e3f3d94ed675 100644 --- a/x-pack/test/detection_engine_api_integration/common/config.ts +++ b/x-pack/test/detection_engine_api_integration/common/config.ts @@ -79,6 +79,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) ])}`, `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, '--xpack.eventLog.logEntries=true', + '--xpack.lists.enabled=true', ...disabledPlugins.map((key) => `--xpack.${key}.enabled=false`), `--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'alerts')}`, `--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'actions')}`,