diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6519bf9c493f9..f9f43b804fc92 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,7 +3,6 @@ # For more info, see https://help.github.com/articles/about-codeowners/ # App -/x-pack/legacy/plugins/dashboard_enhanced/ @elastic/kibana-app /x-pack/legacy/plugins/lens/ @elastic/kibana-app /x-pack/legacy/plugins/graph/ @elastic/kibana-app /src/legacy/server/url_shortening/ @elastic/kibana-app diff --git a/.gitignore b/.gitignore index efb5c57774633..bd7a954f950e9 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ package-lock.json *.sublime-* npm-debug.log* .tern-project +x-pack/legacy/plugins/apm/tsconfig.json +apm.tsconfig.json +/x-pack/legacy/plugins/apm/e2e/snapshots.js diff --git a/docs/development/core/public/kibana-plugin-core-public.app.mount.md b/docs/development/core/public/kibana-plugin-core-public.app.mount.md index c42f73ced95af..8a9dfd9e2e972 100644 --- a/docs/development/core/public/kibana-plugin-core-public.app.mount.md +++ b/docs/development/core/public/kibana-plugin-core-public.app.mount.md @@ -14,5 +14,5 @@ mount: AppMount | AppMountDeprecated ## Remarks -When function has two arguments, it will be called with a [context](./kibana-plugin-core-public.appmountcontext.md) as the first argument. This behavior is \*\*deprecated\*\*, and consumers should instead use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +When function has two arguments, it will be called with a [context](./kibana-plugin-core-public.appmountcontext.md) as the first argument. This behavior is \*\*deprecated\*\*, and consumers should instead use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationsetup.md b/docs/development/core/public/kibana-plugin-core-public.applicationsetup.md index e5554be515077..fc99e2208220f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationsetup.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationsetup.md @@ -17,5 +17,5 @@ export interface ApplicationSetup | --- | --- | | [register(app)](./kibana-plugin-core-public.applicationsetup.register.md) | Register an mountable application to the system. | | [registerAppUpdater(appUpdater$)](./kibana-plugin-core-public.applicationsetup.registerappupdater.md) | Register an application updater that can be used to change the [AppUpdatableFields](./kibana-plugin-core-public.appupdatablefields.md) fields of all applications at runtime.This is meant to be used by plugins that needs to updates the whole list of applications. To only updates a specific application, use the updater$ property of the registered application instead. | -| [registerMountContext(contextName, provider)](./kibana-plugin-core-public.applicationsetup.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). | +| [registerMountContext(contextName, provider)](./kibana-plugin-core-public.applicationsetup.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). | diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationsetup.registermountcontext.md b/docs/development/core/public/kibana-plugin-core-public.applicationsetup.registermountcontext.md index 92a7ae1c0deee..1735d5df943ae 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationsetup.registermountcontext.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationsetup.registermountcontext.md @@ -8,7 +8,7 @@ > > -Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationstart.md b/docs/development/core/public/kibana-plugin-core-public.applicationstart.md index 834411de5d57c..a93bc61bac527 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationstart.md @@ -24,5 +24,5 @@ export interface ApplicationStart | --- | --- | | [getUrlForApp(appId, options)](./kibana-plugin-core-public.applicationstart.geturlforapp.md) | Returns an URL to a given app, including the global base path. By default, the URL is relative (/basePath/app/my-app). Use the absolute option to generate an absolute url (http://host:port/basePath/app/my-app)Note that when generating absolute urls, the protocol, host and port are determined from the browser location. | | [navigateToApp(appId, options)](./kibana-plugin-core-public.applicationstart.navigatetoapp.md) | Navigate to a given app | -| [registerMountContext(contextName, provider)](./kibana-plugin-core-public.applicationstart.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). | +| [registerMountContext(contextName, provider)](./kibana-plugin-core-public.applicationstart.registermountcontext.md) | Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). | diff --git a/docs/development/core/public/kibana-plugin-core-public.applicationstart.registermountcontext.md b/docs/development/core/public/kibana-plugin-core-public.applicationstart.registermountcontext.md index 6e0fbb46e9a1e..11f661c4af2b3 100644 --- a/docs/development/core/public/kibana-plugin-core-public.applicationstart.registermountcontext.md +++ b/docs/development/core/public/kibana-plugin-core-public.applicationstart.registermountcontext.md @@ -8,7 +8,7 @@ > > -Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +Register a context provider for application mounting. Will only be available to applications that depend on the plugin that registered this context. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.appmountcontext.md b/docs/development/core/public/kibana-plugin-core-public.appmountcontext.md index d0b243859aab0..52a36b0b56f02 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appmountcontext.md +++ b/docs/development/core/public/kibana-plugin-core-public.appmountcontext.md @@ -8,7 +8,7 @@ > > -The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). Signature: diff --git a/docs/development/core/public/kibana-plugin-core-public.appmountdeprecated.md b/docs/development/core/public/kibana-plugin-core-public.appmountdeprecated.md index 130689882495a..66b8a69d84a38 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appmountdeprecated.md +++ b/docs/development/core/public/kibana-plugin-core-public.appmountdeprecated.md @@ -18,5 +18,5 @@ export declare type AppMountDeprecated = (contex ## Remarks -When function has two arguments, it will be called with a [context](./kibana-plugin-core-public.appmountcontext.md) as the first argument. This behavior is \*\*deprecated\*\*, and consumers should instead use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). +When function has two arguments, it will be called with a [context](./kibana-plugin-core-public.appmountcontext.md) as the first argument. This behavior is \*\*deprecated\*\*, and consumers should instead use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). diff --git a/docs/development/core/public/kibana-plugin-core-public.coresetup.getstartservices.md b/docs/development/core/public/kibana-plugin-core-public.coresetup.getstartservices.md index 91b906cf83d01..e4fec4eae31b1 100644 --- a/docs/development/core/public/kibana-plugin-core-public.coresetup.getstartservices.md +++ b/docs/development/core/public/kibana-plugin-core-public.coresetup.getstartservices.md @@ -2,16 +2,12 @@ [Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [CoreSetup](./kibana-plugin-core-public.coresetup.md) > [getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md) -## CoreSetup.getStartServices() method +## CoreSetup.getStartServices property -Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed `start`. +[StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) Signature: ```typescript -getStartServices(): Promise<[CoreStart, TPluginsStart]>; +getStartServices: StartServicesAccessor; ``` -Returns: - -`Promise<[CoreStart, TPluginsStart]>` - diff --git a/docs/development/core/public/kibana-plugin-core-public.coresetup.md b/docs/development/core/public/kibana-plugin-core-public.coresetup.md index f211b740e84a3..c039bc19348cc 100644 --- a/docs/development/core/public/kibana-plugin-core-public.coresetup.md +++ b/docs/development/core/public/kibana-plugin-core-public.coresetup.md @@ -19,14 +19,9 @@ export interface CoreSetup | [application](./kibana-plugin-core-public.coresetup.application.md) | ApplicationSetup | [ApplicationSetup](./kibana-plugin-core-public.applicationsetup.md) | | [context](./kibana-plugin-core-public.coresetup.context.md) | ContextSetup | [ContextSetup](./kibana-plugin-core-public.contextsetup.md) | | [fatalErrors](./kibana-plugin-core-public.coresetup.fatalerrors.md) | FatalErrorsSetup | [FatalErrorsSetup](./kibana-plugin-core-public.fatalerrorssetup.md) | +| [getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md) | StartServicesAccessor<TPluginsStart> | [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) | | [http](./kibana-plugin-core-public.coresetup.http.md) | HttpSetup | [HttpSetup](./kibana-plugin-core-public.httpsetup.md) | | [injectedMetadata](./kibana-plugin-core-public.coresetup.injectedmetadata.md) | {
getInjectedVar: (name: string, defaultValue?: any) => unknown;
} | exposed temporarily until https://github.com/elastic/kibana/issues/41990 done use \*only\* to retrieve config values. There is no way to set injected values in the new platform. Use the legacy platform API instead. | | [notifications](./kibana-plugin-core-public.coresetup.notifications.md) | NotificationsSetup | [NotificationsSetup](./kibana-plugin-core-public.notificationssetup.md) | | [uiSettings](./kibana-plugin-core-public.coresetup.uisettings.md) | IUiSettingsClient | [IUiSettingsClient](./kibana-plugin-core-public.iuisettingsclient.md) | -## Methods - -| Method | Description | -| --- | --- | -| [getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md) | Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed start. | - diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index b8aa56eb2941b..adc87de2b9e7e 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -38,7 +38,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [AppLeaveDefaultAction](./kibana-plugin-core-public.appleavedefaultaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-core-public.appleavehandler.md) to execute the default behaviour when leaving the application.See | | [ApplicationSetup](./kibana-plugin-core-public.applicationsetup.md) | | | [ApplicationStart](./kibana-plugin-core-public.applicationstart.md) | | -| [AppMountContext](./kibana-plugin-core-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-core-public.coresetup.getstartservices.md). | +| [AppMountContext](./kibana-plugin-core-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices](./kibana-plugin-core-public.coresetup.getstartservices.md). | | [AppMountParameters](./kibana-plugin-core-public.appmountparameters.md) | | | [Capabilities](./kibana-plugin-core-public.capabilities.md) | The read-only set of capabilities available for the current UI session. Capabilities are simple key-value pairs of (string, boolean), where the string denotes the capability ID, and the boolean is a flag indicating if the capability is enabled or disabled. | | [ChromeBadge](./kibana-plugin-core-public.chromebadge.md) | | @@ -153,6 +153,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value | | [SavedObjectAttributeSingle](./kibana-plugin-core-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | | [SavedObjectsClientContract](./kibana-plugin-core-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md) | +| [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) | Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed start. | | [StringValidation](./kibana-plugin-core-public.stringvalidation.md) | Allows regex objects or a regex string | | [Toast](./kibana-plugin-core-public.toast.md) | | | [ToastInput](./kibana-plugin-core-public.toastinput.md) | Inputs for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. | diff --git a/docs/development/core/public/kibana-plugin-core-public.startservicesaccessor.md b/docs/development/core/public/kibana-plugin-core-public.startservicesaccessor.md new file mode 100644 index 0000000000000..02e896a6b47e5 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.startservicesaccessor.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) + +## StartServicesAccessor type + +Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed `start`. + +Signature: + +```typescript +export declare type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart]>; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.getstartservices.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.getstartservices.md index 10a656363c0d0..ea8e610ee56de 100644 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.getstartservices.md +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.getstartservices.md @@ -2,16 +2,12 @@ [Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CoreSetup](./kibana-plugin-core-server.coresetup.md) > [getStartServices](./kibana-plugin-core-server.coresetup.getstartservices.md) -## CoreSetup.getStartServices() method +## CoreSetup.getStartServices property -Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed `start`. This should only be used inside handlers registered during `setup` that will only be executed after `start` lifecycle. +[StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) Signature: ```typescript -getStartServices(): Promise<[CoreStart, TPluginsStart]>; +getStartServices: StartServicesAccessor; ``` -Returns: - -`Promise<[CoreStart, TPluginsStart]>` - diff --git a/docs/development/core/server/kibana-plugin-core-server.coresetup.md b/docs/development/core/server/kibana-plugin-core-server.coresetup.md index 5b5803629cc86..b0eba8ac78063 100644 --- a/docs/development/core/server/kibana-plugin-core-server.coresetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.coresetup.md @@ -19,15 +19,10 @@ export interface CoreSetup | [capabilities](./kibana-plugin-core-server.coresetup.capabilities.md) | CapabilitiesSetup | [CapabilitiesSetup](./kibana-plugin-core-server.capabilitiessetup.md) | | [context](./kibana-plugin-core-server.coresetup.context.md) | ContextSetup | [ContextSetup](./kibana-plugin-core-server.contextsetup.md) | | [elasticsearch](./kibana-plugin-core-server.coresetup.elasticsearch.md) | ElasticsearchServiceSetup | [ElasticsearchServiceSetup](./kibana-plugin-core-server.elasticsearchservicesetup.md) | +| [getStartServices](./kibana-plugin-core-server.coresetup.getstartservices.md) | StartServicesAccessor<TPluginsStart> | [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) | | [http](./kibana-plugin-core-server.coresetup.http.md) | HttpServiceSetup | [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) | | [metrics](./kibana-plugin-core-server.coresetup.metrics.md) | MetricsServiceSetup | [MetricsServiceSetup](./kibana-plugin-core-server.metricsservicesetup.md) | | [savedObjects](./kibana-plugin-core-server.coresetup.savedobjects.md) | SavedObjectsServiceSetup | [SavedObjectsServiceSetup](./kibana-plugin-core-server.savedobjectsservicesetup.md) | | [uiSettings](./kibana-plugin-core-server.coresetup.uisettings.md) | UiSettingsServiceSetup | [UiSettingsServiceSetup](./kibana-plugin-core-server.uisettingsservicesetup.md) | | [uuid](./kibana-plugin-core-server.coresetup.uuid.md) | UuidServiceSetup | [UuidServiceSetup](./kibana-plugin-core-server.uuidservicesetup.md) | -## Methods - -| Method | Description | -| --- | --- | -| [getStartServices()](./kibana-plugin-core-server.coresetup.getstartservices.md) | Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed start. This should only be used inside handlers registered during setup that will only be executed after start lifecycle. | - diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 54cf496b2d6af..a1158dc853918 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -259,6 +259,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation | | [ScopeableRequest](./kibana-plugin-core-server.scopeablerequest.md) | A user credentials container. It accommodates the necessary auth credentials to impersonate the current user.See [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md). | | [SharedGlobalConfig](./kibana-plugin-core-server.sharedglobalconfig.md) | | +| [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) | Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed start. This should only be used inside handlers registered during setup that will only be executed after start lifecycle. | | [StringValidation](./kibana-plugin-core-server.stringvalidation.md) | Allows regex objects or a regex string | | [UiSettingsType](./kibana-plugin-core-server.uisettingstype.md) | UI element type to represent the settings. | diff --git a/docs/development/core/server/kibana-plugin-core-server.startservicesaccessor.md b/docs/development/core/server/kibana-plugin-core-server.startservicesaccessor.md new file mode 100644 index 0000000000000..4de781fc99cc1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.startservicesaccessor.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [StartServicesAccessor](./kibana-plugin-core-server.startservicesaccessor.md) + +## StartServicesAccessor type + +Allows plugins to get access to APIs available in start inside async handlers. Promise will not resolve until Core and plugin dependencies have completed `start`. This should only be used inside handlers registered during `setup` that will only be executed after `start` lifecycle. + +Signature: + +```typescript +export declare type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart]>; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index ea77d6f39389b..6964c070097c5 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -18,7 +18,9 @@ | [IndexPatternSelect](./kibana-plugin-plugins-data-public.indexpatternselect.md) | | | [OptionedParamType](./kibana-plugin-plugins-data-public.optionedparamtype.md) | | | [Plugin](./kibana-plugin-plugins-data-public.plugin.md) | | +| [RequestTimeoutError](./kibana-plugin-plugins-data-public.requesttimeouterror.md) | Class used to signify that a request timed out. Useful for applications to conditionally handle this type of error differently than other errors. | | [SearchError](./kibana-plugin-plugins-data-public.searcherror.md) | | +| [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) | | | [SearchSource](./kibana-plugin-plugins-data-public.searchsource.md) | | | [TimeHistory](./kibana-plugin-plugins-data-public.timehistory.md) | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md new file mode 100644 index 0000000000000..25e472817b46d --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [RequestTimeoutError](./kibana-plugin-plugins-data-public.requesttimeouterror.md) > [(constructor)](./kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md) + +## RequestTimeoutError.(constructor) + +Constructs a new instance of the `RequestTimeoutError` class + +Signature: + +```typescript +constructor(message?: string); +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| message | string | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror.md new file mode 100644 index 0000000000000..84b2fc3fe0b17 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.requesttimeouterror.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [RequestTimeoutError](./kibana-plugin-plugins-data-public.requesttimeouterror.md) + +## RequestTimeoutError class + +Class used to signify that a request timed out. Useful for applications to conditionally handle this type of error differently than other errors. + +Signature: + +```typescript +export declare class RequestTimeoutError extends Error +``` + +## Constructors + +| Constructor | Modifiers | Description | +| --- | --- | --- | +| [(constructor)(message)](./kibana-plugin-plugins-data-public.requesttimeouterror._constructor_.md) | | Constructs a new instance of the RequestTimeoutError class | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md new file mode 100644 index 0000000000000..6eabefb9eb912 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [(constructor)](./kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md) + +## SearchInterceptor.(constructor) + +This class should be instantiated with a `requestTimeout` corresponding with how many ms after requests are initiated that they should automatically cancel. + +Signature: + +```typescript +constructor(toasts: ToastsStart, application: ApplicationStart, requestTimeout?: number | undefined); +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| toasts | ToastsStart | | +| application | ApplicationStart | | +| requestTimeout | number | undefined | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md new file mode 100644 index 0000000000000..0451a2254dc40 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [abortController](./kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md) + +## SearchInterceptor.abortController property + +`abortController` used to signal all searches to abort. + +Signature: + +```typescript +protected abortController: AbortController; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.application.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.application.md new file mode 100644 index 0000000000000..e44910161aa60 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.application.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [application](./kibana-plugin-plugins-data-public.searchinterceptor.application.md) + +## SearchInterceptor.application property + +Signature: + +```typescript +protected readonly application: ApplicationStart; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md new file mode 100644 index 0000000000000..59b107c92424f --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [getPendingCount$](./kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md) + +## SearchInterceptor.getPendingCount$ property + +Returns an `Observable` over the current number of pending searches. This could mean that one of the search requests is still in flight, or that it has only received partial responses. + +Signature: + +```typescript +getPendingCount$: () => import("rxjs").Observable; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md new file mode 100644 index 0000000000000..59938a755a99e --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [hideToast](./kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md) + +## SearchInterceptor.hideToast property + +Signature: + +```typescript +protected hideToast: () => void; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md new file mode 100644 index 0000000000000..5799039de91bc --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [longRunningToast](./kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md) + +## SearchInterceptor.longRunningToast property + +The current long-running toast (if there is one). + +Signature: + +```typescript +protected longRunningToast?: Toast; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md new file mode 100644 index 0000000000000..0c7b123be72af --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md @@ -0,0 +1,33 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) + +## SearchInterceptor class + +Signature: + +```typescript +export declare class SearchInterceptor +``` + +## Constructors + +| Constructor | Modifiers | Description | +| --- | --- | --- | +| [(constructor)(toasts, application, requestTimeout)](./kibana-plugin-plugins-data-public.searchinterceptor._constructor_.md) | | This class should be instantiated with a requestTimeout corresponding with how many ms after requests are initiated that they should automatically cancel. | + +## Properties + +| Property | Modifiers | Type | Description | +| --- | --- | --- | --- | +| [abortController](./kibana-plugin-plugins-data-public.searchinterceptor.abortcontroller.md) | | AbortController | abortController used to signal all searches to abort. | +| [application](./kibana-plugin-plugins-data-public.searchinterceptor.application.md) | | ApplicationStart | | +| [getPendingCount$](./kibana-plugin-plugins-data-public.searchinterceptor.getpendingcount_.md) | | () => import("rxjs").Observable<number> | Returns an Observable over the current number of pending searches. This could mean that one of the search requests is still in flight, or that it has only received partial responses. | +| [hideToast](./kibana-plugin-plugins-data-public.searchinterceptor.hidetoast.md) | | () => void | | +| [longRunningToast](./kibana-plugin-plugins-data-public.searchinterceptor.longrunningtoast.md) | | Toast | The current long-running toast (if there is one). | +| [requestTimeout](./kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md) | | number | undefined | | +| [search](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | (search: ISearchGeneric, request: IKibanaSearchRequest, options?: ISearchOptions | undefined) => import("rxjs").Observable<import("../../common/search").IEsSearchResponse<unknown>> | Searches using the given search method. Overrides the AbortSignal with one that will abort either when cancelPending is called, when the request times out, or when the original AbortSignal is aborted. Updates the pendingCount when the request is started/finalized. | +| [showToast](./kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md) | | () => void | | +| [timeoutSubscriptions](./kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md) | | Set<Subscription> | The subscriptions from scheduling the automatic timeout for each request. | +| [toasts](./kibana-plugin-plugins-data-public.searchinterceptor.toasts.md) | | ToastsStart | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md new file mode 100644 index 0000000000000..3123433762991 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [requestTimeout](./kibana-plugin-plugins-data-public.searchinterceptor.requesttimeout.md) + +## SearchInterceptor.requestTimeout property + +Signature: + +```typescript +protected readonly requestTimeout?: number | undefined; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md new file mode 100644 index 0000000000000..80c98ab84fb40 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [search](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) + +## SearchInterceptor.search property + +Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort either when `cancelPending` is called, when the request times out, or when the original `AbortSignal` is aborted. Updates the `pendingCount` when the request is started/finalized. + +Signature: + +```typescript +search: (search: ISearchGeneric, request: IKibanaSearchRequest, options?: ISearchOptions | undefined) => import("rxjs").Observable>; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md new file mode 100644 index 0000000000000..e495c72b57215 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [showToast](./kibana-plugin-plugins-data-public.searchinterceptor.showtoast.md) + +## SearchInterceptor.showToast property + +Signature: + +```typescript +protected showToast: () => void; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md new file mode 100644 index 0000000000000..072f67591f097 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [timeoutSubscriptions](./kibana-plugin-plugins-data-public.searchinterceptor.timeoutsubscriptions.md) + +## SearchInterceptor.timeoutSubscriptions property + +The subscriptions from scheduling the automatic timeout for each request. + +Signature: + +```typescript +protected timeoutSubscriptions: Set; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.toasts.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.toasts.md new file mode 100644 index 0000000000000..4953d17c89c39 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.toasts.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchInterceptor](./kibana-plugin-plugins-data-public.searchinterceptor.md) > [toasts](./kibana-plugin-plugins-data-public.searchinterceptor.toasts.md) + +## SearchInterceptor.toasts property + +Signature: + +```typescript +protected readonly toasts: ToastsStart; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md index d179b9d9dcd82..e756eb9b72905 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.md @@ -60,6 +60,7 @@ | [esQuery](./kibana-plugin-plugins-data-server.esquery.md) | | | [fieldFormats](./kibana-plugin-plugins-data-server.fieldformats.md) | | | [indexPatterns](./kibana-plugin-plugins-data-server.indexpatterns.md) | | +| [search](./kibana-plugin-plugins-data-server.search.md) | | ## Type Aliases @@ -69,5 +70,6 @@ | [IFieldFormatsRegistry](./kibana-plugin-plugins-data-server.ifieldformatsregistry.md) | | | [ISearch](./kibana-plugin-plugins-data-server.isearch.md) | | | [ISearchCancel](./kibana-plugin-plugins-data-server.isearchcancel.md) | | +| [ParsedInterval](./kibana-plugin-plugins-data-server.parsedinterval.md) | | | [TSearchStrategyProvider](./kibana-plugin-plugins-data-server.tsearchstrategyprovider.md) | Search strategy provider creates an instance of a search strategy with the request handler context bound to it. This way every search strategy can use whatever information they require from the request context. | diff --git a/examples/alerting_example/public/application.tsx b/examples/alerting_example/public/application.tsx index d71db92d3d421..6ff5a7d0880b8 100644 --- a/examples/alerting_example/public/application.tsx +++ b/examples/alerting_example/public/application.tsx @@ -25,6 +25,7 @@ import { AppMountParameters, CoreStart, IUiSettingsClient, + DocLinksStart, ToastsSetup, } from '../../../src/core/public'; import { DataPublicPluginStart } from '../../../src/plugins/data/public'; @@ -45,6 +46,7 @@ export interface AlertingExampleComponentParams { data: DataPublicPluginStart; charts: ChartsPluginStart; uiSettings: IUiSettingsClient; + docLinks: DocLinksStart; toastNotifications: ToastsSetup; } @@ -88,7 +90,7 @@ const AlertingExampleApp = (deps: AlertingExampleComponentParams) => { }; export const renderApp = ( - { application, notifications, http, uiSettings }: CoreStart, + { application, notifications, http, uiSettings, docLinks }: CoreStart, deps: AlertingExamplePublicStartDeps, { appBasePath, element }: AppMountParameters ) => { @@ -99,6 +101,7 @@ export const renderApp = ( toastNotifications={notifications.toasts} http={http} uiSettings={uiSettings} + docLinks={docLinks} {...deps} />, element diff --git a/examples/alerting_example/public/components/create_alert.tsx b/examples/alerting_example/public/components/create_alert.tsx index 65b8a9412dcda..0541e0b18a2e1 100644 --- a/examples/alerting_example/public/components/create_alert.tsx +++ b/examples/alerting_example/public/components/create_alert.tsx @@ -33,6 +33,7 @@ export const CreateAlert = ({ triggers_actions_ui, charts, uiSettings, + docLinks, data, toastNotifications, }: AlertingExampleComponentParams) => { @@ -56,6 +57,7 @@ export const CreateAlert = ({ alertTypeRegistry: triggers_actions_ui.alertTypeRegistry, toastNotifications, uiSettings, + docLinks, charts, dataFieldsFormats: data.fieldFormats, }} diff --git a/examples/ui_action_examples/public/plugin.ts b/examples/ui_action_examples/public/plugin.ts index d053f7e82862c..c47746d4b3fd6 100644 --- a/examples/ui_action_examples/public/plugin.ts +++ b/examples/ui_action_examples/public/plugin.ts @@ -46,7 +46,7 @@ export class UiActionExamplesPlugin })); uiActions.registerAction(helloWorldAction); - uiActions.addTriggerAction(helloWorldTrigger.id, helloWorldAction); + uiActions.attachAction(helloWorldTrigger.id, helloWorldAction); } public start() {} diff --git a/examples/ui_actions_explorer/public/app.tsx b/examples/ui_actions_explorer/public/app.tsx index f08b8bb29bdd3..462f5c3bf88ba 100644 --- a/examples/ui_actions_explorer/public/app.tsx +++ b/examples/ui_actions_explorer/public/app.tsx @@ -95,7 +95,8 @@ const ActionsExplorer = ({ uiActionsApi, openModal }: Props) => { ); }, }); - uiActionsApi.addTriggerAction(HELLO_WORLD_TRIGGER_ID, dynamicAction); + uiActionsApi.registerAction(dynamicAction); + uiActionsApi.attachAction(HELLO_WORLD_TRIGGER_ID, dynamicAction); setConfirmationText( `You've successfully added a new action: ${dynamicAction.getDisplayName( {} diff --git a/examples/ui_actions_explorer/public/plugin.tsx b/examples/ui_actions_explorer/public/plugin.tsx index de86b51aee3a8..f1895905a45e1 100644 --- a/examples/ui_actions_explorer/public/plugin.tsx +++ b/examples/ui_actions_explorer/public/plugin.tsx @@ -79,21 +79,21 @@ export class UiActionsExplorerPlugin implements Plugin (await startServices)[1].uiActions) ); - deps.uiActions.addTriggerAction( + deps.uiActions.attachAction( USER_TRIGGER, createEditUserAction(async () => (await startServices)[0].overlays.openModal) ); - deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, viewInMapsAction); - deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, lookUpWeatherAction); - deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, showcasePluggability); - deps.uiActions.addTriggerAction(PHONE_TRIGGER, makePhoneCallAction); - deps.uiActions.addTriggerAction(PHONE_TRIGGER, showcasePluggability); - deps.uiActions.addTriggerAction(USER_TRIGGER, showcasePluggability); + deps.uiActions.attachAction(COUNTRY_TRIGGER, viewInMapsAction); + deps.uiActions.attachAction(COUNTRY_TRIGGER, lookUpWeatherAction); + deps.uiActions.attachAction(COUNTRY_TRIGGER, showcasePluggability); + deps.uiActions.attachAction(PHONE_TRIGGER, makePhoneCallAction); + deps.uiActions.attachAction(PHONE_TRIGGER, showcasePluggability); + deps.uiActions.attachAction(USER_TRIGGER, showcasePluggability); core.application.register({ id: 'uiActionsExplorer', diff --git a/package.json b/package.json index 08668730f9a9d..4baffa8719fe3 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "@elastic/apm-rum": "^4.6.0", "@elastic/charts": "^18.1.0", "@elastic/datemath": "5.0.2", - "@elastic/ems-client": "7.7.0", + "@elastic/ems-client": "7.7.1", "@elastic/eui": "21.0.1", "@elastic/filesaver": "1.1.2", "@elastic/good": "8.1.1-kibana2", @@ -162,7 +162,7 @@ "color": "1.0.3", "commander": "3.0.2", "compare-versions": "3.5.1", - "core-js": "^3.2.1", + "core-js": "^3.6.4", "css-loader": "^3.4.2", "d3": "3.5.17", "d3-cloud": "1.2.5", @@ -190,7 +190,7 @@ "hjson": "3.2.1", "hoek": "^5.0.4", "http-proxy-agent": "^2.1.0", - "https-proxy-agent": "^2.2.2", + "https-proxy-agent": "^5.0.0", "immer": "^1.5.0", "inert": "^5.1.0", "inline-style": "^2.0.0", @@ -443,7 +443,7 @@ "jest": "^24.9.0", "jest-cli": "^24.9.0", "jest-raw-loader": "^1.0.1", - "jimp": "0.8.4", + "jimp": "^0.9.6", "json5": "^1.0.1", "karma": "3.1.4", "karma-chrome-launcher": "2.2.0", diff --git a/packages/kbn-es/src/artifact.js b/packages/kbn-es/src/artifact.js index 9ea78386269d9..83dcd1cf36d2e 100644 --- a/packages/kbn-es/src/artifact.js +++ b/packages/kbn-es/src/artifact.js @@ -117,11 +117,14 @@ async function getArtifactSpecForSnapshot(urlVersion, license, log) { const manifest = JSON.parse(json); const platform = process.platform === 'win32' ? 'windows' : process.platform; + const arch = process.arch === 'arm64' ? 'aarch64' : 'x86_64'; + const archive = manifest.archives.find( archive => archive.version === desiredVersion && archive.platform === platform && - archive.license === desiredLicense + archive.license === desiredLicense && + archive.architecture === arch ); if (!archive) { diff --git a/packages/kbn-es/src/artifact.test.js b/packages/kbn-es/src/artifact.test.js index 453eb1a9a7689..02e4d5318f63f 100644 --- a/packages/kbn-es/src/artifact.test.js +++ b/packages/kbn-es/src/artifact.test.js @@ -28,6 +28,7 @@ const log = new ToolingLog(); let MOCKS; const PLATFORM = process.platform === 'win32' ? 'windows' : process.platform; +const ARCHITECTURE = process.arch === 'arm64' ? 'aarch64' : 'x86_64'; const MOCK_VERSION = 'test-version'; const MOCK_URL = 'http://127.0.0.1:12345'; const MOCK_FILENAME = 'test-filename'; @@ -38,13 +39,15 @@ const PERMANENT_SNAPSHOT_BASE_URL = const createArchive = (params = {}) => { const license = params.license || 'default'; + const architecture = params.architecture || ARCHITECTURE; return { license: 'default', + architecture, version: MOCK_VERSION, url: MOCK_URL + `/${license}`, platform: PLATFORM, - filename: MOCK_FILENAME + `.${license}`, + filename: MOCK_FILENAME + `-${architecture}.${license}`, ...params, }; }; @@ -77,6 +80,12 @@ beforeEach(() => { valid: { archives: [createArchive({ license: 'oss' }), createArchive({ license: 'default' })], }, + multipleArch: { + archives: [ + createArchive({ architecture: 'fake_arch', license: 'oss' }), + createArchive({ architecture: ARCHITECTURE, license: 'oss' }), + ], + }, }; }); @@ -95,7 +104,7 @@ const artifactTest = (requestedLicense, expectedLicense, fetchTimesCalled = 1) = expect(artifact.getUrl()).toEqual(MOCK_URL + `/${expectedLicense}`); expect(artifact.getChecksumUrl()).toEqual(MOCK_URL + `/${expectedLicense}.sha512`); expect(artifact.getChecksumType()).toEqual('sha512'); - expect(artifact.getFilename()).toEqual(MOCK_FILENAME + `.${expectedLicense}`); + expect(artifact.getFilename()).toEqual(MOCK_FILENAME + `-${ARCHITECTURE}.${expectedLicense}`); }; }; @@ -153,6 +162,17 @@ describe('Artifact', () => { }); }); + describe('with snapshots for multiple architectures', () => { + beforeEach(() => { + mockFetch(MOCKS.multipleArch); + }); + + it('should return artifact metadata for the correct architecture', async () => { + const artifact = await Artifact.getSnapshot('oss', MOCK_VERSION, log); + expect(artifact.getFilename()).toEqual(MOCK_FILENAME + `-${ARCHITECTURE}.oss`); + }); + }); + describe('with custom snapshot manifest URL', () => { const CUSTOM_URL = 'http://www.creedthoughts.gov.www/creedthoughts'; diff --git a/packages/kbn-spec-to-console/README.md b/packages/kbn-spec-to-console/README.md index 6729f03b3d4db..bf60afd88f494 100644 --- a/packages/kbn-spec-to-console/README.md +++ b/packages/kbn-spec-to-console/README.md @@ -23,10 +23,10 @@ At the root of the Kibana repository, run the following commands: ```sh # OSS -yarn spec_to_console -g "/rest-api-spec/src/main/resources/rest-api-spec/api/*" -d "src/legacy/core_plugins/console/server/api_server/spec/generated" +yarn spec_to_console -g "/rest-api-spec/src/main/resources/rest-api-spec/api/*" -d "src/plugins/console/server/lib/spec_definitions/json" # X-pack -yarn spec_to_console -g "/x-pack/plugin/src/test/resources/rest-api-spec/api/*" -d "x-pack/legacy/plugins/console_extensions/spec/generated" +yarn spec_to_console -g "/x-pack/plugin/src/test/resources/rest-api-spec/api/*" -d "x-pack/plugins/console_extensions/server/spec/generated" ``` ### Information used in Console that is not available in the REST spec diff --git a/packages/kbn-spec-to-console/bin/spec_to_console.js b/packages/kbn-spec-to-console/bin/spec_to_console.js index 20e870963e4b4..20b42c67f3b89 100644 --- a/packages/kbn-spec-to-console/bin/spec_to_console.js +++ b/packages/kbn-spec-to-console/bin/spec_to_console.js @@ -21,6 +21,7 @@ const fs = require('fs'); const path = require('path'); const program = require('commander'); const glob = require('glob'); +const chalk = require('chalk'); const packageJSON = require('../package.json'); const convert = require('../lib/convert'); @@ -37,10 +38,26 @@ if (!program.glob) { } const files = glob.sync(program.glob); -console.log(files.length, files); +const totalFilesCount = files.length; +let convertedFilesCount = 0; + +console.log(chalk.bold(`Detected files (count: ${totalFilesCount}):`)); +console.log(); +console.log(files); +console.log(); + files.forEach(file => { const spec = JSON.parse(fs.readFileSync(file)); - const output = JSON.stringify(convert(spec), null, 2); + const convertedSpec = convert(spec); + if (!Object.keys(convertedSpec).length) { + console.log( + // prettier-ignore + `${chalk.yellow('Detected')} ${chalk.grey(file)} but no endpoints were converted; ${chalk.yellow('skipping')}...` + ); + return; + } + const output = JSON.stringify(convertedSpec, null, 2); + ++convertedFilesCount; if (program.directory) { const outputName = path.basename(file); const outputPath = path.resolve(program.directory, outputName); @@ -54,3 +71,9 @@ files.forEach(file => { console.log(output); } }); + +console.log(); +// prettier-ignore +console.log(`${chalk.grey('Converted')} ${chalk.bold(`${convertedFilesCount}/${totalFilesCount}`)} ${chalk.grey('files')}`); +console.log(`Check your ${chalk.bold('git status')}.`); +console.log(); diff --git a/packages/kbn-spec-to-console/lib/convert.js b/packages/kbn-spec-to-console/lib/convert.js index 5dbdd6e1c94e4..88e3693d702e5 100644 --- a/packages/kbn-spec-to-console/lib/convert.js +++ b/packages/kbn-spec-to-console/lib/convert.js @@ -36,6 +36,11 @@ module.exports = spec => { */ Object.keys(spec).forEach(api => { const source = spec[api]; + + if (source.url.paths.every(path => Boolean(path.deprecated))) { + return; + } + if (!source.url) { return result; } diff --git a/packages/kbn-spec-to-console/lib/convert/params.js b/packages/kbn-spec-to-console/lib/convert/params.js index 86ac1667282f0..0d1747ae4f685 100644 --- a/packages/kbn-spec-to-console/lib/convert/params.js +++ b/packages/kbn-spec-to-console/lib/convert/params.js @@ -47,6 +47,7 @@ module.exports = params => { case 'date': case 'string': case 'number': + case 'number|string': result[param] = defaultValue || ''; break; case 'list': diff --git a/renovate.json5 b/renovate.json5 index e4836537df703..57f175d1afc8e 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -297,6 +297,14 @@ '@types/flot', ], }, + { + groupSlug: 'geojson', + groupName: 'geojson related packages', + packageNames: [ + 'geojson', + '@types/geojson', + ], + }, { groupSlug: 'getopts', groupName: 'getopts related packages', diff --git a/src/cli/cluster/cluster_manager.ts b/src/cli/cluster/cluster_manager.ts index 8862b96e74401..44b6c39556afd 100644 --- a/src/cli/cluster/cluster_manager.ts +++ b/src/cli/cluster/cluster_manager.ts @@ -264,7 +264,7 @@ export class ClusterManager { fromRoot('src/legacy/server/sass/__tmp__'), fromRoot('x-pack/legacy/plugins/reporting/.chromium'), fromRoot('x-pack/legacy/plugins/siem/cypress'), - fromRoot('x-pack/legacy/plugins/apm/e2e/cypress'), + fromRoot('x-pack/legacy/plugins/apm/e2e'), fromRoot('x-pack/legacy/plugins/apm/scripts'), fromRoot('x-pack/legacy/plugins/canvas/canvas_plugin_src'), // prevents server from restarting twice for Canvas plugin changes, 'plugins/java_languageserver', diff --git a/src/core/public/index.ts b/src/core/public/index.ts index b91afa3ae7dc0..f72e115fd24ff 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -208,15 +208,21 @@ export interface CoreSetup { injectedMetadata: { getInjectedVar: (name: string, defaultValue?: any) => unknown; }; - - /** - * Allows plugins to get access to APIs available in start inside async - * handlers, such as {@link App.mount}. Promise will not resolve until Core - * and plugin dependencies have completed `start`. - */ - getStartServices(): Promise<[CoreStart, TPluginsStart]>; + /** {@link StartServicesAccessor} */ + getStartServices: StartServicesAccessor; } +/** + * Allows plugins to get access to APIs available in start inside async + * handlers, such as {@link App.mount}. Promise will not resolve until Core + * and plugin dependencies have completed `start`. + * + * @public + */ +export type StartServicesAccessor = () => Promise< + [CoreStart, TPluginsStart] +>; + /** * Core services exposed to the `Plugin` start lifecycle * diff --git a/src/core/public/overlays/flyout/flyout_service.tsx b/src/core/public/overlays/flyout/flyout_service.tsx index 444430175d4f2..b609b2ce1d741 100644 --- a/src/core/public/overlays/flyout/flyout_service.tsx +++ b/src/core/public/overlays/flyout/flyout_service.tsx @@ -91,7 +91,6 @@ export interface OverlayFlyoutStart { export interface OverlayFlyoutOpenOptions { className?: string; closeButtonAriaLabel?: string; - ownFocus?: boolean; 'data-test-subj'?: string; } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 37212a07ee631..eec12f2348176 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -378,7 +378,8 @@ export interface CoreSetup { context: ContextSetup; // (undocumented) fatalErrors: FatalErrorsSetup; - getStartServices(): Promise<[CoreStart, TPluginsStart]>; + // (undocumented) + getStartServices: StartServicesAccessor; // (undocumented) http: HttpSetup; // @deprecated @@ -1235,6 +1236,9 @@ export class SimpleSavedObject { _version?: SavedObject['version']; } +// @public +export type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart]>; + // @public export type StringValidation = StringValidationRegex | StringValidationRegexString; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 89fee92a7ef02..1b436bfd72622 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -352,15 +352,22 @@ export interface CoreSetup { uuid: UuidServiceSetup; /** {@link MetricsServiceSetup} */ metrics: MetricsServiceSetup; - /** - * Allows plugins to get access to APIs available in start inside async handlers. - * Promise will not resolve until Core and plugin dependencies have completed `start`. - * This should only be used inside handlers registered during `setup` that will only be executed - * after `start` lifecycle. - */ - getStartServices(): Promise<[CoreStart, TPluginsStart]>; + /** {@link StartServicesAccessor} */ + getStartServices: StartServicesAccessor; } +/** + * Allows plugins to get access to APIs available in start inside async handlers. + * Promise will not resolve until Core and plugin dependencies have completed `start`. + * This should only be used inside handlers registered during `setup` that will only be executed + * after `start` lifecycle. + * + * @public + */ +export type StartServicesAccessor = () => Promise< + [CoreStart, TPluginsStart] +>; + /** * Context passed to the plugins `start` method. * diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 229ffc4d21575..6d4181e5e1ab3 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -629,7 +629,8 @@ export interface CoreSetup { context: ContextSetup; // (undocumented) elasticsearch: ElasticsearchServiceSetup; - getStartServices(): Promise<[CoreStart, TPluginsStart]>; + // (undocumented) + getStartServices: StartServicesAccessor; // (undocumented) http: HttpServiceSetup; // (undocumented) @@ -2269,6 +2270,9 @@ export type SharedGlobalConfig = RecursiveReadonly_2<{ path: Pick; }>; +// @public +export type StartServicesAccessor = () => Promise<[CoreStart, TPluginsStart]>; + // @public export type StringValidation = StringValidationRegex | StringValidationRegexString; diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 370abc120d475..8ed64f004c9be 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -18,14 +18,12 @@ */ export const storybookAliases = { - advanced_ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js', apm: 'x-pack/legacy/plugins/apm/scripts/storybook.js', canvas: 'x-pack/legacy/plugins/canvas/scripts/storybook_new.js', codeeditor: 'src/plugins/kibana_react/public/code_editor/scripts/storybook.ts', - dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/scripts/storybook.js', drilldowns: 'x-pack/plugins/drilldowns/scripts/storybook.js', embeddable: 'src/plugins/embeddable/scripts/storybook.js', infra: 'x-pack/legacy/plugins/infra/scripts/storybook.js', siem: 'x-pack/legacy/plugins/siem/scripts/storybook.js', - ui_actions: 'src/plugins/ui_actions/scripts/storybook.js', + ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js', }; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/index.ts b/src/legacy/core_plugins/kibana/public/dashboard/index.ts index 5b9fb8c0b6360..8900d017ef81a 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/index.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/index.ts @@ -21,7 +21,6 @@ import { PluginInitializerContext } from 'kibana/public'; import { DashboardPlugin } from './plugin'; export * from './np_ready/dashboard_constants'; -export { createSavedDashboardLoader } from './saved_dashboard/saved_dashboards'; // Core will be looking for this when loading our plugin in the new platform export const plugin = (context: PluginInitializerContext) => { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/is_dashboard_doc.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/is_dashboard_doc.ts index f3bc2a4a4e155..d8f8882a218dd 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/is_dashboard_doc.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/is_dashboard_doc.ts @@ -17,7 +17,7 @@ * under the License. */ -import { DashboardDoc730ToLatest } from './types'; +import { DashboardDoc730ToLatest } from '../../../../../../plugins/dashboard/public'; import { isDoc } from '../../../migrations/is_doc'; export function isDashboardDoc( diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.test.ts index 2189b53ac81ee..e37c8de08fec4 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.test.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.test.ts @@ -44,8 +44,6 @@ import { RawSavedDashboardPanel620, RawSavedDashboardPanel630, RawSavedDashboardPanel640To720, -} from './types'; -import { DEFAULT_PANEL_WIDTH, DEFAULT_PANEL_HEIGHT, } from '../../../../../../plugins/dashboard/public'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.ts index 6b037fa63cf68..047ec15f9a5d6 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrate_to_730_panels.ts @@ -18,17 +18,17 @@ */ import { i18n } from '@kbn/i18n'; import semver from 'semver'; -import { GridData } from 'src/plugins/dashboard/public'; - import uuid from 'uuid'; import { + GridData, RawSavedDashboardPanelTo60, RawSavedDashboardPanel630, RawSavedDashboardPanel640To720, RawSavedDashboardPanel730ToLatest, RawSavedDashboardPanel610, RawSavedDashboardPanel620, -} from './types'; +} from '../../../../../../plugins/dashboard/public'; + import { SavedDashboardPanelTo60, SavedDashboardPanel620, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.test.ts index 86d399d219a26..34bb46ce5d407 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.test.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.test.ts @@ -24,7 +24,7 @@ import { DashboardDoc730ToLatest, RawSavedDashboardPanel730ToLatest, DashboardDocPre700, -} from './types'; +} from '../../../../../../plugins/dashboard/public'; const mockLogger = { warning: () => {}, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.ts b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.ts index 1ab5738cf4752..56856f7b21303 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/migrations/migrations_730.ts @@ -20,7 +20,10 @@ // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { SavedObjectsMigrationLogger } from 'src/core/server'; import { inspect } from 'util'; -import { DashboardDoc730ToLatest, DashboardDoc700To720 } from './types'; +import { + DashboardDoc730ToLatest, + DashboardDoc700To720, +} from '../../../../../../plugins/dashboard/public'; import { isDashboardDoc } from './is_dashboard_doc'; import { moveFiltersToQuery } from './move_filters_to_query'; import { migratePanelsTo730 } from './migrate_to_730_panels'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx index 4e9942767186e..e21033ffe10ec 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app.tsx @@ -22,7 +22,7 @@ import { Subscription } from 'rxjs'; import { History } from 'history'; import { ViewMode } from '../../../../embeddable_api/public/np_ready/public'; -import { SavedObjectDashboard } from '../saved_dashboard/saved_dashboard'; +import { SavedObjectDashboard } from '../../../../../../plugins/dashboard/public'; import { DashboardAppState, SavedDashboardPanel } from './types'; import { IIndexPattern, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx index f1e1f20de1ce6..0c6686c993371 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx @@ -890,7 +890,8 @@ export class DashboardAppController { share.toggleShareContextMenu({ anchorElement, allowEmbed: true, - allowShortUrl: !dashboardConfig.getHideWriteControls(), + allowShortUrl: + !dashboardConfig.getHideWriteControls() || dashboardCapabilities.createShortUrl, shareableUrl: unhashUrl(window.location.href), objectId: dash.id, objectType: 'dashboard', diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts index f29721e3c3d5c..171f08b45cf8d 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_state_manager.ts @@ -23,7 +23,10 @@ import { Observable, Subscription } from 'rxjs'; import { Moment } from 'moment'; import { History } from 'history'; -import { DashboardContainer } from 'src/plugins/dashboard/public'; +import { + DashboardContainer, + SavedObjectDashboard, +} from '../../../../../../plugins/dashboard/public'; import { ViewMode } from '../../../../../../plugins/embeddable/public'; import { migrateLegacyQuery } from '../legacy_imports'; import { @@ -35,8 +38,6 @@ import { import { getAppStateDefaults, migrateAppState } from './lib'; import { convertPanelStateToSavedDashboardPanel } from './lib/embeddable_saved_object_converters'; import { FilterUtils } from './lib/filter_utils'; -import { SavedObjectDashboard } from '../saved_dashboard/saved_dashboard'; - import { DashboardAppState, DashboardAppStateDefaults, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/embeddable_saved_object_converters.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/embeddable_saved_object_converters.ts index 7d5a378885470..500ee7e28daa6 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/embeddable_saved_object_converters.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/embeddable_saved_object_converters.ts @@ -17,7 +17,7 @@ * under the License. */ import { omit } from 'lodash'; -import { DashboardPanelState } from 'src/plugins/dashboard/public'; +import { DashboardPanelState } from '../../../../../../../plugins/dashboard/public'; import { SavedDashboardPanel } from '../types'; export function convertSavedDashboardPanelToPanelState( diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/get_app_state_defaults.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/get_app_state_defaults.ts index eceb51f17d164..b3acefeba0146 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/get_app_state_defaults.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/get_app_state_defaults.ts @@ -18,7 +18,7 @@ */ import { ViewMode } from '../../../../../../../plugins/embeddable/public'; -import { SavedObjectDashboard } from '../../saved_dashboard/saved_dashboard'; +import { SavedObjectDashboard } from '../../../../../../../plugins/dashboard/public'; import { DashboardAppStateDefaults } from '../types'; export function getAppStateDefaults( diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/update_saved_dashboard.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/update_saved_dashboard.ts index ec8073c0f72f7..dee279550aa6a 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/update_saved_dashboard.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/lib/update_saved_dashboard.ts @@ -20,7 +20,7 @@ import _ from 'lodash'; import { RefreshInterval, TimefilterContract } from 'src/plugins/data/public'; import { FilterUtils } from './filter_utils'; -import { SavedObjectDashboard } from '../../saved_dashboard/saved_dashboard'; +import { SavedObjectDashboard } from '../../../../../../../plugins/dashboard/public'; import { DashboardAppState } from '../types'; export function updateSavedDashboard( diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/test_utils/get_saved_dashboard_mock.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/test_utils/get_saved_dashboard_mock.ts index 60b2a33f720ec..53618f1cfe5fa 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/test_utils/get_saved_dashboard_mock.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/test_utils/get_saved_dashboard_mock.ts @@ -18,7 +18,7 @@ */ import { searchSourceMock } from '../../../../../../../plugins/data/public/mocks'; -import { SavedObjectDashboard } from '../../saved_dashboard/saved_dashboard'; +import { SavedObjectDashboard } from '../../../../../../../plugins/dashboard/public/'; export function getSavedDashboardMock( config?: Partial diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/types.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/types.ts index 0f3a7e322ebf3..9f8682f13d811 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/types.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/types.ts @@ -25,19 +25,11 @@ import { RawSavedDashboardPanel630, RawSavedDashboardPanel640To720, RawSavedDashboardPanel730ToLatest, -} from '../migrations/types'; +} from '../../../../../../plugins/dashboard/public'; import { Query, Filter } from '../../../../../../plugins/data/public'; export type NavAction = (anchorElement?: any) => void; -export interface GridData { - w: number; - h: number; - x: number; - y: number; - i: string; -} - /** * This should always represent the latest dashboard panel shape, after all possible migrations. */ diff --git a/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts b/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts index a9ee77921ed4a..7452807454fe7 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/plugin.ts @@ -49,8 +49,8 @@ import { KibanaLegacySetup, KibanaLegacyStart, } from '../../../../../plugins/kibana_legacy/public'; -import { createSavedDashboardLoader } from './saved_dashboard/saved_dashboards'; import { createKbnUrlTracker } from '../../../../../plugins/kibana_utils/public'; +import { DashboardStart } from '../../../../../plugins/dashboard/public'; export interface DashboardPluginStartDependencies { data: DataPublicPluginStart; @@ -58,6 +58,7 @@ export interface DashboardPluginStartDependencies { navigation: NavigationStart; share: SharePluginStart; kibanaLegacy: KibanaLegacyStart; + dashboard: DashboardStart; } export interface DashboardPluginSetupDependencies { @@ -74,6 +75,7 @@ export class DashboardPlugin implements Plugin { navigation: NavigationStart; share: SharePluginStart; dashboardConfig: KibanaLegacyStart['dashboardConfig']; + dashboard: DashboardStart; } | null = null; private appStateUpdater = new BehaviorSubject(() => ({})); @@ -129,13 +131,9 @@ export class DashboardPlugin implements Plugin { share, data: dataStart, dashboardConfig, + dashboard: { getSavedDashboardLoader }, } = this.startDependencies; - const savedDashboards = createSavedDashboardLoader({ - savedObjectsClient, - indexPatterns: dataStart.indexPatterns, - chrome: coreStart.chrome, - overlays: coreStart.overlays, - }); + const savedDashboards = getSavedDashboardLoader(); const deps: RenderDeps = { pluginInitializerContext: this.initializerContext, @@ -199,6 +197,7 @@ export class DashboardPlugin implements Plugin { data, share, kibanaLegacy: { dashboardConfig }, + dashboard, }: DashboardPluginStartDependencies ) { this.startDependencies = { @@ -208,6 +207,7 @@ export class DashboardPlugin implements Plugin { navigation, share, dashboardConfig, + dashboard, }; } diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/openmetrics.svg b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/openmetrics.svg new file mode 100644 index 0000000000000..feccb88a3f34b --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/openmetrics.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts b/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts index cb9ac0e01bb7f..f3a37e2b7348f 100644 --- a/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts +++ b/src/legacy/core_plugins/kibana/public/management/saved_object_registry.ts @@ -21,7 +21,6 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { npStart } from 'ui/new_platform'; import { SavedObjectLoader } from '../../../../../plugins/saved_objects/public'; -import { createSavedDashboardLoader } from '../dashboard'; import { start as visualizations } from '../../../visualizations/public/np_ready/public/legacy'; import { createSavedSearchesLoader } from '../../../../../plugins/discover/public'; @@ -70,7 +69,7 @@ savedObjectManagementRegistry.register({ savedObjectManagementRegistry.register({ id: 'savedDashboards', - service: createSavedDashboardLoader(services), + service: npStart.plugins.dashboard.getSavedDashboardLoader(), title: i18n.translate('kbn.dashboard.savedDashboardsTitle', { defaultMessage: 'dashboards', }), diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts index 4b21be83f1722..342824bade3dd 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts @@ -45,7 +45,6 @@ import { PersistedState } from '../../../../../../../plugins/visualizations/publ import { buildPipeline } from '../legacy/build_pipeline'; import { Vis } from '../vis'; import { getExpressions, getUiActions } from '../services'; -import { VisualizationsStartDeps } from '../plugin'; import { VIS_EVENT_TO_TRIGGER } from './events'; const getKeys = (o: T): Array => Object.keys(o) as Array; @@ -57,7 +56,6 @@ export interface VisualizeEmbeddableConfiguration { editable: boolean; appState?: { save(): void }; uiState?: PersistedState; - uiActions?: VisualizationsStartDeps['uiActions']; } export interface VisualizeInput extends EmbeddableInput { @@ -96,7 +94,7 @@ export class VisualizeEmbeddable extends Embeddable { public readonly type = VISUALIZE_EMBEDDABLE_TYPE; - constructor( - private readonly getUiActions: () => Promise< - Pick['uiActions'] - > - ) { + constructor() { super({ savedObjectMetaData: { name: i18n.translate('visualizations.savedObjectName', { defaultMessage: 'Visualization' }), @@ -119,8 +114,6 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory< const indexPattern = vis.data.indexPattern; const indexPatterns = indexPattern ? [indexPattern] : []; - const uiActions = await this.getUiActions(); - const editable = await this.isEditable(); return new VisualizeEmbeddable( getTimeFilter(), @@ -131,7 +124,6 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory< editable, appState: input.appState, uiState: input.uiState, - uiActions, }, input, parent diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts index dcd11c920f17c..17f777e4e80e1 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts @@ -17,7 +17,7 @@ * under the License. */ -import { CoreSetup, PluginInitializerContext } from '../../../../../../core/public'; +import { PluginInitializerContext } from '../../../../../../core/public'; import { VisualizationsSetup, VisualizationsStart } from './'; import { VisualizationsPlugin } from './plugin'; import { coreMock } from '../../../../../../core/public/mocks'; @@ -26,7 +26,6 @@ import { expressionsPluginMock } from '../../../../../../plugins/expressions/pub import { dataPluginMock } from '../../../../../../plugins/data/public/mocks'; import { usageCollectionPluginMock } from '../../../../../../plugins/usage_collection/public/mocks'; import { uiActionsPluginMock } from '../../../../../../plugins/ui_actions/public/mocks'; -import { VisualizationsStartDeps } from './plugin'; const createSetupContract = (): VisualizationsSetup => ({ createBaseVisualization: jest.fn(), @@ -49,7 +48,7 @@ const createStartContract = (): VisualizationsStart => ({ const createInstance = async () => { const plugin = new VisualizationsPlugin({} as PluginInitializerContext); - const setup = plugin.setup(coreMock.createSetup() as CoreSetup, { + const setup = plugin.setup(coreMock.createSetup(), { data: dataPluginMock.createSetupContract(), expressions: expressionsPluginMock.createSetupContract(), embeddable: embeddablePluginMock.createSetupContract(), diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts index c826841e2bcf3..3ade6cee0d4d2 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts @@ -111,7 +111,7 @@ export class VisualizationsPlugin constructor(initializerContext: PluginInitializerContext) {} public setup( - core: CoreSetup, + core: CoreSetup, { expressions, embeddable, usageCollection, data }: VisualizationsSetupDeps ): VisualizationsSetup { setUISettings(core.uiSettings); @@ -120,9 +120,7 @@ export class VisualizationsPlugin expressions.registerFunction(visualizationFunction); expressions.registerRenderer(visualizationRenderer); - const embeddableFactory = new VisualizeEmbeddableFactory( - async () => (await core.getStartServices())[1].uiActions - ); + const embeddableFactory = new VisualizeEmbeddableFactory(); embeddable.registerEmbeddableFactory(VISUALIZE_EMBEDDABLE_TYPE, embeddableFactory); return { diff --git a/src/legacy/server/status/index.js b/src/legacy/server/status/index.js index a9544049182a7..df02b3c45ec2f 100644 --- a/src/legacy/server/status/index.js +++ b/src/legacy/server/status/index.js @@ -57,7 +57,7 @@ export function statusMixin(kbnServer, server, config) { // init routes registerStatusPage(kbnServer, server, config); registerStatusApi(kbnServer, server, config); - registerStatsApi(usageCollection, server, config); + registerStatsApi(usageCollection, server, config, kbnServer); // expore shared functionality server.decorate('server', 'getOSInfo', getOSInfo); diff --git a/src/legacy/server/status/routes/api/register_stats.js b/src/legacy/server/status/routes/api/register_stats.js index e218c1caf1701..2dd66cb8caff7 100644 --- a/src/legacy/server/status/routes/api/register_stats.js +++ b/src/legacy/server/status/routes/api/register_stats.js @@ -21,7 +21,7 @@ import Joi from 'joi'; import boom from 'boom'; import { i18n } from '@kbn/i18n'; import { wrapAuthConfig } from '../../wrap_auth_config'; -import { KIBANA_STATS_TYPE } from '../../constants'; +import { getKibanaInfoForStats } from '../../lib'; const STATS_NOT_READY_MESSAGE = i18n.translate('server.stats.notReadyMessage', { defaultMessage: 'Stats are not ready yet. Please try again later.', @@ -37,7 +37,7 @@ const STATS_NOT_READY_MESSAGE = i18n.translate('server.stats.notReadyMessage', { * - Any other value causes a statusCode 400 response (Bad Request) * Including ?exclude_usage in the query string excludes the usage stats from the response. Same value semantics as ?extended */ -export function registerStatsApi(usageCollection, server, config) { +export function registerStatsApi(usageCollection, server, config, kbnServer) { const wrapAuth = wrapAuthConfig(config.get('status.allowAnonymous')); const getClusterUuid = async callCluster => { @@ -50,6 +50,17 @@ export function registerStatsApi(usageCollection, server, config) { return usageCollection.toObject(usage); }; + let lastMetrics = null; + /* kibana_stats gets singled out from the collector set as it is used + * for health-checking Kibana and fetch does not rely on fetching data + * from ES */ + server.newPlatform.setup.core.metrics.getOpsMetrics$().subscribe(metrics => { + lastMetrics = { + ...metrics, + timestamp: new Date().toISOString(), + }; + }); + server.route( wrapAuth({ method: 'GET', @@ -133,15 +144,15 @@ export function registerStatsApi(usageCollection, server, config) { } } - /* kibana_stats gets singled out from the collector set as it is used - * for health-checking Kibana and fetch does not rely on fetching data - * from ES */ - const kibanaStatsCollector = usageCollection.getCollectorByType(KIBANA_STATS_TYPE); - if (!(await kibanaStatsCollector.isReady())) { + if (!lastMetrics) { return boom.serverUnavailable(STATS_NOT_READY_MESSAGE); } - let kibanaStats = await kibanaStatsCollector.fetch(); - kibanaStats = usageCollection.toApiFieldNames(kibanaStats); + const kibanaStats = usageCollection.toApiFieldNames({ + ...lastMetrics, + kibana: getKibanaInfoForStats(server, kbnServer), + last_updated: new Date().toISOString(), + collection_interval_in_millis: config.get('ops.interval'), + }); return { ...kibanaStats, diff --git a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js index 809022620e69d..67877c5382633 100644 --- a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js +++ b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js @@ -330,6 +330,9 @@ export const npStart = { getHideWriteControls: sinon.fake(), }, }, + dashboard: { + getSavedDashboardLoader: sinon.fake(), + }, data: { actions: { createFiltersFromEvent: Promise.resolve(['yes']), diff --git a/src/legacy/ui/public/new_platform/new_platform.ts b/src/legacy/ui/public/new_platform/new_platform.ts index ee14f192a2149..b315abec1a64b 100644 --- a/src/legacy/ui/public/new_platform/new_platform.ts +++ b/src/legacy/ui/public/new_platform/new_platform.ts @@ -22,6 +22,7 @@ import { IScope } from 'angular'; import { UiActionsStart, UiActionsSetup } from 'src/plugins/ui_actions/public'; import { EmbeddableStart, EmbeddableSetup } from 'src/plugins/embeddable/public'; import { createBrowserHistory } from 'history'; +import { DashboardStart } from '../../../../plugins/dashboard/public'; import { LegacyCoreSetup, LegacyCoreStart, @@ -104,6 +105,7 @@ export interface PluginsStart { advancedSettings: AdvancedSettingsStart; discover: DiscoverStart; telemetry?: TelemetryPluginStart; + dashboard: DashboardStart; } export const npSetup = { diff --git a/src/plugins/advanced_settings/public/management_app/index.tsx b/src/plugins/advanced_settings/public/management_app/index.tsx index 27d3114051c16..53b8f9983aa27 100644 --- a/src/plugins/advanced_settings/public/management_app/index.tsx +++ b/src/plugins/advanced_settings/public/management_app/index.tsx @@ -24,7 +24,7 @@ import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; import { AdvancedSettings } from './advanced_settings'; import { ManagementSetup } from '../../../management/public'; -import { CoreSetup } from '../../../../core/public'; +import { StartServicesAccessor } from '../../../../core/public'; import { ComponentRegistry } from '../types'; const title = i18n.translate('advancedSettings.advancedSettingsLabel', { @@ -48,7 +48,7 @@ export async function registerAdvSettingsMgmntApp({ componentRegistry, }: { management: ManagementSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; componentRegistry: ComponentRegistry['start']; }) { const kibanaSection = management.sections.getSection('kibana'); diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cat.aliases.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cat.aliases.json index 2135bd67e57d8..40b0e56782641 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cat.aliases.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cat.aliases.json @@ -6,7 +6,14 @@ "h": [], "help": "__flag__", "s": [], - "v": "__flag__" + "v": "__flag__", + "expand_wildcards": [ + "open", + "closed", + "hidden", + "none", + "all" + ] }, "methods": [ "GET" diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cat.indices.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cat.indices.json index e6ca1fb575396..410350df13721 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cat.indices.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cat.indices.json @@ -36,7 +36,14 @@ "nanos" ], "v": "__flag__", - "include_unloaded_segments": "__flag__" + "include_unloaded_segments": "__flag__", + "expand_wildcards": [ + "open", + "closed", + "hidden", + "none", + "all" + ] }, "methods": [ "GET" diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json new file mode 100644 index 0000000000000..e935b8999e6d3 --- /dev/null +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json @@ -0,0 +1,15 @@ +{ + "cluster.delete_component_template": { + "url_params": { + "timeout": "", + "master_timeout": "" + }, + "methods": [ + "DELETE" + ], + "patterns": [ + "_component_template/{name}" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-component-templates.html" + } +} diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.health.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.health.json index 64ede603c0e0d..1758ea44d92c0 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.health.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.health.json @@ -4,6 +4,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.state.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.state.json index ba9c8d427e7bd..fb4a02c603174 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.state.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.state.json @@ -11,6 +11,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/count.json b/src/plugins/console/server/lib/spec_definitions/json/generated/count.json index bd69fd0c77ec8..67386eb7c6f1b 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/count.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/count.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/delete_by_query.json b/src/plugins/console/server/lib/spec_definitions/json/generated/delete_by_query.json index 2d1636d5f2c02..e01ea8b2dec6d 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/delete_by_query.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/delete_by_query.json @@ -1,6 +1,7 @@ { "delete_by_query": { "url_params": { + "analyzer": "", "analyze_wildcard": "__flag__", "default_operator": [ "AND", @@ -17,6 +18,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/field_caps.json b/src/plugins/console/server/lib/spec_definitions/json/generated/field_caps.json index 5e632018bef25..4bf63d7566788 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/field_caps.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/field_caps.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.clear_cache.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.clear_cache.json index f5cf05c9a3f7f..fc84d07df88a4 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.clear_cache.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.clear_cache.json @@ -9,6 +9,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.close.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.close.json index 676f20632e63b..1b58a27829bc7 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.close.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.close.json @@ -8,6 +8,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.create.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.create.json index 8227e38d3c6d9..1970f88b30958 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.create.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.create.json @@ -1,6 +1,7 @@ { "indices.create": { "url_params": { + "include_type_name": "__flag__", "wait_for_active_shards": "", "timeout": "", "master_timeout": "" diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete.json index b006d5ea7a3cb..084828108123b 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete.json @@ -8,6 +8,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists.json index 33c845210ea87..09f6c7fd780f8 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_alias.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_alias.json index d302bbe6b93de..4b93184ed52f1 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_alias.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_alias.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_type.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_type.json index 70d35e6c453c9..0b11356155b50 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_type.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_type.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.flush.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.flush.json index 0ad1a250229b2..63c86d10a9864 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.flush.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.flush.json @@ -8,6 +8,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.forcemerge.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.forcemerge.json index 0e705e2e721ee..b642d5f04a044 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.forcemerge.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.forcemerge.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get.json index 7ca9e88274aa5..6df796ed6c4cf 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get.json @@ -1,12 +1,14 @@ { "indices.get": { "url_params": { + "include_type_name": "__flag__", "local": "__flag__", "ignore_unavailable": "__flag__", "allow_no_indices": "__flag__", "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_alias.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_alias.json index d687cab56630f..95bc74edc5865 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_alias.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_alias.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_field_mapping.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_field_mapping.json index ea952435566ed..c95e2efc73fab 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_field_mapping.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_field_mapping.json @@ -1,12 +1,14 @@ { "indices.get_field_mapping": { "url_params": { + "include_type_name": "__flag__", "include_defaults": "__flag__", "ignore_unavailable": "__flag__", "allow_no_indices": "__flag__", "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_mapping.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_mapping.json index 73f4e42262bf2..555137d0e2ee0 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_mapping.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_mapping.json @@ -1,11 +1,13 @@ { "indices.get_mapping": { "url_params": { + "include_type_name": "__flag__", "ignore_unavailable": "__flag__", "allow_no_indices": "__flag__", "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_settings.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_settings.json index 1c84258d0fce9..a6777f7a820aa 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_settings.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_settings.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_template.json index f5902929c25cc..d5f52ec76b374 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_template.json @@ -1,6 +1,7 @@ { "indices.get_template": { "url_params": { + "include_type_name": "__flag__", "flat_settings": "__flag__", "master_timeout": "", "local": "__flag__" diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_upgrade.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_upgrade.json index d781172c54d63..99ac958523084 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_upgrade.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_upgrade.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.open.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.open.json index b5c4c5501d05d..6369238739203 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.open.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.open.json @@ -8,6 +8,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_mapping.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_mapping.json index 07a62a64b64e1..e36783c815e3f 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_mapping.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_mapping.json @@ -1,6 +1,7 @@ { "indices.put_mapping": { "url_params": { + "include_type_name": "__flag__", "timeout": "", "master_timeout": "", "ignore_unavailable": "__flag__", @@ -8,6 +9,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_settings.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_settings.json index fe7b938d2f3fc..a2508cd0fc817 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_settings.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_settings.json @@ -9,6 +9,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_template.json index 54a7625a2713c..e6317bd6eb537 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_template.json @@ -1,11 +1,10 @@ { "indices.put_template": { "url_params": { + "include_type_name": "__flag__", "order": "", "create": "__flag__", - "timeout": "", - "master_timeout": "", - "flat_settings": "__flag__" + "master_timeout": "" }, "methods": [ "PUT", diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.refresh.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.refresh.json index 54cd2a869902a..2906349d3fdae 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.refresh.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.refresh.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.rollover.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.rollover.json index 19e0f1f909ab8..7fa76a687eb77 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.rollover.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.rollover.json @@ -1,6 +1,7 @@ { "indices.rollover": { "url_params": { + "include_type_name": "__flag__", "timeout": "", "dry_run": "__flag__", "master_timeout": "", diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.segments.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.segments.json index 9e2eb6efce27e..b3c07150699af 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.segments.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.segments.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.shard_stores.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.shard_stores.json index f8e026eb89984..c50f4cf501698 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.shard_stores.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.shard_stores.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.stats.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.stats.json index c3fc0f8f7055f..1fa32265c91ee 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.stats.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.stats.json @@ -16,6 +16,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.upgrade.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.upgrade.json index 68ee06dd1b0bd..484115bb9b260 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.upgrade.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.upgrade.json @@ -5,6 +5,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.validate_query.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.validate_query.json index 33720576ef8a3..315aa13d4b4e8 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.validate_query.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.validate_query.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/msearch_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/msearch_template.json index c2f741066bbdb..0b0ca087b1819 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/msearch_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/msearch_template.json @@ -9,7 +9,8 @@ ], "typed_keys": "__flag__", "max_concurrent_searches": "", - "rest_total_hits_as_int": "__flag__" + "rest_total_hits_as_int": "__flag__", + "ccs_minimize_roundtrips": "__flag__" }, "methods": [ "GET", diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/rank_eval.json b/src/plugins/console/server/lib/spec_definitions/json/generated/rank_eval.json index c2bed081124a8..4d73e58bd4c06 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/rank_eval.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/rank_eval.json @@ -6,6 +6,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/search.json b/src/plugins/console/server/lib/spec_definitions/json/generated/search.json index eb21b43644d77..78b969d3ed8f2 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/search.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/search.json @@ -19,6 +19,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/search_shards.json b/src/plugins/console/server/lib/spec_definitions/json/generated/search_shards.json index cbeb0a429352d..b0819f8e066c8 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/search_shards.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/search_shards.json @@ -9,6 +9,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ] diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/search_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/search_template.json index cf5a5c5f32db3..748326522e5c2 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/search_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/search_template.json @@ -7,6 +7,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], @@ -22,7 +23,8 @@ "explain": "__flag__", "profile": "__flag__", "typed_keys": "__flag__", - "rest_total_hits_as_int": "__flag__" + "rest_total_hits_as_int": "__flag__", + "ccs_minimize_roundtrips": "__flag__" }, "methods": [ "GET", diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/update_by_query.json b/src/plugins/console/server/lib/spec_definitions/json/generated/update_by_query.json index 393197949e86c..596f8f8b83963 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/update_by_query.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/update_by_query.json @@ -18,6 +18,7 @@ "expand_wildcards": [ "open", "closed", + "hidden", "none", "all" ], diff --git a/src/plugins/console/server/lib/spec_definitions/json/overrides/cluster.health.json b/src/plugins/console/server/lib/spec_definitions/json/overrides/cluster.health.json index 7e1655e680b8f..949b897b29ff4 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/overrides/cluster.health.json +++ b/src/plugins/console/server/lib/spec_definitions/json/overrides/cluster.health.json @@ -1,11 +1,38 @@ { "cluster.health": { "url_params": { - "master_timeout": "30s", - "timeout": "30s", - "wait_for_relocating_shards": 0, - "wait_for_active_shards": 0, - "wait_for_nodes": 0 + "expand_wildcards": [ + "open", + "closed", + "hidden", + "none", + "all" + ], + "level": [ + "cluster", + "indices", + "shards" + ], + "local": "__flag__", + "master_timeout": "", + "timeout": "", + "wait_for_active_shards": "", + "wait_for_nodes": "", + "wait_for_events": [ + "immediate", + "urgent", + "high", + "normal", + "low", + "languid" + ], + "wait_for_no_relocating_shards": "__flag__", + "wait_for_no_initializing_shards": "__flag__", + "wait_for_status": [ + "green", + "yellow", + "red" + ] } } } diff --git a/src/plugins/console/server/lib/spec_definitions/json/overrides/indices.get_template.json b/src/plugins/console/server/lib/spec_definitions/json/overrides/indices.get_template.json deleted file mode 100644 index e0cbcc9cee2ec..0000000000000 --- a/src/plugins/console/server/lib/spec_definitions/json/overrides/indices.get_template.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "indices.get_template": { - "patterns": [ - "_template", - "_template/{template}" - ] - } -} diff --git a/src/plugins/dashboard/kibana.json b/src/plugins/dashboard/kibana.json index e5a657555819a..e35599a5f0b66 100644 --- a/src/plugins/dashboard/kibana.json +++ b/src/plugins/dashboard/kibana.json @@ -5,7 +5,8 @@ "data", "embeddable", "inspector", - "uiActions" + "uiActions", + "savedObjects" ], "optionalPlugins": [ "share" diff --git a/src/plugins/dashboard/public/actions/replace_panel_action.tsx b/src/plugins/dashboard/public/actions/replace_panel_action.tsx index 4e20aa3c35088..21ec961917d17 100644 --- a/src/plugins/dashboard/public/actions/replace_panel_action.tsx +++ b/src/plugins/dashboard/public/actions/replace_panel_action.tsx @@ -37,7 +37,7 @@ export interface ReplacePanelActionContext { export class ReplacePanelAction implements ActionByType { public readonly type = ACTION_REPLACE_PANEL; public readonly id = ACTION_REPLACE_PANEL; - public order = 3; + public order = 11; constructor( private core: CoreStart, diff --git a/src/plugins/ui_actions/public/util/index.ts b/src/plugins/dashboard/public/bwc/index.ts similarity index 92% rename from src/plugins/ui_actions/public/util/index.ts rename to src/plugins/dashboard/public/bwc/index.ts index 53c6109cac4ca..d8f7b5091eb8f 100644 --- a/src/plugins/ui_actions/public/util/index.ts +++ b/src/plugins/dashboard/public/bwc/index.ts @@ -17,5 +17,4 @@ * under the License. */ -export * from './presentable'; -export * from './configurable'; +export * from './types'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/migrations/types.ts b/src/plugins/dashboard/public/bwc/types.ts similarity index 91% rename from src/legacy/core_plugins/kibana/public/dashboard/migrations/types.ts rename to src/plugins/dashboard/public/bwc/types.ts index c264358a8f81f..e9b9d392e9b7d 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/migrations/types.ts +++ b/src/plugins/dashboard/public/bwc/types.ts @@ -17,8 +17,27 @@ * under the License. */ -import { GridData } from '../np_ready/types'; -import { Doc, DocPre700 } from '../../../migrations/types'; +import { SavedObjectReference } from 'kibana/public'; +import { GridData } from '../../../../plugins/dashboard/public'; + +export interface SavedObjectAttributes { + kibanaSavedObjectMeta: { + searchSourceJSON: string; + }; +} + +export interface Doc { + references: SavedObjectReference[]; + attributes: Attributes; + id: string; + type: string; +} + +export interface DocPre700 { + attributes: Attributes; + id: string; + type: string; +} export interface SavedObjectAttributes { kibanaSavedObjectMeta: { diff --git a/src/plugins/ui_actions/public/actions/action_internal.test.ts b/src/plugins/dashboard/public/dashboard_constants.ts similarity index 66% rename from src/plugins/ui_actions/public/actions/action_internal.test.ts rename to src/plugins/dashboard/public/dashboard_constants.ts index b14346180c274..0820ebd371004 100644 --- a/src/plugins/ui_actions/public/actions/action_internal.test.ts +++ b/src/plugins/dashboard/public/dashboard_constants.ts @@ -17,17 +17,16 @@ * under the License. */ -import { ActionDefinition } from './action'; -import { ActionInternal } from './action_internal'; - -const defaultActionDef: ActionDefinition = { - id: 'test-action', - execute: jest.fn(), +export const DashboardConstants = { + ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM: 'addToDashboard', + LANDING_PAGE_PATH: '/dashboards', + CREATE_NEW_DASHBOARD_URL: '/dashboard', + ADD_EMBEDDABLE_ID: 'addEmbeddableId', + ADD_EMBEDDABLE_TYPE: 'addEmbeddableType', + DASHBOARDS_ID: 'dashboards', + DASHBOARD_ID: 'dashboard', }; -describe('ActionInternal', () => { - test('can instantiate from action definition', () => { - const action = new ActionInternal(defaultActionDef); - expect(action.id).toBe('test-action'); - }); -}); +export function createDashboardEditUrl(id: string) { + return `/dashboard/${id}`; +} diff --git a/src/plugins/dashboard/public/embeddable/index.ts b/src/plugins/dashboard/public/embeddable/index.ts index 58bfd5eedefcb..fcc5fe5202bd2 100644 --- a/src/plugins/dashboard/public/embeddable/index.ts +++ b/src/plugins/dashboard/public/embeddable/index.ts @@ -21,7 +21,7 @@ export { DashboardContainerFactory } from './dashboard_container_factory'; export { DashboardContainer, DashboardContainerInput } from './dashboard_container'; export { createPanelState } from './panel'; -export { DashboardPanelState, GridData } from './types'; +export * from './types'; export { DASHBOARD_GRID_COLUMN_COUNT, diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index c6846346b64ef..070e437ce52ef 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -22,14 +22,43 @@ import './index.scss'; import { PluginInitializerContext } from '../../../core/public'; import { DashboardEmbeddableContainerPublicPlugin } from './plugin'; -export * from './types'; -export * from './actions'; -export * from './embeddable'; +/** + * These types can probably be internal once all of dashboard app is migrated into this plugin. Right + * now, migrations are still in legacy land. + */ +export { + DashboardDoc730ToLatest, + DashboardDoc700To720, + RawSavedDashboardPanelTo60, + RawSavedDashboardPanel610, + RawSavedDashboardPanel620, + RawSavedDashboardPanel630, + RawSavedDashboardPanel640To720, + RawSavedDashboardPanel730ToLatest, + DashboardDocPre700, +} from './bwc'; -export function plugin(initializerContext: PluginInitializerContext) { - return new DashboardEmbeddableContainerPublicPlugin(initializerContext); -} +export {} from './types'; +export {} from './actions'; +export { + DashboardContainer, + DashboardContainerInput, + DashboardContainerFactory, + DASHBOARD_CONTAINER_TYPE, + DashboardPanelState, + // Types below here can likely be made private when dashboard app moved into this NP plugin. + DEFAULT_PANEL_WIDTH, + DEFAULT_PANEL_HEIGHT, + GridData, +} from './embeddable'; + +export { SavedObjectDashboard } from './saved_dashboards'; +export { DashboardStart } from './plugin'; export { DashboardEmbeddableContainerPublicPlugin as Plugin }; export { DASHBOARD_APP_URL_GENERATOR } from './url_generator'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new DashboardEmbeddableContainerPublicPlugin(initializerContext); +} diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index d663c736e5aed..df3c312c7ae1b 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -21,13 +21,18 @@ import * as React from 'react'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { SharePluginSetup } from 'src/plugins/share/public'; +import { + CONTEXT_MENU_TRIGGER, + EmbeddableSetup, + EmbeddableStart, +} from '../../../plugins/embeddable/public'; +import { DataPublicPluginStart } from '../../../plugins/data/public'; +import { SharePluginSetup } from '../../../plugins/share/public'; import { UiActionsSetup, UiActionsStart } from '../../../plugins/ui_actions/public'; -import { CONTEXT_MENU_TRIGGER, EmbeddableSetup, EmbeddableStart } from './embeddable_plugin'; -import { ExpandPanelAction, ReplacePanelAction } from '.'; +import { ExpandPanelAction, ReplacePanelAction } from './actions'; import { DashboardContainerFactory } from './embeddable/dashboard_container_factory'; import { Start as InspectorStartContract } from '../../../plugins/inspector/public'; -import { getSavedObjectFinder } from '../../../plugins/saved_objects/public'; +import { getSavedObjectFinder, SavedObjectLoader } from '../../../plugins/saved_objects/public'; import { ExitFullScreenButton as ExitFullScreenButtonUi, ExitFullScreenButtonProps, @@ -39,6 +44,7 @@ import { DASHBOARD_APP_URL_GENERATOR, createDirectAccessDashboardLinkGenerator, } from './url_generator'; +import { createSavedDashboardLoader } from './saved_dashboards'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -56,10 +62,13 @@ interface StartDependencies { embeddable: EmbeddableStart; inspector: InspectorStartContract; uiActions: UiActionsStart; + data: DataPublicPluginStart; } export type Setup = void; -export type Start = void; +export interface DashboardStart { + getSavedDashboardLoader: () => SavedObjectLoader; +} declare module '../../../plugins/ui_actions/public' { export interface ActionContextMapping { @@ -69,7 +78,7 @@ declare module '../../../plugins/ui_actions/public' { } export class DashboardEmbeddableContainerPublicPlugin - implements Plugin { + implements Plugin { constructor(initializerContext: PluginInitializerContext) {} public setup( @@ -78,7 +87,7 @@ export class DashboardEmbeddableContainerPublicPlugin ): Setup { const expandPanelAction = new ExpandPanelAction(); uiActions.registerAction(expandPanelAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction); const startServices = core.getStartServices(); if (share) { @@ -121,9 +130,12 @@ export class DashboardEmbeddableContainerPublicPlugin embeddable.registerEmbeddableFactory(factory.type, factory); } - public start(core: CoreStart, plugins: StartDependencies): Start { + public start(core: CoreStart, plugins: StartDependencies): DashboardStart { const { notifications } = core; - const { uiActions } = plugins; + const { + uiActions, + data: { indexPatterns }, + } = plugins; const SavedObjectFinder = getSavedObjectFinder(core.savedObjects, core.uiSettings); @@ -134,7 +146,16 @@ export class DashboardEmbeddableContainerPublicPlugin plugins.embeddable.getEmbeddableFactories ); uiActions.registerAction(changeViewAction); - uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, changeViewAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction); + const savedDashboardLoader = createSavedDashboardLoader({ + savedObjectsClient: core.savedObjects.client, + indexPatterns, + chrome: core.chrome, + overlays: core.overlays, + }); + return { + getSavedDashboardLoader: () => savedDashboardLoader, + }; } public stop() {} diff --git a/src/plugins/kibana_utils/index.ts b/src/plugins/dashboard/public/saved_dashboards/index.ts similarity index 87% rename from src/plugins/kibana_utils/index.ts rename to src/plugins/dashboard/public/saved_dashboards/index.ts index 14d6e52dc0465..9b7745bd884f7 100644 --- a/src/plugins/kibana_utils/index.ts +++ b/src/plugins/dashboard/public/saved_dashboards/index.ts @@ -16,5 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - -export { createStateContainer, StateContainer, of } from './common'; +export * from './saved_dashboard_references'; +export * from './saved_dashboard'; +export * from './saved_dashboards'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts similarity index 86% rename from src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard.ts rename to src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index c5ac05b5a77eb..c4ebf4f07a5db 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts @@ -20,16 +20,11 @@ import { createSavedObjectClass, SavedObject, SavedObjectKibanaServices, -} from '../../../../../../plugins/saved_objects/public'; +} from '../../../../plugins/saved_objects/public'; import { extractReferences, injectReferences } from './saved_dashboard_references'; -import { - Filter, - ISearchSource, - Query, - RefreshInterval, -} from '../../../../../../plugins/data/public'; -import { createDashboardEditUrl } from '..'; +import { Filter, ISearchSource, Query, RefreshInterval } from '../../../../plugins/data/public'; +import { createDashboardEditUrl } from '../dashboard_constants'; export interface SavedObjectDashboard extends SavedObject { id?: string; @@ -49,7 +44,9 @@ export interface SavedObjectDashboard extends SavedObject { } // Used only by the savedDashboards service, usually no reason to change this -export function createSavedDashboardClass(services: SavedObjectKibanaServices) { +export function createSavedDashboardClass( + services: SavedObjectKibanaServices +): new (id: string) => SavedObjectDashboard { const SavedObjectClass = createSavedObjectClass(services); class SavedDashboard extends SavedObjectClass { // save these objects with the 'dashboard' type @@ -121,5 +118,7 @@ export function createSavedDashboardClass(services: SavedObjectKibanaServices) { } } - return SavedDashboard; + // Unfortunately this throws a typescript error without the casting. I think it's due to the + // convoluted way SavedObjects are created. + return (SavedDashboard as unknown) as new (id: string) => SavedObjectDashboard; } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.test.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard_references.test.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.test.ts rename to src/plugins/dashboard/public/saved_dashboards/saved_dashboard_references.test.ts diff --git a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard_references.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboard_references.ts rename to src/plugins/dashboard/public/saved_dashboards/saved_dashboard_references.ts diff --git a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboards.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts similarity index 67% rename from src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboards.ts rename to src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts index 2ff76da9c5ca6..2a1e64fa88a02 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/saved_dashboard/saved_dashboards.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts @@ -17,13 +17,22 @@ * under the License. */ -import { - SavedObjectLoader, - SavedObjectKibanaServices, -} from '../../../../../../plugins/saved_objects/public'; +import { SavedObjectsClientContract, ChromeStart, OverlayStart } from 'kibana/public'; +import { IndexPatternsContract } from '../../../../plugins/data/public'; +import { SavedObjectLoader } from '../../../../plugins/saved_objects/public'; import { createSavedDashboardClass } from './saved_dashboard'; -export function createSavedDashboardLoader(services: SavedObjectKibanaServices) { +interface Services { + savedObjectsClient: SavedObjectsClientContract; + indexPatterns: IndexPatternsContract; + chrome: ChromeStart; + overlays: OverlayStart; +} + +/** + * @param services + */ +export function createSavedDashboardLoader(services: Services) { const SavedDashboard = createSavedDashboardClass(services); return new SavedObjectLoader(SavedDashboard, services.savedObjectsClient, services.chrome); } diff --git a/src/plugins/dashboard/public/tests/dashboard_container.test.tsx b/src/plugins/dashboard/public/tests/dashboard_container.test.tsx index 4aede3f3442fb..a81d80b440e04 100644 --- a/src/plugins/dashboard/public/tests/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/tests/dashboard_container.test.tsx @@ -49,7 +49,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { const editModeAction = createEditModeAction(); uiActionsSetup.registerAction(editModeAction); - uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction); + uiActionsSetup.attachAction(CONTEXT_MENU_TRIGGER, editModeAction); setup.registerEmbeddableFactory( CONTACT_CARD_EMBEDDABLE, new ContactCardEmbeddableFactory({} as any, (() => null) as any, {} as any) diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 977b9568ceaa6..efafea44167d4 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -374,6 +374,8 @@ export { TabbedAggColumn, TabbedAggRow, TabbedTable, + SearchInterceptor, + RequestTimeoutError, } from './search'; // Search namespace diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index ea2e85947aa12..fc5dde94fa851 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -109,12 +109,12 @@ export class DataPublicPlugin implements Plugin import("rxjs").Observable; + // (undocumented) + protected hideToast: () => void; + protected longRunningToast?: Toast; + // (undocumented) + protected readonly requestTimeout?: number | undefined; + search: (search: ISearchGeneric, request: IKibanaSearchRequest, options?: ISearchOptions | undefined) => import("rxjs").Observable>; + // (undocumented) + protected showToast: () => void; + protected timeoutSubscriptions: Set; + // (undocumented) + protected readonly toasts: ToastsStart; +} + // Warning: (ae-missing-release-tag) "SearchRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1848,21 +1881,21 @@ export type TSearchStrategyProvider = (context: ISearc // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "getRoutes" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:380:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:385:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:386:1 - (ae-forgotten-export) The symbol "convertDateRangeToString" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:402:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:403:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:406:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:407:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:382:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:387:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "convertDateRangeToString" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:404:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:405:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:408:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:33:33 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:37:1 - (ae-forgotten-export) The symbol "QueryStateChange" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromEvent" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index f3d2d99af5998..1687d749f46e2 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -57,5 +57,6 @@ export { } from './search_source'; export { SearchInterceptor } from './search_interceptor'; +export { RequestTimeoutError } from './request_timeout_error'; export { FetchOptions } from './fetch'; diff --git a/src/plugins/data/public/search/long_query_notification.tsx b/src/plugins/data/public/search/long_query_notification.tsx new file mode 100644 index 0000000000000..590fee20db690 --- /dev/null +++ b/src/plugins/data/public/search/long_query_notification.tsx @@ -0,0 +1,61 @@ +/* + * 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 { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { ApplicationStart } from 'kibana/public'; +import { toMountPoint } from '../../../kibana_react/public'; + +interface Props { + application: ApplicationStart; +} + +export function getLongQueryNotification(props: Props) { + return toMountPoint(); +} + +export function LongQueryNotification(props: Props) { + return ( +
+ + + + + { + await props.application.navigateToApp( + 'kibana#/management/elasticsearch/license_management' + ); + }} + > + + + + +
+ ); +} diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts index 12cf258759a99..b70e889066a45 100644 --- a/src/plugins/data/public/search/mocks.ts +++ b/src/plugins/data/public/search/mocks.ts @@ -31,10 +31,8 @@ export const searchSetupMock = { export const searchStartMock: jest.Mocked = { aggs: searchAggsStartMock(), + setInterceptor: jest.fn(), search: jest.fn(), - cancel: jest.fn(), - getPendingCount$: jest.fn(), - runBeyondTimeout: jest.fn(), __LEGACY: { AggConfig: jest.fn() as any, AggType: jest.fn(), diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts index a89d17464b9e0..bd056271688c1 100644 --- a/src/plugins/data/public/search/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor.test.ts @@ -18,27 +18,38 @@ */ import { Observable, Subject } from 'rxjs'; +import { CoreStart } from '../../../../core/public'; +import { coreMock } from '../../../../core/public/mocks'; import { IKibanaSearchRequest } from '../../common/search'; import { RequestTimeoutError } from './request_timeout_error'; import { SearchInterceptor } from './search_interceptor'; jest.useFakeTimers(); -const flushPromises = () => new Promise(resolve => setImmediate(resolve)); const mockSearch = jest.fn(); let searchInterceptor: SearchInterceptor; +let mockCoreStart: MockedKeys; describe('SearchInterceptor', () => { beforeEach(() => { + mockCoreStart = coreMock.createStart(); mockSearch.mockClear(); - searchInterceptor = new SearchInterceptor(1000); + searchInterceptor = new SearchInterceptor( + mockCoreStart.notifications.toasts, + mockCoreStart.application, + 1000 + ); }); describe('search', () => { test('should invoke `search` with the request', () => { - mockSearch.mockReturnValue(new Observable()); + const mockResponse = new Subject(); + mockSearch.mockReturnValue(mockResponse.asObservable()); const mockRequest: IKibanaSearchRequest = {}; - searchInterceptor.search(mockSearch, mockRequest); + const response = searchInterceptor.search(mockSearch, mockRequest); + mockResponse.complete(); + + response.subscribe(); expect(mockSearch.mock.calls[0][0]).toBe(mockRequest); }); @@ -92,44 +103,6 @@ describe('SearchInterceptor', () => { }); }); - describe('cancelPending', () => { - test('should abort all pending requests', async () => { - mockSearch.mockReturnValue(new Observable()); - - searchInterceptor.search(mockSearch, {}); - searchInterceptor.search(mockSearch, {}); - searchInterceptor.cancelPending(); - - await flushPromises(); - - const areAllRequestsAborted = mockSearch.mock.calls.every(([, { signal }]) => signal.aborted); - expect(areAllRequestsAborted).toBe(true); - }); - }); - - describe('runBeyondTimeout', () => { - test('should prevent the request from timing out', () => { - const mockResponse = new Subject(); - mockSearch.mockReturnValue(mockResponse.asObservable()); - const response = searchInterceptor.search(mockSearch, {}); - - setTimeout(searchInterceptor.runBeyondTimeout, 500); - setTimeout(() => mockResponse.next('hi'), 250); - setTimeout(() => mockResponse.complete(), 2000); - - const next = jest.fn(); - const complete = jest.fn(); - const error = jest.fn(); - response.subscribe({ next, error, complete }); - - jest.advanceTimersByTime(2000); - - expect(next).toHaveBeenCalledWith('hi'); - expect(error).not.toHaveBeenCalled(); - expect(complete).toHaveBeenCalled(); - }); - }); - describe('getPendingCount$', () => { test('should observe the number of pending requests', () => { let i = 0; diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index 3f83214f6050c..d83ddab807bc5 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -17,51 +17,59 @@ * under the License. */ -import { BehaviorSubject, fromEvent, throwError } from 'rxjs'; -import { mergeMap, takeUntil, finalize } from 'rxjs/operators'; +import { BehaviorSubject, throwError, timer, Subscription, defer, fromEvent } from 'rxjs'; +import { takeUntil, finalize, filter, mergeMapTo } from 'rxjs/operators'; +import { ApplicationStart, Toast, ToastsStart } from 'kibana/public'; import { getCombinedSignal } from '../../common/utils'; import { IKibanaSearchRequest } from '../../common/search'; import { ISearchGeneric, ISearchOptions } from './i_search'; import { RequestTimeoutError } from './request_timeout_error'; +import { getLongQueryNotification } from './long_query_notification'; export class SearchInterceptor { /** * `abortController` used to signal all searches to abort. */ - private abortController = new AbortController(); + protected abortController = new AbortController(); /** - * Observable that emits when the number of pending requests changes. + * The number of pending search requests. */ - private pendingCount$ = new BehaviorSubject(0); + private pendingCount = 0; /** - * The IDs from `setTimeout` when scheduling the automatic timeout for each request. + * Observable that emits when the number of pending requests changes. */ - private timeoutIds: Set = new Set(); + private pendingCount$ = new BehaviorSubject(this.pendingCount); /** - * This class should be instantiated with a `requestTimeout` corresponding with how many ms after - * requests are initiated that they should automatically cancel. - * @param requestTimeout Usually config value `elasticsearch.requestTimeout` + * The subscriptions from scheduling the automatic timeout for each request. */ - constructor(private readonly requestTimeout?: number) {} + protected timeoutSubscriptions: Set = new Set(); /** - * Abort our `AbortController`, which in turn aborts any intercepted searches. + * The current long-running toast (if there is one). */ - public cancelPending = () => { - this.abortController.abort(); - this.abortController = new AbortController(); - }; + protected longRunningToast?: Toast; /** - * Un-schedule timing out all of the searches intercepted. + * This class should be instantiated with a `requestTimeout` corresponding with how many ms after + * requests are initiated that they should automatically cancel. + * @param toasts The `core.notifications.toasts` service + * @param application The `core.application` service + * @param requestTimeout Usually config value `elasticsearch.requestTimeout` */ - public runBeyondTimeout = () => { - this.timeoutIds.forEach(clearTimeout); - this.timeoutIds.clear(); - }; + constructor( + protected readonly toasts: ToastsStart, + protected readonly application: ApplicationStart, + protected readonly requestTimeout?: number + ) { + // When search requests go out, a notification is scheduled allowing users to continue the + // request past the timeout. When all search requests complete, we remove the notification. + this.getPendingCount$() + .pipe(filter(count => count === 0)) + .subscribe(this.hideToast); + } /** * Returns an `Observable` over the current number of pending searches. This could mean that one @@ -81,41 +89,66 @@ export class SearchInterceptor { request: IKibanaSearchRequest, options?: ISearchOptions ) => { - // Schedule this request to automatically timeout after some interval - const timeoutController = new AbortController(); - const { signal: timeoutSignal } = timeoutController; - const timeoutId = window.setTimeout(() => { - timeoutController.abort(); - }, this.requestTimeout); - this.addTimeoutId(timeoutId); - - // Get a combined `AbortSignal` that will be aborted whenever the first of the following occurs: - // 1. The user manually aborts (via `cancelPending`) - // 2. The request times out - // 3. The passed-in signal aborts (e.g. when re-fetching, or whenever the app determines) - const signals = [this.abortController.signal, timeoutSignal, options?.signal].filter( - Boolean - ) as AbortSignal[]; - const combinedSignal = getCombinedSignal(signals); - - // If the request timed out, throw a `RequestTimeoutError` - const timeoutError$ = fromEvent(timeoutSignal, 'abort').pipe( - mergeMap(() => throwError(new RequestTimeoutError())) - ); + // Defer the following logic until `subscribe` is actually called + return defer(() => { + this.pendingCount$.next(++this.pendingCount); - return search(request as any, { ...options, signal: combinedSignal }).pipe( - takeUntil(timeoutError$), - finalize(() => this.removeTimeoutId(timeoutId)) - ); + // Schedule this request to automatically timeout after some interval + const timeoutController = new AbortController(); + const { signal: timeoutSignal } = timeoutController; + const timeout$ = timer(this.requestTimeout); + const subscription = timeout$.subscribe(() => timeoutController.abort()); + this.timeoutSubscriptions.add(subscription); + + // If the request timed out, throw a `RequestTimeoutError` + const timeoutError$ = fromEvent(timeoutSignal, 'abort').pipe( + mergeMapTo(throwError(new RequestTimeoutError())) + ); + + // Schedule the notification to allow users to cancel or wait beyond the timeout + const notificationSubscription = timer(10000).subscribe(this.showToast); + + // Get a combined `AbortSignal` that will be aborted whenever the first of the following occurs: + // 1. The user manually aborts (via `cancelPending`) + // 2. The request times out + // 3. The passed-in signal aborts (e.g. when re-fetching, or whenever the app determines) + const signals = [ + this.abortController.signal, + timeoutSignal, + ...(options?.signal ? [options.signal] : []), + ]; + const combinedSignal = getCombinedSignal(signals); + + return search(request as any, { ...options, signal: combinedSignal }).pipe( + takeUntil(timeoutError$), + finalize(() => { + this.pendingCount$.next(--this.pendingCount); + this.timeoutSubscriptions.delete(subscription); + notificationSubscription.unsubscribe(); + }) + ); + }); }; - private addTimeoutId(id: number) { - this.timeoutIds.add(id); - this.pendingCount$.next(this.timeoutIds.size); - } + protected showToast = () => { + if (this.longRunningToast) return; + this.longRunningToast = this.toasts.addInfo( + { + title: 'Your query is taking awhile', + text: getLongQueryNotification({ + application: this.application, + }), + }, + { + toastLifeTimeMs: Infinity, + } + ); + }; - private removeTimeoutId(id: number) { - this.timeoutIds.delete(id); - this.pendingCount$.next(this.timeoutIds.size); - } + protected hideToast = () => { + if (this.longRunningToast) { + this.toasts.remove(this.longRunningToast); + delete this.longRunningToast; + } + }; } diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index 62c7e0468bb88..311a8a2fc6f60 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -58,6 +58,7 @@ export class SearchService implements Plugin { private esClient?: LegacyApiCaller; private readonly aggTypesRegistry = new AggTypesRegistry(); + private searchInterceptor!: SearchInterceptor; private registerSearchStrategyProvider = ( name: T, @@ -98,7 +99,9 @@ export class SearchService implements Plugin { * TODO: Make this modular so that apps can opt in/out of search collection, or even provide * their own search collector instances */ - const searchInterceptor = new SearchInterceptor( + this.searchInterceptor = new SearchInterceptor( + core.notifications.toasts, + core.application, core.injectedMetadata.getInjectedVar('esRequestTimeout') as number ); @@ -114,16 +117,17 @@ export class SearchService implements Plugin { }, types: aggTypesStart, }, - cancel: () => searchInterceptor.cancelPending(), - getPendingCount$: () => searchInterceptor.getPendingCount$(), - runBeyondTimeout: () => searchInterceptor.runBeyondTimeout(), search: (request, options, strategyName) => { const strategyProvider = this.getSearchStrategy(strategyName || DEFAULT_SEARCH_STRATEGY); const { search } = strategyProvider({ core, getSearchStrategy: this.getSearchStrategy, }); - return searchInterceptor.search(search as any, request, options); + return this.searchInterceptor.search(search as any, request, options); + }, + setInterceptor: (searchInterceptor: SearchInterceptor) => { + // TODO: should an intercepror have a destroy method? + this.searchInterceptor = searchInterceptor; }, __LEGACY: { esClient: this.esClient!, diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts index 1b551f978b971..03cbfa9f8ed84 100644 --- a/src/plugins/data/public/search/types.ts +++ b/src/plugins/data/public/search/types.ts @@ -17,12 +17,12 @@ * under the License. */ -import { Observable } from 'rxjs'; import { CoreStart } from 'kibana/public'; import { SearchAggsSetup, SearchAggsStart, SearchAggsStartLegacy } from './aggs'; import { ISearch, ISearchGeneric } from './i_search'; import { TStrategyTypes } from './strategy_types'; import { LegacyApiCaller } from './es_client'; +import { SearchInterceptor } from './search_interceptor'; export interface ISearchContext { core: CoreStart; @@ -87,9 +87,7 @@ export interface ISearchSetup { export interface ISearchStart { aggs: SearchAggsStart; - cancel: () => void; - getPendingCount$: () => Observable; - runBeyondTimeout: () => void; + setInterceptor: (searchInterceptor: SearchInterceptor) => void; search: ISearchGeneric; __LEGACY: ISearchStartLegacy & SearchAggsStartLegacy; } diff --git a/src/plugins/data/server/kql_telemetry/route.ts b/src/plugins/data/server/kql_telemetry/route.ts index d5725c859c9a9..dd7ff333e6257 100644 --- a/src/plugins/data/server/kql_telemetry/route.ts +++ b/src/plugins/data/server/kql_telemetry/route.ts @@ -17,12 +17,12 @@ * under the License. */ -import { CoreSetup, IRouter, Logger } from 'kibana/server'; +import { StartServicesAccessor, IRouter, Logger } from 'kibana/server'; import { schema } from '@kbn/config-schema'; export function registerKqlTelemetryRoute( router: IRouter, - getStartServices: CoreSetup['getStartServices'], + getStartServices: StartServicesAccessor, logger: Logger ) { router.post( diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index d37cba6f897c8..9125dc0813f98 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -33,7 +33,7 @@ interface ActionContext { export class EditPanelAction implements Action { public readonly type = ACTION_EDIT_PANEL; public readonly id = ACTION_EDIT_PANEL; - public order = 50; + public order = 15; constructor(private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']) {} diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index 35973cc16cf9b..eb10c16806640 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -16,35 +16,23 @@ * specific language governing permissions and limitations * under the License. */ -import { cloneDeep, isEqual } from 'lodash'; +import { isEqual, cloneDeep } from 'lodash'; import * as Rx from 'rxjs'; -import { Adapters, ViewMode } from '../types'; +import { Adapters } from '../types'; import { IContainer } from '../containers'; -import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable'; +import { IEmbeddable, EmbeddableInput, EmbeddableOutput } from './i_embeddable'; +import { ViewMode } from '../types'; import { TriggerContextMapping } from '../ui_actions'; import { EmbeddableActionStorage } from './embeddable_action_storage'; -import { - UiActionsDynamicActionManager, - UiActionsStart, -} from '../../../../../plugins/ui_actions/public'; -import { EmbeddableContext } from '../triggers'; function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) { return input.hidePanelTitles ? '' : input.title === undefined ? output.defaultTitle : input.title; } -export interface EmbeddableParams { - uiActions?: UiActionsStart; -} - export abstract class Embeddable< TEmbeddableInput extends EmbeddableInput = EmbeddableInput, TEmbeddableOutput extends EmbeddableOutput = EmbeddableOutput > implements IEmbeddable { - static runtimeId: number = 0; - - public readonly runtimeId = Embeddable.runtimeId++; - public readonly parent?: IContainer; public readonly isContainer: boolean = false; public abstract readonly type: string; @@ -60,34 +48,15 @@ export abstract class Embeddable< // to update input when the parent changes. private parentSubscription?: Rx.Subscription; - private storageSubscription?: Rx.Subscription; - // TODO: Rename to destroyed. private destoyed: boolean = false; - private storage = new EmbeddableActionStorage((this as unknown) as Embeddable); - - private cachedDynamicActions?: UiActionsDynamicActionManager; - public get dynamicActions(): UiActionsDynamicActionManager | undefined { - if (!this.params.uiActions) return undefined; - if (!this.cachedDynamicActions) { - this.cachedDynamicActions = new UiActionsDynamicActionManager({ - isCompatible: async (context: unknown) => - (context as EmbeddableContext).embeddable.runtimeId === this.runtimeId, - storage: this.storage, - uiActions: this.params.uiActions, - }); - } - - return this.cachedDynamicActions; + private __actionStorage?: EmbeddableActionStorage; + public get actionStorage(): EmbeddableActionStorage { + return this.__actionStorage || (this.__actionStorage = new EmbeddableActionStorage(this)); } - constructor( - input: TEmbeddableInput, - output: TEmbeddableOutput, - parent?: IContainer, - public readonly params: EmbeddableParams = {} - ) { + constructor(input: TEmbeddableInput, output: TEmbeddableOutput, parent?: IContainer) { this.id = input.id; this.output = { title: getPanelTitle(input, output), @@ -111,18 +80,6 @@ export abstract class Embeddable< this.onResetInput(newInput); }); } - - if (this.dynamicActions) { - this.dynamicActions.start().catch(error => { - /* eslint-disable */ - console.log('Failed to start embeddable dynamic actions', this); - console.error(error); - /* eslint-enable */ - }); - this.storageSubscription = this.input$.subscribe(() => { - this.storage.reload$.next(); - }); - } } public getIsContainer(): this is IContainer { @@ -201,20 +158,6 @@ export abstract class Embeddable< */ public destroy(): void { this.destoyed = true; - - if (this.dynamicActions) { - this.dynamicActions.stop().catch(error => { - /* eslint-disable */ - console.log('Failed to stop embeddable dynamic actions', this); - console.error(error); - /* eslint-enable */ - }); - } - - if (this.storageSubscription) { - this.storageSubscription.unsubscribe(); - } - if (this.parentSubscription) { this.parentSubscription.unsubscribe(); } diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts index 83fd3f184e098..56facc37fc666 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts @@ -20,8 +20,7 @@ import { Embeddable } from './embeddable'; import { EmbeddableInput } from './i_embeddable'; import { ViewMode } from '../types'; -import { EmbeddableActionStorage } from './embeddable_action_storage'; -import { UiActionsSerializedEvent } from '../../../../ui_actions/public'; +import { EmbeddableActionStorage, SerializedEvent } from './embeddable_action_storage'; import { of } from '../../../../kibana_utils/common'; class TestEmbeddable extends Embeddable { @@ -43,9 +42,9 @@ describe('EmbeddableActionStorage', () => { test('can add event to embeddable', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: UiActionsSerializedEvent = { + const event: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -58,40 +57,23 @@ describe('EmbeddableActionStorage', () => { expect(events2).toEqual([event]); }); - test('does not merge .getInput() into .updateInput()', async () => { - const embeddable = new TestEmbeddable(); - const storage = new EmbeddableActionStorage(embeddable); - const event: UiActionsSerializedEvent = { - eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], - action: {} as any, - }; - - const spy = jest.spyOn(embeddable, 'updateInput'); - - await storage.create(event); - - expect(spy.mock.calls[0][0].id).toBe(undefined); - expect(spy.mock.calls[0][0].viewMode).toBe(undefined); - }); - test('can create multiple events', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: UiActionsSerializedEvent = { + const event1: SerializedEvent = { eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; - const event2: UiActionsSerializedEvent = { + const event2: SerializedEvent = { eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; - const event3: UiActionsSerializedEvent = { + const event3: SerializedEvent = { eventId: 'EVENT_ID3', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -113,9 +95,9 @@ describe('EmbeddableActionStorage', () => { test('throws when creating an event with the same ID', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: UiActionsSerializedEvent = { + const event: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -140,16 +122,16 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: UiActionsSerializedEvent = { + const event1: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'foo', } as any, }; - const event2: UiActionsSerializedEvent = { + const event2: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'bar', } as any, @@ -166,30 +148,30 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: UiActionsSerializedEvent = { + const event1: SerializedEvent = { eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'foo', } as any, }; - const event2: UiActionsSerializedEvent = { + const event2: SerializedEvent = { eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'bar', } as any, }; - const event22: UiActionsSerializedEvent = { + const event22: SerializedEvent = { eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'baz', } as any, }; - const event3: UiActionsSerializedEvent = { + const event3: SerializedEvent = { eventId: 'EVENT_ID3', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'qux', } as any, @@ -217,9 +199,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: UiActionsSerializedEvent = { + const event: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -235,14 +217,14 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: UiActionsSerializedEvent = { + const event1: SerializedEvent = { eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; - const event2: UiActionsSerializedEvent = { + const event2: SerializedEvent = { eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -267,9 +249,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: UiActionsSerializedEvent = { + const event: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -284,23 +266,23 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: UiActionsSerializedEvent = { + const event1: SerializedEvent = { eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'foo', } as any, }; - const event2: UiActionsSerializedEvent = { + const event2: SerializedEvent = { eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'bar', } as any, }; - const event3: UiActionsSerializedEvent = { + const event3: SerializedEvent = { eventId: 'EVENT_ID3', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: { name: 'qux', } as any, @@ -345,9 +327,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: UiActionsSerializedEvent = { + const event: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -373,9 +355,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: UiActionsSerializedEvent = { + const event: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -401,9 +383,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: UiActionsSerializedEvent = { + const event: SerializedEvent = { eventId: 'EVENT_ID', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID', action: {} as any, }; @@ -420,19 +402,19 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: UiActionsSerializedEvent = { + const event1: SerializedEvent = { eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID1', action: {} as any, }; - const event2: UiActionsSerializedEvent = { + const event2: SerializedEvent = { eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID2', action: {} as any, }; - const event3: UiActionsSerializedEvent = { + const event3: SerializedEvent = { eventId: 'EVENT_ID3', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID3', action: {} as any, }; @@ -476,7 +458,7 @@ describe('EmbeddableActionStorage', () => { await storage.create({ eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID1', action: {} as any, }); @@ -484,7 +466,7 @@ describe('EmbeddableActionStorage', () => { await storage.create({ eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID1', action: {} as any, }); @@ -520,15 +502,15 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: UiActionsSerializedEvent = { + const event1: SerializedEvent = { eventId: 'EVENT_ID1', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID1', action: {} as any, }; - const event2: UiActionsSerializedEvent = { + const event2: SerializedEvent = { eventId: 'EVENT_ID2', - triggers: ['TRIGGER-ID'], + triggerId: 'TRIGGER-ID1', action: {} as any, }; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts index fad5b4d535d6c..520f92840c5f9 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts @@ -17,20 +17,32 @@ * under the License. */ -import { - UiActionsAbstractActionStorage, - UiActionsSerializedEvent, -} from '../../../../ui_actions/public'; import { Embeddable } from '..'; -export class EmbeddableActionStorage extends UiActionsAbstractActionStorage { - constructor(private readonly embbeddable: Embeddable) { - super(); - } +/** + * Below two interfaces are here temporarily, they will move to `ui_actions` + * plugin once #58216 is merged. + */ +export interface SerializedEvent { + eventId: string; + triggerId: string; + action: unknown; +} +export interface ActionStorage { + create(event: SerializedEvent): Promise; + update(event: SerializedEvent): Promise; + remove(eventId: string): Promise; + read(eventId: string): Promise; + count(): Promise; + list(): Promise; +} - async create(event: UiActionsSerializedEvent) { +export class EmbeddableActionStorage implements ActionStorage { + constructor(private readonly embbeddable: Embeddable) {} + + async create(event: SerializedEvent) { const input = this.embbeddable.getInput(); - const events = (input.events || []) as UiActionsSerializedEvent[]; + const events = (input.events || []) as SerializedEvent[]; const exists = !!events.find(({ eventId }) => eventId === event.eventId); if (exists) { @@ -41,13 +53,14 @@ export class EmbeddableActionStorage extends UiActionsAbstractActionStorage { } this.embbeddable.updateInput({ + ...input, events: [...events, event], }); } - async update(event: UiActionsSerializedEvent) { + async update(event: SerializedEvent) { const input = this.embbeddable.getInput(); - const events = (input.events || []) as UiActionsSerializedEvent[]; + const events = (input.events || []) as SerializedEvent[]; const index = events.findIndex(({ eventId }) => eventId === event.eventId); if (index === -1) { @@ -59,13 +72,14 @@ export class EmbeddableActionStorage extends UiActionsAbstractActionStorage { } this.embbeddable.updateInput({ + ...input, events: [...events.slice(0, index), event, ...events.slice(index + 1)], }); } async remove(eventId: string) { const input = this.embbeddable.getInput(); - const events = (input.events || []) as UiActionsSerializedEvent[]; + const events = (input.events || []) as SerializedEvent[]; const index = events.findIndex(event => eventId === event.eventId); if (index === -1) { @@ -77,13 +91,14 @@ export class EmbeddableActionStorage extends UiActionsAbstractActionStorage { } this.embbeddable.updateInput({ + ...input, events: [...events.slice(0, index), ...events.slice(index + 1)], }); } - async read(eventId: string): Promise { + async read(eventId: string): Promise { const input = this.embbeddable.getInput(); - const events = (input.events || []) as UiActionsSerializedEvent[]; + const events = (input.events || []) as SerializedEvent[]; const event = events.find(ev => eventId === ev.eventId); if (!event) { @@ -98,10 +113,14 @@ export class EmbeddableActionStorage extends UiActionsAbstractActionStorage { private __list() { const input = this.embbeddable.getInput(); - return (input.events || []) as UiActionsSerializedEvent[]; + return (input.events || []) as SerializedEvent[]; + } + + async count(): Promise { + return this.__list().length; } - async list(): Promise { + async list(): Promise { return this.__list(); } } diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 9a4452aceba00..6345c34b0dda2 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -18,7 +18,6 @@ */ import { Observable } from 'rxjs'; -import { UiActionsDynamicActionManager } from '../../../../../plugins/ui_actions/public'; import { Adapters } from '../types'; import { IContainer } from '../containers/i_container'; import { ViewMode } from '../types'; @@ -34,7 +33,7 @@ export interface EmbeddableInput { /** * Reserved key for `ui_actions` events. */ - events?: Array<{ eventId: string }>; + events?: unknown; /** * List of action IDs that this embeddable should not render. @@ -83,19 +82,6 @@ export interface IEmbeddable< **/ readonly id: string; - /** - * Unique ID an embeddable is assigned each time it is initialized. This ID - * is different for different instances of the same embeddable. For example, - * if the same dashboard is rendered twice on the screen, all embeddable - * instances will have a unique `runtimeId`. - */ - readonly runtimeId?: number; - - /** - * Default implementation of dynamic action API for embeddables. - */ - dynamicActions?: UiActionsDynamicActionManager; - /** * A functional representation of the isContainer variable, but helpful for typescript to * know the shape if this returns true diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 83d3d5e10761b..757d4e6bfddef 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -44,7 +44,7 @@ import { import { inspectorPluginMock } from 'src/plugins/inspector/public/mocks'; import { EuiBadge } from '@elastic/eui'; -const actionRegistry = new Map(); +const actionRegistry = new Map>(); const triggerRegistry = new Map(); const embeddableFactories = new Map(); const getEmbeddableFactory = (id: string) => embeddableFactories.get(id); @@ -213,17 +213,13 @@ const renderInEditModeAndOpenContextMenu = async ( }; test('HelloWorldContainer in edit mode hides disabledActions', async () => { - const action = { + const action: Action = { id: 'FOO', type: 'FOO' as ActionType, getIconType: () => undefined, getDisplayName: () => 'foo', isCompatible: async () => true, execute: async () => {}, - order: 10, - getHref: () => { - return undefined; - }, }; const getActions = () => Promise.resolve([action]); @@ -249,17 +245,13 @@ test('HelloWorldContainer in edit mode hides disabledActions', async () => { }); test('HelloWorldContainer hides disabled badges', async () => { - const action = { + const action: Action = { id: 'BAR', type: 'BAR' as ActionType, getIconType: () => undefined, getDisplayName: () => 'bar', isCompatible: async () => true, execute: async () => {}, - order: 10, - getHref: () => { - return undefined; - }, }; const getActions = () => Promise.resolve([action]); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index c6537f2d94994..b95060a73252f 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -38,14 +38,6 @@ import { EditPanelAction } from '../actions'; import { CustomizePanelModal } from './panel_header/panel_actions/customize_title/customize_panel_modal'; import { EmbeddableStart } from '../../plugin'; -const sortByOrderField = ( - { order: orderA }: { order?: number }, - { order: orderB }: { order?: number } -) => (orderB || 0) - (orderA || 0); - -const removeById = (disabledActions: string[]) => ({ id }: { id: string }) => - disabledActions.indexOf(id) === -1; - interface Props { embeddable: IEmbeddable; getActions: UiActionsService['getTriggerCompatibleActions']; @@ -65,14 +57,12 @@ interface State { hidePanelTitles: boolean; closeContextMenu: boolean; badges: Array>; - eventCount?: number; } export class EmbeddablePanel extends React.Component { private embeddableRoot: React.RefObject; private parentSubscription?: Subscription; private subscription?: Subscription; - private eventCountSubscription?: Subscription; private mounted: boolean = false; private generateId = htmlIdGenerator(); @@ -146,9 +136,6 @@ export class EmbeddablePanel extends React.Component { if (this.subscription) { this.subscription.unsubscribe(); } - if (this.eventCountSubscription) { - this.eventCountSubscription.unsubscribe(); - } if (this.parentSubscription) { this.parentSubscription.unsubscribe(); } @@ -190,7 +177,6 @@ export class EmbeddablePanel extends React.Component { badges={this.state.badges} embeddable={this.props.embeddable} headerId={headerId} - eventCount={this.state.eventCount} /> )}
@@ -202,15 +188,6 @@ export class EmbeddablePanel extends React.Component { if (this.embeddableRoot.current) { this.props.embeddable.render(this.embeddableRoot.current); } - - const dynamicActions = this.props.embeddable.dynamicActions; - if (dynamicActions) { - this.setState({ eventCount: dynamicActions.state.get().events.length }); - this.eventCountSubscription = dynamicActions.state.state$.subscribe(({ events }) => { - if (!this.mounted) return; - this.setState({ eventCount: events.length }); - }); - } } closeMyContextMenuPanel = () => { @@ -224,14 +201,13 @@ export class EmbeddablePanel extends React.Component { }; private getActionContextMenuPanel = async () => { - let regularActions = await this.props.getActions(CONTEXT_MENU_TRIGGER, { + let actions = await this.props.getActions(CONTEXT_MENU_TRIGGER, { embeddable: this.props.embeddable, }); const { disabledActions } = this.props.embeddable.getInput(); if (disabledActions) { - const removeDisabledActions = removeById(disabledActions); - regularActions = regularActions.filter(removeDisabledActions); + actions = actions.filter(action => disabledActions.indexOf(action.id) === -1); } const createGetUserData = (overlays: OverlayStart) => @@ -270,10 +246,16 @@ export class EmbeddablePanel extends React.Component { new EditPanelAction(this.props.getEmbeddableFactory), ]; - const sortedActions = [...regularActions, ...extraActions].sort(sortByOrderField); + const sorted = actions + .concat(extraActions) + .sort((a: Action, b: Action) => { + const bOrder = b.order || 0; + const aOrder = a.order || 0; + return bOrder - aOrder; + }); return await buildContextMenuForActions({ - actions: sortedActions, + actions: sorted, actionContext: { embeddable: this.props.embeddable }, closeMenu: this.closeMyContextMenuPanel, }); diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts index 36957c3b79491..c0e43c0538833 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts @@ -33,13 +33,15 @@ interface ActionContext { export class CustomizePanelTitleAction implements Action { public readonly type = ACTION_CUSTOMIZE_PANEL; public id = ACTION_CUSTOMIZE_PANEL; - public order = 40; + public order = 10; - constructor(private readonly getDataFromUser: GetUserData) {} + constructor(private readonly getDataFromUser: GetUserData) { + this.order = 10; + } public getDisplayName() { return i18n.translate('embeddableApi.customizePanel.action.displayName', { - defaultMessage: 'Edit panel title', + defaultMessage: 'Customize panel', }); } diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts index ae9645767b267..d04f35715537c 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts @@ -31,7 +31,7 @@ interface ActionContext { export class InspectPanelAction implements Action { public readonly type = ACTION_INSPECT_PANEL; public readonly id = ACTION_INSPECT_PANEL; - public order = 20; + public order = 10; constructor(private readonly inspector: InspectorStartContract) {} diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts index a6d4128f3f106..ee7948f3d6a4a 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts @@ -41,7 +41,7 @@ function hasExpandedPanelInput( export class RemovePanelAction implements Action { public readonly type = REMOVE_PANEL_ACTION; public readonly id = REMOVE_PANEL_ACTION; - public order = 1; + public order = 5; constructor() {} diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 2a856af7ae916..99516a1d21d6f 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -23,7 +23,6 @@ import { EuiIcon, EuiToolTip, EuiScreenReaderOnly, - EuiNotificationBadge, } from '@elastic/eui'; import classNames from 'classnames'; import React from 'react'; @@ -41,7 +40,6 @@ export interface PanelHeaderProps { badges: Array>; embeddable: IEmbeddable; headerId?: string; - eventCount?: number; } function renderBadges(badges: Array>, embeddable: IEmbeddable) { @@ -92,7 +90,6 @@ export function PanelHeader({ badges, embeddable, headerId, - eventCount, }: PanelHeaderProps) { const viewDescription = getViewDescription(embeddable); const showTitle = !isViewMode || (title && !hidePanelTitles) || viewDescription !== ''; @@ -150,11 +147,7 @@ export function PanelHeader({ )} {renderBadges(badges, embeddable)} - {!isViewMode && !!eventCount && ( - - {eventCount} - - )} + >( - container: Container -): UnboxState => useObservable(container.state$, container.get()); - -/** - * Apply selector to state container to extract only needed information. Will - * re-render your component only when the section changes. - * - * @param container State container which state to track. - * @param selector Function used to pick parts of state. - * @param comparator Comparator function used to memoize previous result, to not - * re-render React component if state did not change. By default uses - * `fast-deep-equal` package. - */ -export const useContainerSelector = , Result>( - container: Container, - selector: (state: UnboxState) => Result, - comparator: Comparator = defaultComparator -): Result => { - const { state$, get } = container; - const lastValueRef = useRef(get()); - const [value, setValue] = React.useState(() => { - const newValue = selector(get()); - lastValueRef.current = newValue; - return newValue; - }); - useLayoutEffect(() => { - const subscription = state$.subscribe((currentState: UnboxState) => { - const newValue = selector(currentState); - if (!comparator(lastValueRef.current, newValue)) { - lastValueRef.current = newValue; - setValue(newValue); - } - }); - return () => subscription.unsubscribe(); - }, [state$, comparator]); - return value; -}; - export const createStateContainerReactHelpers = >() => { const context = React.createContext(null as any); const useContainer = (): Container => useContext(context); const useState = (): UnboxState => { - const container = useContainer(); - return useContainerState(container); + const { state$, get } = useContainer(); + const value = useObservable(state$, get()); + return value; }; const useTransitions: () => Container['transitions'] = () => useContainer().transitions; @@ -84,8 +41,24 @@ export const createStateContainerReactHelpers = ) => Result, comparator: Comparator = defaultComparator ): Result => { - const container = useContainer(); - return useContainerSelector(container, selector, comparator); + const { state$, get } = useContainer(); + const lastValueRef = useRef(get()); + const [value, setValue] = React.useState(() => { + const newValue = selector(get()); + lastValueRef.current = newValue; + return newValue; + }); + useLayoutEffect(() => { + const subscription = state$.subscribe((currentState: UnboxState) => { + const newValue = selector(currentState); + if (!comparator(lastValueRef.current, newValue)) { + lastValueRef.current = newValue; + setValue(newValue); + } + }); + return () => subscription.unsubscribe(); + }, [state$, comparator]); + return value; }; const connect: Connect> = mapStateToProp => component => props => diff --git a/src/plugins/kibana_utils/common/state_containers/types.ts b/src/plugins/kibana_utils/common/state_containers/types.ts index 29ffa4cd486b5..26a29bc470e8a 100644 --- a/src/plugins/kibana_utils/common/state_containers/types.ts +++ b/src/plugins/kibana_utils/common/state_containers/types.ts @@ -43,7 +43,7 @@ export interface BaseStateContainer { export interface StateContainer< State extends BaseState, - PureTransitions extends object = object, + PureTransitions extends object, PureSelectors extends object = {} > extends BaseStateContainer { transitions: Readonly>; diff --git a/src/plugins/management/public/management_app.tsx b/src/plugins/management/public/management_app.tsx index 705d98eaaf2ff..38db1039042e5 100644 --- a/src/plugins/management/public/management_app.tsx +++ b/src/plugins/management/public/management_app.tsx @@ -26,7 +26,7 @@ import { KibanaLegacySetup } from '../../kibana_legacy/public'; import { LegacyManagementSection } from './legacy'; import { ManagementChrome } from './components'; import { ManagementSection } from './management_section'; -import { ChromeBreadcrumb, CoreSetup } from '../../../core/public/'; +import { ChromeBreadcrumb, StartServicesAccessor } from '../../../core/public/'; export class ManagementApp { readonly id: string; @@ -41,7 +41,7 @@ export class ManagementApp { getSections: () => ManagementSection[], registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], getLegacyManagementSections: () => LegacyManagementSection, - getStartServices: CoreSetup['getStartServices'] + getStartServices: StartServicesAccessor ) { this.id = id; this.title = title; diff --git a/src/plugins/management/public/management_section.ts b/src/plugins/management/public/management_section.ts index 2f323c4b6a9cf..483605341ae4c 100644 --- a/src/plugins/management/public/management_section.ts +++ b/src/plugins/management/public/management_section.ts @@ -19,7 +19,7 @@ import { CreateSection, RegisterManagementAppArgs } from './types'; import { KibanaLegacySetup } from '../../kibana_legacy/public'; -import { CoreSetup } from '../../../core/public'; +import { StartServicesAccessor } from '../../../core/public'; // @ts-ignore import { LegacyManagementSection } from './legacy'; import { ManagementApp } from './management_app'; @@ -34,14 +34,14 @@ export class ManagementSection { private readonly getSections: () => ManagementSection[]; private readonly registerLegacyApp: KibanaLegacySetup['registerLegacyApp']; private readonly getLegacyManagementSection: () => LegacyManagementSection; - private readonly getStartServices: CoreSetup['getStartServices']; + private readonly getStartServices: StartServicesAccessor; constructor( { id, title, order = 100, euiIconType, icon }: CreateSection, getSections: () => ManagementSection[], registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], getLegacyManagementSection: () => ManagementSection, - getStartServices: CoreSetup['getStartServices'] + getStartServices: StartServicesAccessor ) { this.id = id; this.title = title; diff --git a/src/plugins/management/public/management_service.ts b/src/plugins/management/public/management_service.ts index 4a900345b3843..ed31a22992da8 100644 --- a/src/plugins/management/public/management_service.ts +++ b/src/plugins/management/public/management_service.ts @@ -22,7 +22,7 @@ import { KibanaLegacySetup } from '../../kibana_legacy/public'; // @ts-ignore import { LegacyManagementSection } from './legacy'; import { CreateSection } from './types'; -import { CoreSetup, CoreStart } from '../../../core/public'; +import { StartServicesAccessor, CoreStart } from '../../../core/public'; export class ManagementService { private sections: ManagementSection[] = []; @@ -30,7 +30,7 @@ export class ManagementService { private register( registerLegacyApp: KibanaLegacySetup['registerLegacyApp'], getLegacyManagement: () => LegacyManagementSection, - getStartServices: CoreSetup['getStartServices'] + getStartServices: StartServicesAccessor ) { return (section: CreateSection) => { if (this.getSection(section.id)) { @@ -71,7 +71,7 @@ export class ManagementService { public setup( kibanaLegacy: KibanaLegacySetup, getLegacyManagement: () => LegacyManagementSection, - getStartServices: CoreSetup['getStartServices'] + getStartServices: StartServicesAccessor ) { const register = this.register.bind(this)( kibanaLegacy.registerLegacyApp, diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts index 15f1d6dd79289..2b2fc004a84c6 100644 --- a/src/plugins/ui_actions/public/actions/action.ts +++ b/src/plugins/ui_actions/public/actions/action.ts @@ -19,12 +19,10 @@ import { UiComponent } from 'src/plugins/kibana_utils/common'; import { ActionType, ActionContextMapping } from '../types'; -import { Presentable } from '../util/presentable'; export type ActionByType = Action; -export interface Action - extends Partial> { +export interface Action { /** * Determined the order when there is more than one action matched to a trigger. * Higher numbers are displayed first. @@ -65,30 +63,12 @@ export interface Action isCompatible(context: Context): Promise; /** - * Executes the action. + * If this returns something truthy, this is used in addition to the `execute` method when clicked. */ - execute(context: Context): Promise; -} - -/** - * A convenience interface used to register an action. - */ -export interface ActionDefinition - extends Partial> { - /** - * ID of the action that uniquely identifies this action in the actions registry. - */ - readonly id: string; - - /** - * ID of the factory for this action. Used to construct dynamic actions. - */ - readonly type?: ActionType; + getHref?(context: Context): string | undefined; /** * Executes the action. */ execute(context: Context): Promise; } - -export type ActionContext = A extends ActionDefinition ? Context : never; diff --git a/src/plugins/ui_actions/public/util/presentable.ts b/src/plugins/ui_actions/public/actions/action_definition.ts similarity index 50% rename from src/plugins/ui_actions/public/util/presentable.ts rename to src/plugins/ui_actions/public/actions/action_definition.ts index 945fd2065ce78..c590cf8f34ee0 100644 --- a/src/plugins/ui_actions/public/util/presentable.ts +++ b/src/plugins/ui_actions/public/actions/action_definition.ts @@ -18,46 +18,55 @@ */ import { UiComponent } from 'src/plugins/kibana_utils/common'; +import { ActionType, ActionContextMapping } from '../types'; -/** - * Represents something that can be displayed to user in UI. - */ -export interface Presentable { +export interface ActionDefinition { /** - * ID that uniquely identifies this object. + * Determined the order when there is more than one action matched to a trigger. + * Higher numbers are displayed first. */ - readonly id: string; + order?: number; /** - * Determines the display order in relation to other items. Higher numbers are - * displayed first. + * A unique identifier for this action instance. */ - readonly order: number; + id?: string; /** - * `UiComponent` to render when displaying this entity as a context menu item. - * If not provided, `getDisplayName` will be used instead. + * The action type is what determines the context shape. */ - readonly MenuItem?: UiComponent<{ context: Context }>; + readonly type: T; /** * Optional EUI icon type that can be displayed along with the title. */ - getIconType(context: Context): string | undefined; + getIconType?(context: ActionContextMapping[T]): string; /** * Returns a title to be displayed to the user. + * @param context + */ + getDisplayName?(context: ActionContextMapping[T]): string; + + /** + * `UiComponent` to render when displaying this action as a context menu item. + * If not provided, `getDisplayName` will be used instead. + */ + MenuItem?: UiComponent<{ context: ActionContextMapping[T] }>; + + /** + * Returns a promise that resolves to true if this action is compatible given the context, + * otherwise resolves to false. */ - getDisplayName(context: Context): string; + isCompatible?(context: ActionContextMapping[T]): Promise; /** - * This method should return a link if this item can be clicked on. + * If this returns something truthy, this is used in addition to the `execute` method when clicked. */ - getHref?(context: Context): string | undefined; + getHref?(context: ActionContextMapping[T]): string | undefined; /** - * Returns a promise that resolves to true if this item is compatible given - * the context and should be displayed to user, otherwise resolves to false. + * Executes the action. */ - isCompatible(context: Context): Promise; + execute(context: ActionContextMapping[T]): Promise; } diff --git a/src/plugins/ui_actions/public/actions/action_factory.ts b/src/plugins/ui_actions/public/actions/action_factory.ts deleted file mode 100644 index bc0ec844d00f5..0000000000000 --- a/src/plugins/ui_actions/public/actions/action_factory.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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 { uiToReactComponent } from '../../../kibana_react/public'; -import { Presentable } from '../util/presentable'; -import { ActionDefinition } from './action'; -import { ActionFactoryDefinition } from './action_factory_definition'; -import { Configurable } from '../util'; -import { SerializedAction } from './types'; - -export class ActionFactory< - Config extends object = object, - FactoryContext extends object = object, - ActionContext extends object = object -> implements Presentable, Configurable { - constructor( - protected readonly def: ActionFactoryDefinition - ) {} - - public readonly id = this.def.id; - public readonly order = this.def.order || 0; - public readonly MenuItem? = this.def.MenuItem; - public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined; - - public readonly CollectConfig = this.def.CollectConfig; - public readonly ReactCollectConfig = uiToReactComponent(this.CollectConfig); - public readonly createConfig = this.def.createConfig; - public readonly isConfigValid = this.def.isConfigValid; - - public getIconType(context: FactoryContext): string | undefined { - if (!this.def.getIconType) return undefined; - return this.def.getIconType(context); - } - - public getDisplayName(context: FactoryContext): string { - if (!this.def.getDisplayName) return ''; - return this.def.getDisplayName(context); - } - - public async isCompatible(context: FactoryContext): Promise { - if (!this.def.isCompatible) return true; - return await this.def.isCompatible(context); - } - - public getHref(context: FactoryContext): string | undefined { - if (!this.def.getHref) return undefined; - return this.def.getHref(context); - } - - public create( - serializedAction: Omit, 'factoryId'> - ): ActionDefinition { - return this.def.create(serializedAction); - } -} diff --git a/src/plugins/ui_actions/public/actions/action_factory_definition.ts b/src/plugins/ui_actions/public/actions/action_factory_definition.ts deleted file mode 100644 index 7ac94a41e7076..0000000000000 --- a/src/plugins/ui_actions/public/actions/action_factory_definition.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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 { ActionDefinition } from './action'; -import { Presentable, Configurable } from '../util'; -import { SerializedAction } from './types'; - -/** - * This is a convenience interface for registering new action factories. - */ -export interface ActionFactoryDefinition< - Config extends object = object, - FactoryContext extends object = object, - ActionContext extends object = object -> extends Partial>, Configurable { - /** - * Unique ID of the action factory. This ID is used to identify this action - * factory in the registry as well as to construct actions of this type and - * identify this action factory when presenting it to the user in UI. - */ - id: string; - - /** - * This method should return a definition of a new action, normally used to - * register it in `ui_actions` registry. - */ - create( - serializedAction: Omit, 'factoryId'> - ): ActionDefinition; -} diff --git a/src/plugins/ui_actions/public/actions/action_internal.ts b/src/plugins/ui_actions/public/actions/action_internal.ts deleted file mode 100644 index 245ded991c032..0000000000000 --- a/src/plugins/ui_actions/public/actions/action_internal.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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 { Action, ActionContext as Context, ActionDefinition } from './action'; -import { Presentable } from '../util/presentable'; -import { uiToReactComponent } from '../../../kibana_react/public'; -import { ActionType } from '../types'; - -export class ActionInternal - implements Action>, Presentable> { - constructor(public readonly definition: A) {} - - public readonly id: string = this.definition.id; - public readonly type: ActionType = this.definition.type || ''; - public readonly order: number = this.definition.order || 0; - public readonly MenuItem? = this.definition.MenuItem; - public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined; - - public execute(context: Context) { - return this.definition.execute(context); - } - - public getIconType(context: Context): string | undefined { - if (!this.definition.getIconType) return undefined; - return this.definition.getIconType(context); - } - - public getDisplayName(context: Context): string { - if (!this.definition.getDisplayName) return `Action: ${this.id}`; - return this.definition.getDisplayName(context); - } - - public async isCompatible(context: Context): Promise { - if (!this.definition.isCompatible) return true; - return await this.definition.isCompatible(context); - } - - public getHref(context: Context): string | undefined { - if (!this.definition.getHref) return undefined; - return this.definition.getHref(context); - } -} diff --git a/src/plugins/ui_actions/public/actions/create_action.ts b/src/plugins/ui_actions/public/actions/create_action.ts index 8f1cd23715d3f..90a9415c0b497 100644 --- a/src/plugins/ui_actions/public/actions/create_action.ts +++ b/src/plugins/ui_actions/public/actions/create_action.ts @@ -17,19 +17,11 @@ * under the License. */ -import { ActionContextMapping } from '../types'; import { ActionByType } from './action'; import { ActionType } from '../types'; -import { ActionDefinition } from './action'; +import { ActionDefinition } from './action_definition'; -interface ActionDefinitionByType - extends Omit, 'id'> { - id?: string; -} - -export function createAction( - action: ActionDefinitionByType -): ActionByType { +export function createAction(action: ActionDefinition): ActionByType { return { getIconType: () => undefined, order: 0, @@ -38,5 +30,5 @@ export function createAction( getDisplayName: () => '', getHref: () => undefined, ...action, - } as ActionByType; + }; } diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts b/src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts deleted file mode 100644 index 2574a9e529ebf..0000000000000 --- a/src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts +++ /dev/null @@ -1,646 +0,0 @@ -/* - * 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 { DynamicActionManager } from './dynamic_action_manager'; -import { ActionStorage, MemoryActionStorage, SerializedEvent } from './dynamic_action_storage'; -import { UiActionsService } from '../service'; -import { ActionFactoryDefinition } from './action_factory_definition'; -import { ActionRegistry } from '../types'; -import { SerializedAction } from './types'; -import { of } from '../../../kibana_utils'; - -const actionFactoryDefinition1: ActionFactoryDefinition = { - id: 'ACTION_FACTORY_1', - CollectConfig: {} as any, - createConfig: () => ({}), - isConfigValid: (() => true) as any, - create: ({ name }) => ({ - id: '', - execute: async () => {}, - getDisplayName: () => name, - }), -}; - -const actionFactoryDefinition2: ActionFactoryDefinition = { - id: 'ACTION_FACTORY_2', - CollectConfig: {} as any, - createConfig: () => ({}), - isConfigValid: (() => true) as any, - create: ({ name }) => ({ - id: '', - execute: async () => {}, - getDisplayName: () => name, - }), -}; - -const event1: SerializedEvent = { - eventId: 'EVENT_ID_1', - triggers: ['VALUE_CLICK_TRIGGER'], - action: { - factoryId: actionFactoryDefinition1.id, - name: 'Action 1', - config: {}, - }, -}; - -const event2: SerializedEvent = { - eventId: 'EVENT_ID_2', - triggers: ['VALUE_CLICK_TRIGGER'], - action: { - factoryId: actionFactoryDefinition1.id, - name: 'Action 2', - config: {}, - }, -}; - -const event3: SerializedEvent = { - eventId: 'EVENT_ID_3', - triggers: ['VALUE_CLICK_TRIGGER'], - action: { - factoryId: actionFactoryDefinition2.id, - name: 'Action 3', - config: {}, - }, -}; - -const setup = (events: readonly SerializedEvent[] = []) => { - const isCompatible = async () => true; - const storage: ActionStorage = new MemoryActionStorage(events); - const actions: ActionRegistry = new Map(); - const uiActions = new UiActionsService({ - actions, - }); - const manager = new DynamicActionManager({ - isCompatible, - storage, - uiActions, - }); - - uiActions.registerTrigger({ - id: 'VALUE_CLICK_TRIGGER', - }); - - return { - isCompatible, - actions, - storage, - uiActions, - manager, - }; -}; - -describe('DynamicActionManager', () => { - test('can instantiate', () => { - const { manager } = setup([event1]); - expect(manager).toBeInstanceOf(DynamicActionManager); - }); - - describe('.start()', () => { - test('instantiates stored events', async () => { - const { manager, actions, uiActions } = setup([event1]); - const create1 = jest.fn(); - const create2 = jest.fn(); - - uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); - uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); - - expect(create1).toHaveBeenCalledTimes(0); - expect(create2).toHaveBeenCalledTimes(0); - expect(actions.size).toBe(0); - - await manager.start(); - - expect(create1).toHaveBeenCalledTimes(1); - expect(create2).toHaveBeenCalledTimes(0); - expect(actions.size).toBe(1); - }); - - test('does nothing when no events stored', async () => { - const { manager, actions, uiActions } = setup(); - const create1 = jest.fn(); - const create2 = jest.fn(); - - uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); - uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); - - expect(create1).toHaveBeenCalledTimes(0); - expect(create2).toHaveBeenCalledTimes(0); - expect(actions.size).toBe(0); - - await manager.start(); - - expect(create1).toHaveBeenCalledTimes(0); - expect(create2).toHaveBeenCalledTimes(0); - expect(actions.size).toBe(0); - }); - - test('UI state is empty before manager starts', async () => { - const { manager } = setup([event1]); - - expect(manager.state.get()).toMatchObject({ - events: [], - isFetchingEvents: false, - fetchCount: 0, - }); - }); - - test('loads events into UI state', async () => { - const { manager, uiActions } = setup([event1, event2, event3]); - - uiActions.registerActionFactory(actionFactoryDefinition1); - uiActions.registerActionFactory(actionFactoryDefinition2); - - await manager.start(); - - expect(manager.state.get()).toMatchObject({ - events: [event1, event2, event3], - isFetchingEvents: false, - fetchCount: 1, - }); - }); - - test('sets isFetchingEvents to true while fetching events', async () => { - const { manager, uiActions } = setup([event1, event2, event3]); - - uiActions.registerActionFactory(actionFactoryDefinition1); - uiActions.registerActionFactory(actionFactoryDefinition2); - - const promise = manager.start().catch(() => {}); - - expect(manager.state.get().isFetchingEvents).toBe(true); - - await promise; - - expect(manager.state.get().isFetchingEvents).toBe(false); - }); - - test('throws if storage threw', async () => { - const { manager, storage } = setup([event1]); - - storage.list = async () => { - throw new Error('baz'); - }; - - const [, error] = await of(manager.start()); - - expect(error).toEqual(new Error('baz')); - }); - - test('sets UI state error if error happened during initial fetch', async () => { - const { manager, storage } = setup([event1]); - - storage.list = async () => { - throw new Error('baz'); - }; - - await of(manager.start()); - - expect(manager.state.get().fetchError!.message).toBe('baz'); - }); - }); - - describe('.stop()', () => { - test('removes events from UI actions registry', async () => { - const { manager, actions, uiActions } = setup([event1, event2]); - const create1 = jest.fn(); - const create2 = jest.fn(); - - uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); - uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); - - expect(actions.size).toBe(0); - - await manager.start(); - - expect(actions.size).toBe(2); - - await manager.stop(); - - expect(actions.size).toBe(0); - }); - }); - - describe('.createEvent()', () => { - describe('when storage succeeds', () => { - test('stores new event in storage', async () => { - const { manager, storage, uiActions } = setup([]); - - uiActions.registerActionFactory(actionFactoryDefinition1); - await manager.start(); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }; - - expect(await storage.count()).toBe(0); - - await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); - - expect(await storage.count()).toBe(1); - - const [event] = await storage.list(); - - expect(event).toMatchObject({ - eventId: expect.any(String), - triggers: ['VALUE_CLICK_TRIGGER'], - action: { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }, - }); - }); - - test('adds event to UI state', async () => { - const { manager, uiActions } = setup([]); - const action: SerializedAction = { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }; - - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - - expect(manager.state.get().events.length).toBe(0); - - await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); - - expect(manager.state.get().events.length).toBe(1); - }); - - test('optimistically adds event to UI state', async () => { - const { manager, uiActions } = setup([]); - const action: SerializedAction = { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }; - - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - - expect(manager.state.get().events.length).toBe(0); - - const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e); - - expect(manager.state.get().events.length).toBe(1); - - await promise; - - expect(manager.state.get().events.length).toBe(1); - }); - - test('instantiates event in actions service', async () => { - const { manager, uiActions, actions } = setup([]); - const action: SerializedAction = { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }; - - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - - expect(actions.size).toBe(0); - - await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); - - expect(actions.size).toBe(1); - }); - }); - - describe('when storage fails', () => { - test('throws an error', async () => { - const { manager, storage, uiActions } = setup([]); - - storage.create = async () => { - throw new Error('foo'); - }; - - uiActions.registerActionFactory(actionFactoryDefinition1); - await manager.start(); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }; - - const [, error] = await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); - - expect(error).toEqual(new Error('foo')); - }); - - test('does not add even to UI state', async () => { - const { manager, storage, uiActions } = setup([]); - const action: SerializedAction = { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }; - - storage.create = async () => { - throw new Error('foo'); - }; - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); - - expect(manager.state.get().events.length).toBe(0); - }); - - test('optimistically adds event to UI state and then removes it', async () => { - const { manager, storage, uiActions } = setup([]); - const action: SerializedAction = { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }; - - storage.create = async () => { - throw new Error('foo'); - }; - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - - expect(manager.state.get().events.length).toBe(0); - - const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e); - - expect(manager.state.get().events.length).toBe(1); - - await promise; - - expect(manager.state.get().events.length).toBe(0); - }); - - test('does not instantiate event in actions service', async () => { - const { manager, storage, uiActions, actions } = setup([]); - const action: SerializedAction = { - factoryId: actionFactoryDefinition1.id, - name: 'foo', - config: {}, - }; - - storage.create = async () => { - throw new Error('foo'); - }; - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - - expect(actions.size).toBe(0); - - await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); - - expect(actions.size).toBe(0); - }); - }); - }); - - describe('.updateEvent()', () => { - describe('when storage succeeds', () => { - test('un-registers old event from ui actions service and registers the new one', async () => { - const { manager, actions, uiActions } = setup([event3]); - - uiActions.registerActionFactory(actionFactoryDefinition2); - await manager.start(); - - expect(actions.size).toBe(1); - - const registeredAction1 = actions.values().next().value; - - expect(registeredAction1.getDisplayName()).toBe('Action 3'); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition2.id, - name: 'foo', - config: {}, - }; - - await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); - - expect(actions.size).toBe(1); - - const registeredAction2 = actions.values().next().value; - - expect(registeredAction2.getDisplayName()).toBe('foo'); - }); - - test('updates event in storage', async () => { - const { manager, storage, uiActions } = setup([event3]); - const storageUpdateSpy = jest.spyOn(storage, 'update'); - - uiActions.registerActionFactory(actionFactoryDefinition2); - await manager.start(); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition2.id, - name: 'foo', - config: {}, - }; - - expect(storageUpdateSpy).toHaveBeenCalledTimes(0); - - await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); - - expect(storageUpdateSpy).toHaveBeenCalledTimes(1); - expect(storageUpdateSpy.mock.calls[0][0]).toMatchObject({ - eventId: expect.any(String), - triggers: ['VALUE_CLICK_TRIGGER'], - action: { - factoryId: actionFactoryDefinition2.id, - }, - }); - }); - - test('updates event in UI state', async () => { - const { manager, uiActions } = setup([event3]); - - uiActions.registerActionFactory(actionFactoryDefinition2); - await manager.start(); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition2.id, - name: 'foo', - config: {}, - }; - - expect(manager.state.get().events[0].action.name).toBe('Action 3'); - - await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); - - expect(manager.state.get().events[0].action.name).toBe('foo'); - }); - - test('optimistically updates event in UI state', async () => { - const { manager, uiActions } = setup([event3]); - - uiActions.registerActionFactory(actionFactoryDefinition2); - await manager.start(); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition2.id, - name: 'foo', - config: {}, - }; - - expect(manager.state.get().events[0].action.name).toBe('Action 3'); - - const promise = manager - .updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']) - .catch(e => e); - - expect(manager.state.get().events[0].action.name).toBe('foo'); - - await promise; - }); - }); - - describe('when storage fails', () => { - test('throws error', async () => { - const { manager, storage, uiActions } = setup([event3]); - - storage.update = () => { - throw new Error('bar'); - }; - uiActions.registerActionFactory(actionFactoryDefinition2); - await manager.start(); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition2.id, - name: 'foo', - config: {}, - }; - - const [, error] = await of( - manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']) - ); - - expect(error).toEqual(new Error('bar')); - }); - - test('keeps the old action in actions registry', async () => { - const { manager, storage, actions, uiActions } = setup([event3]); - - storage.update = () => { - throw new Error('bar'); - }; - uiActions.registerActionFactory(actionFactoryDefinition2); - await manager.start(); - - expect(actions.size).toBe(1); - - const registeredAction1 = actions.values().next().value; - - expect(registeredAction1.getDisplayName()).toBe('Action 3'); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition2.id, - name: 'foo', - config: {}, - }; - - await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])); - - expect(actions.size).toBe(1); - - const registeredAction2 = actions.values().next().value; - - expect(registeredAction2.getDisplayName()).toBe('Action 3'); - }); - - test('keeps old event in UI state', async () => { - const { manager, storage, uiActions } = setup([event3]); - - storage.update = () => { - throw new Error('bar'); - }; - uiActions.registerActionFactory(actionFactoryDefinition2); - await manager.start(); - - const action: SerializedAction = { - factoryId: actionFactoryDefinition2.id, - name: 'foo', - config: {}, - }; - - expect(manager.state.get().events[0].action.name).toBe('Action 3'); - - await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])); - - expect(manager.state.get().events[0].action.name).toBe('Action 3'); - }); - }); - }); - - describe('.deleteEvents()', () => { - describe('when storage succeeds', () => { - test('removes all actions from uiActions service', async () => { - const { manager, actions, uiActions } = setup([event2, event1]); - - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - - expect(actions.size).toBe(2); - - await manager.deleteEvents([event1.eventId, event2.eventId]); - - expect(actions.size).toBe(0); - }); - - test('removes all events from storage', async () => { - const { manager, uiActions, storage } = setup([event2, event1]); - - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - - expect(await storage.list()).toEqual([event2, event1]); - - await manager.deleteEvents([event1.eventId, event2.eventId]); - - expect(await storage.list()).toEqual([]); - }); - - test('removes all events from UI state', async () => { - const { manager, uiActions } = setup([event2, event1]); - - uiActions.registerActionFactory(actionFactoryDefinition1); - - await manager.start(); - - expect(manager.state.get().events).toEqual([event2, event1]); - - await manager.deleteEvents([event1.eventId, event2.eventId]); - - expect(manager.state.get().events).toEqual([]); - }); - }); - }); -}); diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_manager.ts b/src/plugins/ui_actions/public/actions/dynamic_action_manager.ts deleted file mode 100644 index 97eb5b05fbbc2..0000000000000 --- a/src/plugins/ui_actions/public/actions/dynamic_action_manager.ts +++ /dev/null @@ -1,284 +0,0 @@ -/* - * 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 { v4 as uuidv4 } from 'uuid'; -import { Subscription } from 'rxjs'; -import { ActionStorage, SerializedEvent } from './dynamic_action_storage'; -import { UiActionsService } from '../service'; -import { SerializedAction } from './types'; -import { TriggerContextMapping } from '../types'; -import { ActionDefinition } from './action'; -import { defaultState, transitions, selectors, State } from './dynamic_action_manager_state'; -import { StateContainer, createStateContainer } from '../../../kibana_utils'; - -const compareEvents = ( - a: ReadonlyArray<{ eventId: string }>, - b: ReadonlyArray<{ eventId: string }> -) => { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) if (a[i].eventId !== b[i].eventId) return false; - return true; -}; - -export type DynamicActionManagerState = State; - -export interface DynamicActionManagerParams { - storage: ActionStorage; - uiActions: Pick< - UiActionsService, - 'registerAction' | 'attachAction' | 'unregisterAction' | 'detachAction' | 'getActionFactory' - >; - isCompatible: (context: C) => Promise; -} - -export class DynamicActionManager { - static idPrefixCounter = 0; - - private readonly idPrefix = `D_ACTION_${DynamicActionManager.idPrefixCounter++}_`; - private stopped: boolean = false; - private reloadSubscription?: Subscription; - - /** - * UI State of the dynamic action manager. - */ - protected readonly ui = createStateContainer(defaultState, transitions, selectors); - - constructor(protected readonly params: DynamicActionManagerParams) {} - - protected getEvent(eventId: string): SerializedEvent { - const oldEvent = this.ui.selectors.getEvent(eventId); - if (!oldEvent) throw new Error(`Could not find event [eventId = ${eventId}].`); - return oldEvent; - } - - /** - * We prefix action IDs with a unique `.idPrefix`, so we can render the - * same dashboard twice on the screen. - */ - protected generateActionId(eventId: string): string { - return this.idPrefix + eventId; - } - - protected reviveAction(event: SerializedEvent) { - const { eventId, triggers, action } = event; - const { uiActions, isCompatible } = this.params; - - const actionId = this.generateActionId(eventId); - const factory = uiActions.getActionFactory(event.action.factoryId); - const actionDefinition: ActionDefinition = { - ...factory.create(action as SerializedAction), - id: actionId, - isCompatible, - }; - - uiActions.registerAction(actionDefinition); - for (const trigger of triggers) uiActions.attachAction(trigger as any, actionId); - } - - protected killAction({ eventId, triggers }: SerializedEvent) { - const { uiActions } = this.params; - const actionId = this.generateActionId(eventId); - - for (const trigger of triggers) uiActions.detachAction(trigger as any, actionId); - uiActions.unregisterAction(actionId); - } - - private syncId = 0; - - /** - * This function is called every time stored events might have changed not by - * us. For example, when in edit mode on dashboard user presses "back" button - * in the browser, then contents of storage changes. - */ - private onSync = () => { - if (this.stopped) return; - - (async () => { - const syncId = ++this.syncId; - const events = await this.params.storage.list(); - - if (this.stopped) return; - if (syncId !== this.syncId) return; - if (compareEvents(events, this.ui.get().events)) return; - - for (const event of this.ui.get().events) this.killAction(event); - for (const event of events) this.reviveAction(event); - this.ui.transitions.finishFetching(events); - })().catch(error => { - /* eslint-disable */ - console.log('Dynamic action manager storage reload failed.'); - console.error(error); - /* eslint-enable */ - }); - }; - - // Public API: --------------------------------------------------------------- - - /** - * Read-only state container of dynamic action manager. Use it to perform all - * *read* operations. - */ - public readonly state: StateContainer = this.ui; - - /** - * 1. Loads all events from @type {DynamicActionStorage} storage. - * 2. Creates actions for each event in `ui_actions` registry. - * 3. Adds events to UI state. - * 4. Does nothing if dynamic action manager was stopped or if event fetching - * is already taking place. - */ - public async start() { - if (this.stopped) return; - if (this.ui.get().isFetchingEvents) return; - - this.ui.transitions.startFetching(); - try { - const events = await this.params.storage.list(); - for (const event of events) this.reviveAction(event); - this.ui.transitions.finishFetching(events); - } catch (error) { - this.ui.transitions.failFetching(error instanceof Error ? error : { message: String(error) }); - throw error; - } - - if (this.params.storage.reload$) { - this.reloadSubscription = this.params.storage.reload$.subscribe(this.onSync); - } - } - - /** - * 1. Removes all events from `ui_actions` registry. - * 2. Puts dynamic action manager is stopped state. - */ - public async stop() { - this.stopped = true; - const events = await this.params.storage.list(); - - for (const event of events) { - this.killAction(event); - } - - if (this.reloadSubscription) { - this.reloadSubscription.unsubscribe(); - } - } - - /** - * Creates a new event. - * - * 1. Stores event in @type {DynamicActionStorage} storage. - * 2. Optimistically adds it to UI state, and rolls back on failure. - * 3. Adds action to `ui_actions` registry. - * - * @param action Dynamic action for which to create an event. - * @param triggers List of triggers to which action should react. - */ - public async createEvent( - action: SerializedAction, - triggers: Array - ) { - const event: SerializedEvent = { - eventId: uuidv4(), - triggers, - action, - }; - - this.ui.transitions.addEvent(event); - try { - await this.params.storage.create(event); - this.reviveAction(event); - } catch (error) { - this.ui.transitions.removeEvent(event.eventId); - throw error; - } - } - - /** - * Updates an existing event. Fails if event with given `eventId` does not - * exit. - * - * 1. Updates the event in @type {DynamicActionStorage} storage. - * 2. Optimistically replaces the old event by the new one in UI state, and - * rolls back on failure. - * 3. Replaces action in `ui_actions` registry with the new event. - * - * - * @param eventId ID of the event to replace. - * @param action New action for which to create the event. - * @param triggers List of triggers to which action should react. - */ - public async updateEvent( - eventId: string, - action: SerializedAction, - triggers: Array - ) { - const event: SerializedEvent = { - eventId, - triggers, - action, - }; - const oldEvent = this.getEvent(eventId); - this.killAction(oldEvent); - - this.reviveAction(event); - this.ui.transitions.replaceEvent(event); - - try { - await this.params.storage.update(event); - } catch (error) { - this.killAction(event); - this.reviveAction(oldEvent); - this.ui.transitions.replaceEvent(oldEvent); - throw error; - } - } - - /** - * Removes existing event. Throws if event does not exist. - * - * 1. Removes the event from @type {DynamicActionStorage} storage. - * 2. Optimistically removes event from UI state, and puts it back on failure. - * 3. Removes associated action from `ui_actions` registry. - * - * @param eventId ID of the event to remove. - */ - public async deleteEvent(eventId: string) { - const event = this.getEvent(eventId); - - this.killAction(event); - this.ui.transitions.removeEvent(eventId); - - try { - await this.params.storage.remove(eventId); - } catch (error) { - this.reviveAction(event); - this.ui.transitions.addEvent(event); - throw error; - } - } - - /** - * Deletes multiple events at once. - * - * @param eventIds List of event IDs. - */ - public async deleteEvents(eventIds: string[]) { - await Promise.all(eventIds.map(this.deleteEvent.bind(this))); - } -} diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts b/src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts deleted file mode 100644 index 636af076ea39f..0000000000000 --- a/src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * 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 { SerializedEvent } from './dynamic_action_storage'; - -/** - * This interface represents the state of @type {DynamicActionManager} at any - * point in time. - */ -export interface State { - /** - * Whether dynamic action manager is currently in process of fetching events - * from storage. - */ - readonly isFetchingEvents: boolean; - - /** - * Number of times event fetching has been completed. - */ - readonly fetchCount: number; - - /** - * Error received last time when fetching events. - */ - readonly fetchError?: { - message: string; - }; - - /** - * List of all fetched events. - */ - readonly events: readonly SerializedEvent[]; -} - -export interface Transitions { - startFetching: (state: State) => () => State; - finishFetching: (state: State) => (events: SerializedEvent[]) => State; - failFetching: (state: State) => (error: { message: string }) => State; - addEvent: (state: State) => (event: SerializedEvent) => State; - removeEvent: (state: State) => (eventId: string) => State; - replaceEvent: (state: State) => (event: SerializedEvent) => State; -} - -export interface Selectors { - getEvent: (state: State) => (eventId: string) => SerializedEvent | null; -} - -export const defaultState: State = { - isFetchingEvents: false, - fetchCount: 0, - events: [], -}; - -export const transitions: Transitions = { - startFetching: state => () => ({ ...state, isFetchingEvents: true }), - - finishFetching: state => events => ({ - ...state, - isFetchingEvents: false, - fetchCount: state.fetchCount + 1, - fetchError: undefined, - events, - }), - - failFetching: state => ({ message }) => ({ - ...state, - isFetchingEvents: false, - fetchCount: state.fetchCount + 1, - fetchError: { message }, - }), - - addEvent: state => (event: SerializedEvent) => ({ - ...state, - events: [...state.events, event], - }), - - removeEvent: state => (eventId: string) => ({ - ...state, - events: state.events ? state.events.filter(event => event.eventId !== eventId) : state.events, - }), - - replaceEvent: state => event => { - const index = state.events.findIndex(({ eventId }) => eventId === event.eventId); - if (index === -1) return state; - - return { - ...state, - events: [...state.events.slice(0, index), event, ...state.events.slice(index + 1)], - }; - }, -}; - -export const selectors: Selectors = { - getEvent: state => eventId => state.events.find(event => event.eventId === eventId) || null, -}; diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_storage.ts b/src/plugins/ui_actions/public/actions/dynamic_action_storage.ts deleted file mode 100644 index 28550a671782e..0000000000000 --- a/src/plugins/ui_actions/public/actions/dynamic_action_storage.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * 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. - */ - -/* eslint-disable max-classes-per-file */ - -import { Observable, Subject } from 'rxjs'; -import { SerializedAction } from './types'; - -/** - * Serialized representation of event-action pair, used to persist in storage. - */ -export interface SerializedEvent { - eventId: string; - triggers: string[]; - action: SerializedAction; -} - -/** - * This CRUD interface needs to be implemented by dynamic action users if they - * want to persist the dynamic actions. It has a default implementation in - * Embeddables, however one can use the dynamic actions without Embeddables, - * in that case they have to implement this interface. - */ -export interface ActionStorage { - create(event: SerializedEvent): Promise; - update(event: SerializedEvent): Promise; - remove(eventId: string): Promise; - read(eventId: string): Promise; - count(): Promise; - list(): Promise; - - /** - * Triggered every time events changed in storage and should be re-loaded. - */ - readonly reload$?: Observable; -} - -export abstract class AbstractActionStorage implements ActionStorage { - public readonly reload$: Observable & Pick, 'next'> = new Subject(); - - public async count(): Promise { - return (await this.list()).length; - } - - public async read(eventId: string): Promise { - const events = await this.list(); - const event = events.find(ev => ev.eventId === eventId); - if (!event) throw new Error(`Event [eventId = ${eventId}] not found.`); - return event; - } - - abstract create(event: SerializedEvent): Promise; - abstract update(event: SerializedEvent): Promise; - abstract remove(eventId: string): Promise; - abstract list(): Promise; -} - -/** - * This is an in-memory implementation of ActionStorage. It is used in testing, - * but can also be used production code to store events in memory. - */ -export class MemoryActionStorage extends AbstractActionStorage { - constructor(public events: readonly SerializedEvent[] = []) { - super(); - } - - public async list() { - return this.events.map(event => ({ ...event })); - } - - public async create(event: SerializedEvent) { - this.events = [...this.events, { ...event }]; - } - - public async update(event: SerializedEvent) { - const index = this.events.findIndex(({ eventId }) => eventId === event.eventId); - if (index < 0) throw new Error(`Event [eventId = ${event.eventId}] not found`); - this.events = [...this.events.slice(0, index), { ...event }, ...this.events.slice(index + 1)]; - } - - public async remove(eventId: string) { - const index = this.events.findIndex(ev => eventId === ev.eventId); - if (index < 0) throw new Error(`Event [eventId = ${eventId}] not found`); - this.events = [...this.events.slice(0, index), ...this.events.slice(index + 1)]; - } -} diff --git a/src/plugins/ui_actions/public/actions/index.ts b/src/plugins/ui_actions/public/actions/index.ts index 0ddba197aced6..64bfd368e3dfa 100644 --- a/src/plugins/ui_actions/public/actions/index.ts +++ b/src/plugins/ui_actions/public/actions/index.ts @@ -18,11 +18,5 @@ */ export * from './action'; -export * from './action_internal'; -export * from './action_factory_definition'; -export * from './action_factory'; export * from './create_action'; export * from './incompatible_action_error'; -export * from './dynamic_action_storage'; -export * from './dynamic_action_manager'; -export * from './types'; diff --git a/src/plugins/ui_actions/public/actions/types.ts b/src/plugins/ui_actions/public/actions/types.ts deleted file mode 100644 index 465f091e45ef1..0000000000000 --- a/src/plugins/ui_actions/public/actions/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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. - */ - -export interface SerializedAction { - readonly factoryId: string; - readonly name: string; - readonly config: Config; -} diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index ec58261d9e4f7..3dce2c1f4c257 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -24,25 +24,19 @@ import { i18n } from '@kbn/i18n'; import { uiToReactComponent } from '../../../kibana_react/public'; import { Action } from '../actions'; -export const defaultTitle = i18n.translate('uiActions.actionPanel.title', { - defaultMessage: 'Options', -}); - /** * Transforms an array of Actions to the shape EuiContextMenuPanel expects. */ -export async function buildContextMenuForActions({ +export async function buildContextMenuForActions({ actions, actionContext, - title = defaultTitle, closeMenu, }: { - actions: Array>; - actionContext: Context; - title?: string; + actions: Array>; + actionContext: A; closeMenu: () => void; }): Promise { - const menuItems = await buildEuiContextMenuPanelItems({ + const menuItems = await buildEuiContextMenuPanelItems({ actions, actionContext, closeMenu, @@ -50,7 +44,9 @@ export async function buildContextMenuForActions({ return { id: 'mainMenu', - title, + title: i18n.translate('uiActions.actionPanel.title', { + defaultMessage: 'Options', + }), items: menuItems, }; } @@ -58,41 +54,49 @@ export async function buildContextMenuForActions({ /** * Transform an array of Actions into the shape needed to build an EUIContextMenu */ -async function buildEuiContextMenuPanelItems({ +async function buildEuiContextMenuPanelItems({ actions, actionContext, closeMenu, }: { - actions: Array>; - actionContext: Context; + actions: Array>; + actionContext: A; closeMenu: () => void; }) { - const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length); - const promises = actions.map(async (action, index) => { + const items: EuiContextMenuPanelItemDescriptor[] = []; + const promises = actions.map(async action => { const isCompatible = await action.isCompatible(actionContext); if (!isCompatible) { return; } - items[index] = convertPanelActionToContextMenuItem({ - action, - actionContext, - closeMenu, - }); + items.push( + convertPanelActionToContextMenuItem({ + action, + actionContext, + closeMenu, + }) + ); }); await Promise.all(promises); - return items.filter(Boolean); + return items; } -function convertPanelActionToContextMenuItem({ +/** + * + * @param {ContextMenuAction} action + * @param {Embeddable} embeddable + * @return {EuiContextMenuPanelItemDescriptor} + */ +function convertPanelActionToContextMenuItem({ action, actionContext, closeMenu, }: { - action: Action; - actionContext: Context; + action: Action; + actionContext: A; closeMenu: () => void; }): EuiContextMenuPanelItemDescriptor { const menuPanelItem: EuiContextMenuPanelItemDescriptor = { @@ -111,11 +115,8 @@ function convertPanelActionToContextMenuItem({ closeMenu(); }; - if (action.getHref) { - const href = action.getHref(actionContext); - if (href) { - menuPanelItem.href = action.getHref(actionContext); - } + if (action.getHref && action.getHref(actionContext)) { + menuPanelItem.href = action.getHref(actionContext); } return menuPanelItem; diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index 9265d35bad9a9..49b6bd5e17699 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -26,26 +26,8 @@ export function plugin(initializerContext: PluginInitializerContext) { export { UiActionsSetup, UiActionsStart } from './plugin'; export { UiActionsServiceParams, UiActionsService } from './service'; -export { - Action, - ActionDefinition as UiActionsActionDefinition, - ActionFactoryDefinition as UiActionsActionFactoryDefinition, - ActionInternal as UiActionsActionInternal, - ActionStorage as UiActionsActionStorage, - AbstractActionStorage as UiActionsAbstractActionStorage, - createAction, - DynamicActionManager, - DynamicActionManagerState, - IncompatibleActionError, - SerializedAction as UiActionsSerializedAction, - SerializedEvent as UiActionsSerializedEvent, -} from './actions'; +export { Action, createAction, IncompatibleActionError } from './actions'; export { buildContextMenuForActions } from './context_menu'; -export { - Presentable as UiActionsPresentable, - Configurable as UiActionsConfigurable, - CollectConfigProps as UiActionsCollectConfigProps, -} from './util'; export { Trigger, TriggerContext, @@ -57,4 +39,4 @@ export { applyFilterTrigger, } from './triggers'; export { TriggerContextMapping, TriggerId, ActionContextMapping, ActionType } from './types'; -export { ActionByType, DynamicActionManager as UiActionsDynamicActionManager } from './actions'; +export { ActionByType } from './actions'; diff --git a/src/plugins/ui_actions/public/mocks.ts b/src/plugins/ui_actions/public/mocks.ts index 4de38eb5421e9..c1be6b2626525 100644 --- a/src/plugins/ui_actions/public/mocks.ts +++ b/src/plugins/ui_actions/public/mocks.ts @@ -28,13 +28,10 @@ export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { - addTriggerAction: jest.fn(), attachAction: jest.fn(), detachAction: jest.fn(), registerAction: jest.fn(), - registerActionFactory: jest.fn(), registerTrigger: jest.fn(), - unregisterAction: jest.fn(), }; return setupContract; }; @@ -42,21 +39,16 @@ const createSetupContract = (): Setup => { const createStartContract = (): Start => { const startContract: Start = { attachAction: jest.fn(), - unregisterAction: jest.fn(), - addTriggerAction: jest.fn(), - clear: jest.fn(), + registerAction: jest.fn(), + registerTrigger: jest.fn(), + getAction: jest.fn(), detachAction: jest.fn(), executeTriggerActions: jest.fn(), - fork: jest.fn(), - getAction: jest.fn(), - getActionFactories: jest.fn(), - getActionFactory: jest.fn(), getTrigger: jest.fn(), getTriggerActions: jest.fn((id: TriggerId) => []), getTriggerCompatibleActions: jest.fn(), - registerAction: jest.fn(), - registerActionFactory: jest.fn(), - registerTrigger: jest.fn(), + clear: jest.fn(), + fork: jest.fn(), }; return startContract; diff --git a/src/plugins/ui_actions/public/plugin.ts b/src/plugins/ui_actions/public/plugin.ts index 88a5cb04eac6f..928e57937a9b5 100644 --- a/src/plugins/ui_actions/public/plugin.ts +++ b/src/plugins/ui_actions/public/plugin.ts @@ -23,13 +23,7 @@ import { selectRangeTrigger, valueClickTrigger, applyFilterTrigger } from './tri export type UiActionsSetup = Pick< UiActionsService, - | 'addTriggerAction' - | 'attachAction' - | 'detachAction' - | 'registerAction' - | 'registerActionFactory' - | 'registerTrigger' - | 'unregisterAction' + 'attachAction' | 'detachAction' | 'registerAction' | 'registerTrigger' >; export type UiActionsStart = PublicMethodsOf; diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts index 41e2b57d53dd8..bdf71a25e6dbc 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts @@ -18,13 +18,7 @@ */ import { UiActionsService } from './ui_actions_service'; -import { - Action, - ActionInternal, - createAction, - ActionFactoryDefinition, - ActionFactory, -} from '../actions'; +import { Action, createAction } from '../actions'; import { createHelloWorldAction } from '../tests/test_samples'; import { ActionRegistry, TriggerRegistry, TriggerId, ActionType } from '../types'; import { Trigger } from '../triggers'; @@ -108,21 +102,6 @@ describe('UiActionsService', () => { type: 'test' as ActionType, }); }); - - test('return action instance', () => { - const service = new UiActionsService(); - const action = service.registerAction({ - id: 'test', - execute: async () => {}, - getDisplayName: () => 'test', - getIconType: () => '', - isCompatible: async () => true, - type: 'test' as ActionType, - }); - - expect(action).toBeInstanceOf(ActionInternal); - expect(action.id).toBe('test'); - }); }); describe('.getTriggerActions()', () => { @@ -160,14 +139,13 @@ describe('UiActionsService', () => { expect(list0).toHaveLength(0); - service.addTriggerAction(FOO_TRIGGER, action1); + service.attachAction(FOO_TRIGGER, action1); const list1 = service.getTriggerActions(FOO_TRIGGER); expect(list1).toHaveLength(1); - expect(list1[0]).toBeInstanceOf(ActionInternal); - expect(list1[0].id).toBe(action1.id); + expect(list1).toEqual([action1]); - service.addTriggerAction(FOO_TRIGGER, action2); + service.attachAction(FOO_TRIGGER, action2); const list2 = service.getTriggerActions(FOO_TRIGGER); expect(list2).toHaveLength(2); @@ -186,7 +164,7 @@ describe('UiActionsService', () => { service.registerAction(helloWorldAction); expect(actions.size - length).toBe(1); - expect(actions.get(helloWorldAction.id)!.id).toBe(helloWorldAction.id); + expect(actions.get(helloWorldAction.id)).toBe(helloWorldAction); }); test('getTriggerCompatibleActions returns attached actions', async () => { @@ -200,7 +178,7 @@ describe('UiActionsService', () => { title: 'My trigger', }; service.registerTrigger(testTrigger); - service.addTriggerAction(MY_TRIGGER, helloWorldAction); + service.attachAction(MY_TRIGGER, helloWorldAction); const compatibleActions = await service.getTriggerCompatibleActions(MY_TRIGGER, { hi: 'there', @@ -226,7 +204,7 @@ describe('UiActionsService', () => { }; service.registerTrigger(testTrigger); - service.addTriggerAction(testTrigger.id, action); + service.attachAction(testTrigger.id, action); const compatibleActions1 = await service.getTriggerCompatibleActions(testTrigger.id, { accept: true, @@ -310,7 +288,7 @@ describe('UiActionsService', () => { id: FOO_TRIGGER, }); service1.registerAction(testAction1); - service1.addTriggerAction(FOO_TRIGGER, testAction1); + service1.attachAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); @@ -331,14 +309,14 @@ describe('UiActionsService', () => { }); service1.registerAction(testAction1); service1.registerAction(testAction2); - service1.addTriggerAction(FOO_TRIGGER, testAction1); + service1.attachAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); - service2.addTriggerAction(FOO_TRIGGER, testAction2); + service2.attachAction(FOO_TRIGGER, testAction2); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(2); @@ -352,14 +330,14 @@ describe('UiActionsService', () => { }); service1.registerAction(testAction1); service1.registerAction(testAction2); - service1.addTriggerAction(FOO_TRIGGER, testAction1); + service1.attachAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); - service1.addTriggerAction(FOO_TRIGGER, testAction2); + service1.attachAction(FOO_TRIGGER, testAction2); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(2); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); @@ -414,7 +392,7 @@ describe('UiActionsService', () => { } as any; service.registerTrigger(trigger); - service.addTriggerAction(MY_TRIGGER, action); + service.attachAction(MY_TRIGGER, action); const actions = service.getTriggerActions(trigger.id); @@ -422,7 +400,7 @@ describe('UiActionsService', () => { expect(actions[0].id).toBe(ACTION_HELLO_WORLD); }); - test('can detach an action from a trigger', () => { + test('can detach an action to a trigger', () => { const service = new UiActionsService(); const trigger: Trigger = { @@ -435,7 +413,7 @@ describe('UiActionsService', () => { service.registerTrigger(trigger); service.registerAction(action); - service.addTriggerAction(trigger.id, action); + service.attachAction(trigger.id, action); service.detachAction(trigger.id, action.id); const actions2 = service.getTriggerActions(trigger.id); @@ -467,7 +445,7 @@ describe('UiActionsService', () => { } as any; service.registerAction(action); - expect(() => service.addTriggerAction('i do not exist' as TriggerId, action)).toThrowError( + expect(() => service.attachAction('i do not exist' as TriggerId, action)).toThrowError( 'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = ACTION_HELLO_WORLD].' ); }); @@ -497,64 +475,4 @@ describe('UiActionsService', () => { ); }); }); - - describe('action factories', () => { - const factoryDefinition1: ActionFactoryDefinition = { - id: 'test-factory-1', - CollectConfig: {} as any, - createConfig: () => ({}), - isConfigValid: () => true, - create: () => ({} as any), - }; - const factoryDefinition2: ActionFactoryDefinition = { - id: 'test-factory-2', - CollectConfig: {} as any, - createConfig: () => ({}), - isConfigValid: () => true, - create: () => ({} as any), - }; - - test('.getActionFactories() returns empty array if no action factories registered', () => { - const service = new UiActionsService(); - - const factories = service.getActionFactories(); - - expect(factories).toEqual([]); - }); - - test('can register and retrieve an action factory', () => { - const service = new UiActionsService(); - - service.registerActionFactory(factoryDefinition1); - - const factory = service.getActionFactory(factoryDefinition1.id); - - expect(factory).toBeInstanceOf(ActionFactory); - expect(factory.id).toBe(factoryDefinition1.id); - }); - - test('can retrieve all action factories', () => { - const service = new UiActionsService(); - - service.registerActionFactory(factoryDefinition1); - service.registerActionFactory(factoryDefinition2); - - const factories = service.getActionFactories(); - const factoriesSorted = [...factories].sort((f1, f2) => (f1.id > f2.id ? 1 : -1)); - - expect(factoriesSorted.length).toBe(2); - expect(factoriesSorted[0].id).toBe(factoryDefinition1.id); - expect(factoriesSorted[1].id).toBe(factoryDefinition2.id); - }); - - test('throws when retrieving action factory that does not exist', () => { - const service = new UiActionsService(); - - service.registerActionFactory(factoryDefinition1); - - expect(() => service.getActionFactory('UNKNOWN_ID')).toThrowError( - 'Action factory [actionFactoryId = UNKNOWN_ID] does not exist.' - ); - }); - }); }); diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.ts b/src/plugins/ui_actions/public/service/ui_actions_service.ts index 8bd3bb34fbbd8..f7718e63773f5 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts @@ -24,17 +24,8 @@ import { TriggerId, TriggerContextMapping, ActionType, - ActionFactoryRegistry, } from '../types'; -import { - ActionInternal, - Action, - ActionByType, - ActionFactory, - ActionDefinition, - ActionFactoryDefinition, - ActionContext, -} from '../actions'; +import { Action, ActionByType } from '../actions'; import { Trigger, TriggerContext } from '../triggers/trigger'; import { TriggerInternal } from '../triggers/trigger_internal'; import { TriggerContract } from '../triggers/trigger_contract'; @@ -47,25 +38,21 @@ export interface UiActionsServiceParams { * A 1-to-N mapping from `Trigger` to zero or more `Action`. */ readonly triggerToActions?: TriggerToActionsRegistry; - readonly actionFactories?: ActionFactoryRegistry; } export class UiActionsService { protected readonly triggers: TriggerRegistry; protected readonly actions: ActionRegistry; protected readonly triggerToActions: TriggerToActionsRegistry; - protected readonly actionFactories: ActionFactoryRegistry; constructor({ triggers = new Map(), actions = new Map(), triggerToActions = new Map(), - actionFactories = new Map(), }: UiActionsServiceParams = {}) { this.triggers = triggers; this.actions = actions; this.triggerToActions = triggerToActions; - this.actionFactories = actionFactories; } public readonly registerTrigger = (trigger: Trigger) => { @@ -89,44 +76,49 @@ export class UiActionsService { return trigger.contract; }; - public readonly registerAction = ( - definition: A - ): ActionInternal => { - if (this.actions.has(definition.id)) { - throw new Error(`Action [action.id = ${definition.id}] already registered.`); + public readonly registerAction = (action: ActionByType) => { + if (this.actions.has(action.id)) { + throw new Error(`Action [action.id = ${action.id}] already registered.`); } - const action = new ActionInternal(definition); - this.actions.set(action.id, action); - - return action; }; - public readonly unregisterAction = (actionId: string): void => { - if (!this.actions.has(actionId)) { - throw new Error(`Action [action.id = ${actionId}] is not registered.`); + public readonly getAction = (id: string): ActionByType => { + if (!this.actions.has(id)) { + throw new Error(`Action [action.id = ${id}] not registered.`); } - this.actions.delete(actionId); + return this.actions.get(id) as ActionByType; }; - public readonly attachAction = ( - triggerId: TriggerId, - actionId: string + public readonly attachAction = ( + triggerId: TType, + // The action can accept partial or no context, but if it needs context not provided + // by this type of trigger, typescript will complain. yay! + action: ActionByType & Action ): void => { + if (!this.actions.has(action.id)) { + this.registerAction(action); + } else { + const registeredAction = this.actions.get(action.id); + if (registeredAction !== action) { + throw new Error(`A different action instance with this id is already registered.`); + } + } + const trigger = this.triggers.get(triggerId); if (!trigger) { throw new Error( - `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${actionId}].` + `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${action.id}].` ); } const actionIds = this.triggerToActions.get(triggerId); - if (!actionIds!.find(id => id === actionId)) { - this.triggerToActions.set(triggerId, [...actionIds!, actionId]); + if (!actionIds!.find(id => id === action.id)) { + this.triggerToActions.set(triggerId, [...actionIds!, action.id]); } }; @@ -147,26 +139,6 @@ export class UiActionsService { ); }; - public readonly addTriggerAction = ( - triggerId: TType, - // The action can accept partial or no context, but if it needs context not provided - // by this type of trigger, typescript will complain. yay! - action: ActionByType & Action - ): void => { - if (!this.actions.has(action.id)) this.registerAction(action); - this.attachAction(triggerId, action.id); - }; - - public readonly getAction = ( - id: string - ): Action> => { - if (!this.actions.has(id)) { - throw new Error(`Action [action.id = ${id}] not registered.`); - } - - return this.actions.get(id) as ActionInternal; - }; - public readonly getTriggerActions = ( triggerId: T ): Array> => { @@ -175,9 +147,9 @@ export class UiActionsService { const actionIds = this.triggerToActions.get(triggerId); - const actions = actionIds! - .map(actionId => this.actions.get(actionId) as ActionInternal) - .filter(Boolean); + const actions = actionIds!.map(actionId => this.actions.get(actionId)).filter(Boolean) as Array< + Action + >; return actions as Array>>; }; @@ -215,7 +187,6 @@ export class UiActionsService { this.actions.clear(); this.triggers.clear(); this.triggerToActions.clear(); - this.actionFactories.clear(); }; /** @@ -235,41 +206,4 @@ export class UiActionsService { return new UiActionsService({ triggers, actions, triggerToActions }); }; - - /** - * Register an action factory. Action factories are used to configure and - * serialize/deserialize dynamic actions. - */ - public readonly registerActionFactory = < - Config extends object = object, - FactoryContext extends object = object, - ActionContext extends object = object - >( - definition: ActionFactoryDefinition - ) => { - if (this.actionFactories.has(definition.id)) { - throw new Error(`ActionFactory [actionFactory.id = ${definition.id}] already registered.`); - } - - const actionFactory = new ActionFactory(definition); - - this.actionFactories.set(actionFactory.id, actionFactory as ActionFactory); - }; - - public readonly getActionFactory = (actionFactoryId: string): ActionFactory => { - const actionFactory = this.actionFactories.get(actionFactoryId); - - if (!actionFactory) { - throw new Error(`Action factory [actionFactoryId = ${actionFactoryId}] does not exist.`); - } - - return actionFactory; - }; - - /** - * Returns an array of all action factories. - */ - public readonly getActionFactories = (): ActionFactory[] => { - return [...this.actionFactories.values()]; - }; } diff --git a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts index ade21ee4b7d91..5b427f918c173 100644 --- a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts @@ -69,7 +69,7 @@ test('executes a single action mapped to a trigger', async () => { const action = createTestAction('test1', () => true); setup.registerTrigger(trigger); - setup.addTriggerAction(trigger.id, action); + setup.attachAction(trigger.id, action); const context = {}; const start = doStart(); @@ -109,7 +109,7 @@ test('does not execute an incompatible action', async () => { ); setup.registerTrigger(trigger); - setup.addTriggerAction(trigger.id, action); + setup.attachAction(trigger.id, action); const start = doStart(); const context = { @@ -130,8 +130,8 @@ test('shows a context menu when more than one action is mapped to a trigger', as const action2 = createTestAction('test2', () => true); setup.registerTrigger(trigger); - setup.addTriggerAction(trigger.id, action1); - setup.addTriggerAction(trigger.id, action2); + setup.attachAction(trigger.id, action1); + setup.attachAction(trigger.id, action2); expect(openContextMenu).toHaveBeenCalledTimes(0); @@ -155,7 +155,7 @@ test('passes whole action context to isCompatible()', async () => { }); setup.registerTrigger(trigger); - setup.addTriggerAction(trigger.id, action); + setup.attachAction(trigger.id, action); const start = doStart(); diff --git a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts index 55ccac42ff255..f5a6a96fb41a4 100644 --- a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { ActionInternal, Action } from '../actions'; +import { Action } from '../actions'; import { uiActionsPluginMock } from '../mocks'; import { TriggerId, ActionType } from '../types'; @@ -47,14 +47,13 @@ test('returns actions set on trigger', () => { expect(list0).toHaveLength(0); - setup.addTriggerAction('trigger' as TriggerId, action1); + setup.attachAction('trigger' as TriggerId, action1); const list1 = start.getTriggerActions('trigger' as TriggerId); expect(list1).toHaveLength(1); - expect(list1[0]).toBeInstanceOf(ActionInternal); - expect(list1[0].id).toBe(action1.id); + expect(list1).toEqual([action1]); - setup.addTriggerAction('trigger' as TriggerId, action2); + setup.attachAction('trigger' as TriggerId, action2); const list2 = start.getTriggerActions('trigger' as TriggerId); expect(list2).toHaveLength(2); diff --git a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts index 21dd17ed82e3f..c5e68e5d5ca5a 100644 --- a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts @@ -37,7 +37,7 @@ beforeEach(() => { id: 'trigger' as TriggerId, title: 'trigger', }); - uiActions.setup.addTriggerAction('trigger' as TriggerId, action); + uiActions.setup.attachAction('trigger' as TriggerId, action); }); test('can register action', async () => { @@ -58,7 +58,7 @@ test('getTriggerCompatibleActions returns attached actions', async () => { title: 'My trigger', }; setup.registerTrigger(testTrigger); - setup.addTriggerAction('MY-TRIGGER' as TriggerId, helloWorldAction); + setup.attachAction('MY-TRIGGER' as TriggerId, helloWorldAction); const start = doStart(); const actions = await start.getTriggerCompatibleActions('MY-TRIGGER' as TriggerId, {}); @@ -84,7 +84,7 @@ test('filters out actions not applicable based on the context', async () => { setup.registerTrigger(testTrigger); setup.registerAction(action1); - setup.addTriggerAction(testTrigger.id, action1); + setup.attachAction(testTrigger.id, action1); const start = doStart(); let actions = await start.getTriggerCompatibleActions(testTrigger.id, { accept: true }); diff --git a/src/plugins/ui_actions/public/tests/test_samples/index.ts b/src/plugins/ui_actions/public/tests/test_samples/index.ts index dfa71cec89595..7d63b1b6d5669 100644 --- a/src/plugins/ui_actions/public/tests/test_samples/index.ts +++ b/src/plugins/ui_actions/public/tests/test_samples/index.ts @@ -16,5 +16,4 @@ * specific language governing permissions and limitations * under the License. */ - export { createHelloWorldAction } from './hello_world_action'; diff --git a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts index 9758508dc3dac..c638db0ce9dab 100644 --- a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts @@ -22,6 +22,6 @@ import { Trigger } from '.'; export const SELECT_RANGE_TRIGGER = 'SELECT_RANGE_TRIGGER'; export const selectRangeTrigger: Trigger<'SELECT_RANGE_TRIGGER'> = { id: SELECT_RANGE_TRIGGER, - title: '', + title: 'Select range', description: 'Applies a range filter', }; diff --git a/src/plugins/ui_actions/public/triggers/trigger_internal.ts b/src/plugins/ui_actions/public/triggers/trigger_internal.ts index 9885ed3abe93b..5b670df354f78 100644 --- a/src/plugins/ui_actions/public/triggers/trigger_internal.ts +++ b/src/plugins/ui_actions/public/triggers/trigger_internal.ts @@ -72,7 +72,6 @@ export class TriggerInternal { const panel = await buildContextMenuForActions({ actions, actionContext: context, - title: this.trigger.title, closeMenu: () => session.close(), }); const session = openContextMenu([panel]); diff --git a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts index 2671584d105c8..ad32bdc1b564e 100644 --- a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts @@ -22,6 +22,6 @@ import { Trigger } from '.'; export const VALUE_CLICK_TRIGGER = 'VALUE_CLICK_TRIGGER'; export const valueClickTrigger: Trigger<'VALUE_CLICK_TRIGGER'> = { id: VALUE_CLICK_TRIGGER, - title: '', + title: 'Value clicked', description: 'Value was clicked', }; diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index 2cb4a8f26a879..c7e6d61e15f31 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -17,17 +17,15 @@ * under the License. */ -import { ActionInternal } from './actions/action_internal'; +import { ActionByType } from './actions/action'; import { TriggerInternal } from './triggers/trigger_internal'; -import { ActionFactory } from './actions'; import { EmbeddableVisTriggerContext, IEmbeddable } from '../../embeddable/public'; import { Filter } from '../../data/public'; import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers'; export type TriggerRegistry = Map>; -export type ActionRegistry = Map; +export type ActionRegistry = Map>; export type TriggerToActionsRegistry = Map; -export type ActionFactoryRegistry = Map; const DEFAULT_TRIGGER = ''; diff --git a/src/plugins/ui_actions/public/util/configurable.ts b/src/plugins/ui_actions/public/util/configurable.ts deleted file mode 100644 index d3a527a2183b1..0000000000000 --- a/src/plugins/ui_actions/public/util/configurable.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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 { UiComponent } from 'src/plugins/kibana_utils/common'; - -/** - * Represents something that can be configured by user using UI. - */ -export interface Configurable { - /** - * Create default config for this item, used when item is created for the first time. - */ - readonly createConfig: () => Config; - - /** - * Is this config valid. Used to validate user's input before saving. - */ - readonly isConfigValid: (config: Config) => boolean; - - /** - * `UiComponent` to be rendered when collecting configuration for this item. - */ - readonly CollectConfig: UiComponent>; -} - -/** - * Props provided to `CollectConfig` component on every re-render. - */ -export interface CollectConfigProps { - /** - * Current (latest) config of the item. - */ - config: Config; - - /** - * Callback called when user updates the config in UI. - */ - onConfig: (config: Config) => void; - - /** - * Context information about where component is being rendered. - */ - context: Context; -} diff --git a/src/plugins/ui_actions/scripts/storybook.js b/src/plugins/ui_actions/scripts/storybook.js deleted file mode 100644 index cb2eda610170d..0000000000000 --- a/src/plugins/ui_actions/scripts/storybook.js +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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 { join } from 'path'; - -// eslint-disable-next-line -require('@kbn/storybook').runStorybookCli({ - name: 'ui_actions', - storyGlobs: [join(__dirname, '..', 'public', 'components', '**', '*.story.tsx')], -}); diff --git a/src/plugins/usage_collection/README.md b/src/plugins/usage_collection/README.md index 1c97c9c63c0e2..e32dfae35832b 100644 --- a/src/plugins/usage_collection/README.md +++ b/src/plugins/usage_collection/README.md @@ -83,14 +83,14 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { CoreSetup, CoreStart } from 'kibana/server'; class Plugin { - private savedObjectsClient?: ISavedObjectsRepository; + private savedObjectsRepository?: ISavedObjectsRepository; public setup(core: CoreSetup, plugins: { usageCollection?: UsageCollectionSetup }) { - registerMyPluginUsageCollector(() => this.savedObjectsClient, plugins.usageCollection); + registerMyPluginUsageCollector(() => this.savedObjectsRepository, plugins.usageCollection); } public start(core: CoreStart) { - this.savedObjectsClient = core.savedObjects.client + this.savedObjectsRepository = core.savedObjects.createInternalRepository(); } } ``` @@ -101,7 +101,7 @@ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { ISavedObjectsRepository } from 'kibana/server'; export function registerMyPluginUsageCollector( - getSavedObjectsClient: () => ISavedObjectsRepository | undefined, + getSavedObjectsRepository: () => ISavedObjectsRepository | undefined, usageCollection?: UsageCollectionSetup ): void { // usageCollection is an optional dependency, so make sure to return if it is not registered. @@ -112,9 +112,9 @@ export function registerMyPluginUsageCollector( // create usage collector const myCollector = usageCollection.makeUsageCollector({ type: MY_USAGE_TYPE, - isReady: () => typeof getSavedObjectsClient() !== 'undefined', + isReady: () => typeof getSavedObjectsRepository() !== 'undefined', fetch: async () => { - const savedObjectsClient = getSavedObjectsClient()!; + const savedObjectsRepository = getSavedObjectsRepository()!; // get something from the savedObjects return { my_objects }; diff --git a/test/functional/apps/getting_started/_shakespeare.js b/test/functional/apps/getting_started/_shakespeare.js index ded4eca908410..9a4bb0081b7ad 100644 --- a/test/functional/apps/getting_started/_shakespeare.js +++ b/test/functional/apps/getting_started/_shakespeare.js @@ -60,8 +60,7 @@ export default function({ getService, getPageObjects }) { it('should create shakespeare index pattern', async function() { log.debug('Create shakespeare index pattern'); await PageObjects.settings.createIndexPattern('shakes', null); - const indexPageHeading = await PageObjects.settings.getIndexPageHeading(); - const patternName = await indexPageHeading.getVisibleText(); + const patternName = await PageObjects.settings.getIndexPageHeading(); expect(patternName).to.be('shakes*'); }); diff --git a/test/functional/apps/management/_handle_alias.js b/test/functional/apps/management/_handle_alias.js index 4ef02f6c9e873..35c43c4633410 100644 --- a/test/functional/apps/management/_handle_alias.js +++ b/test/functional/apps/management/_handle_alias.js @@ -51,8 +51,7 @@ export default function({ getService, getPageObjects }) { it('should be able to create index pattern without time field', async function() { await PageObjects.settings.createIndexPattern('alias1', null); - const indexPageHeading = await PageObjects.settings.getIndexPageHeading(); - const patternName = await indexPageHeading.getVisibleText(); + const patternName = await PageObjects.settings.getIndexPageHeading(); expect(patternName).to.be('alias1*'); }); @@ -66,8 +65,7 @@ export default function({ getService, getPageObjects }) { it('should be able to create index pattern with timefield', async function() { await PageObjects.settings.createIndexPattern('alias2', 'date'); - const indexPageHeading = await PageObjects.settings.getIndexPageHeading(); - const patternName = await indexPageHeading.getVisibleText(); + const patternName = await PageObjects.settings.getIndexPageHeading(); expect(patternName).to.be('alias2*'); }); diff --git a/test/functional/apps/management/_index_pattern_create_delete.js b/test/functional/apps/management/_index_pattern_create_delete.js index 4661c9b4d53b8..a74620b696d1b 100644 --- a/test/functional/apps/management/_index_pattern_create_delete.js +++ b/test/functional/apps/management/_index_pattern_create_delete.js @@ -71,8 +71,7 @@ export default function({ getService, getPageObjects }) { }); it('should have index pattern in page header', async function() { - const indexPageHeading = await PageObjects.settings.getIndexPageHeading(); - const patternName = await indexPageHeading.getVisibleText(); + const patternName = await PageObjects.settings.getIndexPageHeading(); expect(patternName).to.be('logstash-*'); }); diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index 25706fda74925..3f6036f58f0a9 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -169,7 +169,7 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider } async getIndexPageHeading() { - return await testSubjects.find('indexPatternTitle'); + return await testSubjects.getVisibleText('indexPatternTitle'); } async getConfigureHeader() { diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx index 8ddb2e1a4803b..18ceec652392d 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx @@ -70,10 +70,11 @@ export class EmbeddableExplorerPublicPlugin const sayHelloAction = new SayHelloAction(alert); const sendMessageAction = createSendMessageAction(core.overlays); + plugins.uiActions.registerAction(helloWorldAction); plugins.uiActions.registerAction(sayHelloAction); plugins.uiActions.registerAction(sendMessageAction); - plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, helloWorldAction); + plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, helloWorldAction); plugins.__LEGACY.onRenderComplete(() => { const root = document.getElementById(REACT_ROOT_ID); diff --git a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx index 7c7cc689d05e5..8395fddece2a4 100644 --- a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx +++ b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx @@ -62,4 +62,5 @@ function createSamplePanelAction() { } const action = createSamplePanelAction(); -npSetup.plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); +npSetup.plugins.uiActions.registerAction(action); +npSetup.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); diff --git a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts index e034fbe320608..4b09be4db8a60 100644 --- a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts +++ b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts @@ -33,4 +33,5 @@ export const createSamplePanelLink = (): Action => }); const action = createSamplePanelLink(); -npStart.plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); +npStart.plugins.uiActions.registerAction(action); +npStart.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 784b5a5a42ace..2a28e349ace99 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -9,7 +9,6 @@ "xpack.beatsManagement": "legacy/plugins/beats_management", "xpack.canvas": "legacy/plugins/canvas", "xpack.crossClusterReplication": "legacy/plugins/cross_cluster_replication", - "xpack.dashboard": "plugins/dashboard_enhanced", "xpack.dashboardMode": "legacy/plugins/dashboard_mode", "xpack.data": "plugins/data_enhanced", "xpack.drilldowns": "plugins/drilldowns", diff --git a/x-pack/legacy/plugins/apm/e2e/.gitignore b/x-pack/legacy/plugins/apm/e2e/.gitignore index 10c769065fc28..a14856506bc6c 100644 --- a/x-pack/legacy/plugins/apm/e2e/.gitignore +++ b/x-pack/legacy/plugins/apm/e2e/.gitignore @@ -1,4 +1,3 @@ -cypress/ingest-data/events.json cypress/screenshots/* - cypress/test-results +tmp diff --git a/x-pack/legacy/plugins/apm/e2e/README.md b/x-pack/legacy/plugins/apm/e2e/README.md index 73a1e860f5564..a891d64539a3f 100644 --- a/x-pack/legacy/plugins/apm/e2e/README.md +++ b/x-pack/legacy/plugins/apm/e2e/README.md @@ -1,58 +1,16 @@ # End-To-End (e2e) Test for APM UI -## Ingest static data into Elasticsearch via APM Server +**Run E2E tests** -1. Start Elasticsearch and APM Server, using [apm-integration-testing](https://github.com/elastic/apm-integration-testing): - -```shell -$ git clone https://github.com/elastic/apm-integration-testing.git -$ cd apm-integration-testing -./scripts/compose.py start master --no-kibana --no-xpack-secure -``` - -2. Download [static data file](https://storage.googleapis.com/apm-ui-e2e-static-data/events.json) - -```shell -$ cd x-pack/legacy/plugins/apm/e2e/cypress/ingest-data -$ curl https://storage.googleapis.com/apm-ui-e2e-static-data/events.json --output events.json -``` - -3. Post to APM Server - -```shell -$ cd x-pack/legacy/plugins/apm/e2e/cypress/ingest-data -$ node replay.js --server-url http://localhost:8200 --secret-token abcd --events ./events.json -``` ->This process will take a few minutes to ingest all data - -4. Start Kibana - -```shell -$ yarn kbn bootstrap -$ yarn start --no-base-path --csp.strict=false -``` - -> Content Security Policy (CSP) Settings: Your Kibana instance must have the `csp.strict: false`. - -## How to run the tests - -_Note: Run the following commands from `kibana/x-pack/legacy/plugins/apm/e2e/cypress`._ - -### Interactive mode - -``` -yarn cypress open +```sh +x-pack/legacy/plugins/apm/e2e/run-e2e.sh ``` -### Headless mode - -``` -yarn cypress run -``` +_Starts Kibana, APM Server, Elasticsearch (with sample data) and runs the tests_ ## Reproducing CI builds ->This process is very slow compared to the local development described above. Consider that the CI must install and configure the build tools and create a Docker image for the project to run tests in a consistent manner. +> This process is very slow compared to the local development described above. Consider that the CI must install and configure the build tools and create a Docker image for the project to run tests in a consistent manner. The Jenkins CI uses a shell script to prepare Kibana: @@ -60,7 +18,7 @@ The Jenkins CI uses a shell script to prepare Kibana: # Prepare and run Kibana locally $ x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh # Build Docker image for Kibana -$ docker build --tag cypress --build-arg NODE_VERSION=$(cat .node-version) x-pack/legacy/plugins/apm/e2e/ci +$ docker build --tag cypress --build-arg NODE_VERSION=$(cat .node-version) x-pack/legacy/plugins/apm/e2e/ci # Run Docker image $ docker run --rm -t --user "$(id -u):$(id -g)" \ -v `pwd`:/app --network="host" \ diff --git a/x-pack/legacy/plugins/apm/e2e/ci/entrypoint.sh b/x-pack/legacy/plugins/apm/e2e/ci/entrypoint.sh index f7226dca1d276..ae5155d966e58 100755 --- a/x-pack/legacy/plugins/apm/e2e/ci/entrypoint.sh +++ b/x-pack/legacy/plugins/apm/e2e/ci/entrypoint.sh @@ -7,7 +7,7 @@ if [ -z "${kibana}" ] ; then kibana=127.0.0.1 fi -export CYPRESS_BASE_URL=http://${kibana}:5601 +export CYPRESS_BASE_URL=http://${kibana}:5701 ## To avoid issues with the home and caching artifacts export HOME=/tmp diff --git a/x-pack/legacy/plugins/apm/e2e/ci/kibana.dev.yml b/x-pack/legacy/plugins/apm/e2e/ci/kibana.dev.yml deleted file mode 100644 index db57db9a1abe9..0000000000000 --- a/x-pack/legacy/plugins/apm/e2e/ci/kibana.dev.yml +++ /dev/null @@ -1,7 +0,0 @@ -## -# Disabled plugins -######################## -logging.verbose: true -elasticsearch.username: "kibana_system_user" -elasticsearch.password: "changeme" -xpack.security.encryptionKey: "something_at_least_32_characters" diff --git a/x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml b/x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml new file mode 100644 index 0000000000000..19f3f7c8978fa --- /dev/null +++ b/x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml @@ -0,0 +1,31 @@ +# Kibana +server.port: 5701 +xpack.security.encryptionKey: 'something_at_least_32_characters' +csp.strict: false +logging.verbose: true + +# Elasticsearch +# Started via apm-integration-testing +# ./scripts/compose.py start master --no-kibana --elasticsearch-port 9201 --apm-server-port 8201 +elasticsearch.hosts: http://localhost:9201 +elasticsearch.username: 'kibana_system_user' +elasticsearch.password: 'changeme' + +# APM index pattern +apm_oss.indexPattern: apm-* + +# APM Indices +apm_oss.errorIndices: apm-*-error* +apm_oss.sourcemapIndices: apm-*-sourcemap +apm_oss.transactionIndices: apm-*-transaction* +apm_oss.spanIndices: apm-*-span* +apm_oss.metricsIndices: apm-*-metric* +apm_oss.onboardingIndices: apm-*-onboarding* + +# APM options +xpack.apm.enabled: true +xpack.apm.serviceMapEnabled: false +xpack.apm.autocreateApmIndexPattern: true +xpack.apm.ui.enabled: true +xpack.apm.ui.transactionGroupBucketSize: 100 +xpack.apm.ui.maxTraceItems: 1000 diff --git a/x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh b/x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh index 4f176fd0070f5..6df17bd51e0e8 100755 --- a/x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh +++ b/x-pack/legacy/plugins/apm/e2e/ci/prepare-kibana.sh @@ -1,24 +1,21 @@ #!/usr/bin/env bash set -e -CYPRESS_DIR="x-pack/legacy/plugins/apm/e2e" +E2E_DIR="x-pack/legacy/plugins/apm/e2e" echo "1/3 Install dependencies ..." # shellcheck disable=SC1091 source src/dev/ci_setup/setup_env.sh true yarn kbn bootstrap -cp ${CYPRESS_DIR}/ci/kibana.dev.yml config/kibana.dev.yml -echo 'elasticsearch:' >> config/kibana.dev.yml -cp ${CYPRESS_DIR}/ci/kibana.dev.yml config/kibana.yml echo "2/3 Ingest test data ..." -pushd ${CYPRESS_DIR} +pushd ${E2E_DIR} yarn install curl --silent https://storage.googleapis.com/apm-ui-e2e-static-data/events.json --output ingest-data/events.json -node ingest-data/replay.js --server-url http://localhost:8200 --secret-token abcd --events ./events.json > ingest-data.log +node ingest-data/replay.js --server-url http://localhost:8201 --secret-token abcd --events ./events.json > ingest-data.log echo "3/3 Start Kibana ..." popd ## Might help to avoid FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory export NODE_OPTIONS="--max-old-space-size=4096" -nohup node scripts/kibana --no-base-path --csp.strict=false --optimize.watch=false> kibana.log 2>&1 & +nohup node scripts/kibana --config "${E2E_DIR}/ci/kibana.e2e.yml" --no-base-path --optimize.watch=false> kibana.log 2>&1 & diff --git a/x-pack/legacy/plugins/apm/e2e/cypress.json b/x-pack/legacy/plugins/apm/e2e/cypress.json index 310964656f107..0894cfd13a197 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress.json +++ b/x-pack/legacy/plugins/apm/e2e/cypress.json @@ -1,5 +1,6 @@ { - "baseUrl": "http://localhost:5601", + "nodeVersion": "system", + "baseUrl": "http://localhost:5701", "video": false, "trashAssetsBeforeRuns": false, "fileServerFolder": "../", @@ -15,5 +16,9 @@ "mochaFile": "./cypress/test-results/[hash]-e2e-tests.xml", "toConsole": false }, - "testFiles": "**/*.{feature,features}" + "testFiles": "**/*.{feature,features}", + "env": { + "elasticsearch_username": "admin", + "elasticsearch_password": "changeme" + } } diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/integration/apm.feature b/x-pack/legacy/plugins/apm/e2e/cypress/integration/apm.feature index 01fee2bf68b09..285615108266b 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/integration/apm.feature +++ b/x-pack/legacy/plugins/apm/e2e/cypress/integration/apm.feature @@ -2,6 +2,6 @@ Feature: APM Scenario: Transaction duration charts Given a user browses the APM UI application - When the user inspects the opbeans-go service + When the user inspects the opbeans-node service Then should redirect to correct path with correct params - And should have correct y-axis ticks \ No newline at end of file + And should have correct y-axis ticks diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/integration/helpers.ts b/x-pack/legacy/plugins/apm/e2e/cypress/integration/helpers.ts index 1239ef397e086..90d5c9eda632d 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/integration/helpers.ts +++ b/x-pack/legacy/plugins/apm/e2e/cypress/integration/helpers.ts @@ -6,45 +6,26 @@ /* eslint-disable import/no-extraneous-dependencies */ -import { safeLoad } from 'js-yaml'; - -const RANGE_FROM = '2019-09-04T18:00:00.000Z'; -const RANGE_TO = '2019-09-05T06:00:00.000Z'; +const RANGE_FROM = '2020-03-04T12:30:00.000Z'; +const RANGE_TO = '2020-03-04T13:00:00.000Z'; const BASE_URL = Cypress.config().baseUrl; -/** - * Credentials in the `kibana.dev.yml` config file will be used to authenticate with Kibana - */ -const KIBANA_DEV_YML_PATH = '../../../../../config/kibana.dev.yml'; - /** The default time in ms to wait for a Cypress command to complete */ -export const DEFAULT_TIMEOUT = 30 * 1000; +export const DEFAULT_TIMEOUT = 60 * 1000; export function loginAndWaitForPage(url: string) { - // read the login details from `kibana.dev.yml` - cy.readFile(KIBANA_DEV_YML_PATH).then(kibanaDevYml => { - const config = safeLoad(kibanaDevYml); - const username = config['elasticsearch.username']; - const password = config['elasticsearch.password']; - - const hasCredentials = username && password; - - cy.log( - `Authenticating via config credentials from "${KIBANA_DEV_YML_PATH}". username: ${username}, password: ${password}` - ); + const username = Cypress.env('elasticsearch_username'); + const password = Cypress.env('elasticsearch_password'); - const options = hasCredentials - ? { - auth: { username, password } - } - : {}; + cy.log(`Authenticating via ${username} / ${password}`); - const fullUrl = `${BASE_URL}${url}?rangeFrom=${RANGE_FROM}&rangeTo=${RANGE_TO}`; - cy.visit(fullUrl, options); - }); + const fullUrl = `${BASE_URL}${url}?rangeFrom=${RANGE_FROM}&rangeTo=${RANGE_TO}`; + cy.visit(fullUrl, { auth: { username, password } }); cy.viewport('macbook-15'); // wait for loading spinner to disappear - cy.get('.kibanaLoaderWrap', { timeout: DEFAULT_TIMEOUT }).should('not.exist'); + cy.get('#kbn_loading_message', { timeout: DEFAULT_TIMEOUT }).should( + 'not.exist' + ); } diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/integration/snapshots.js b/x-pack/legacy/plugins/apm/e2e/cypress/integration/snapshots.js index 0e4b91ab45a40..968c2675a62e7 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/integration/snapshots.js +++ b/x-pack/legacy/plugins/apm/e2e/cypress/integration/snapshots.js @@ -1,19 +1,10 @@ module.exports = { - "When clicking opbeans-go service": { - "transaction duration charts": { - "should have correct y-axis ticks": { - "1": "3.7 min", - "2": "1.8 min", - "3": "0.0 min" - } - } - }, - "__version": "3.8.3", "APM": { "Transaction duration charts": { - "1": "3.7 min", - "2": "1.8 min", - "3": "0.0 min" + "1": "500 ms", + "2": "250 ms", + "3": "0 ms" } - } + }, + "__version": "4.2.0" } diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/support/step_definitions/apm.ts b/x-pack/legacy/plugins/apm/e2e/cypress/support/step_definitions/apm.ts index f2f1e515f967a..f58118f3352ea 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/support/step_definitions/apm.ts +++ b/x-pack/legacy/plugins/apm/e2e/cypress/support/step_definitions/apm.ts @@ -12,15 +12,15 @@ Given(`a user browses the APM UI application`, () => { loginAndWaitForPage(`/app/apm#/services`); }); -When(`the user inspects the opbeans-go service`, () => { - // click opbeans-go service - cy.get(':contains(opbeans-go)') +When(`the user inspects the opbeans-node service`, () => { + // click opbeans-node service + cy.get(':contains(opbeans-node)') .last() .click({ force: true }); }); Then(`should redirect to correct path with correct params`, () => { - cy.url().should('contain', `/app/apm#/services/opbeans-go/transactions`); + cy.url().should('contain', `/app/apm#/services/opbeans-node/transactions`); cy.url().should('contain', `transactionType=request`); }); diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/webpack.config.js b/x-pack/legacy/plugins/apm/e2e/cypress/webpack.config.js index 823b23cfdffec..8db6a1ef83520 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/webpack.config.js +++ b/x-pack/legacy/plugins/apm/e2e/cypress/webpack.config.js @@ -14,6 +14,7 @@ module.exports = { { test: /\.ts$/, exclude: [/node_modules/], + include: [/e2e\/cypress/], use: [ { loader: 'ts-loader' diff --git a/x-pack/legacy/plugins/apm/e2e/cypress/ingest-data/replay.js b/x-pack/legacy/plugins/apm/e2e/ingest-data/replay.js similarity index 50% rename from x-pack/legacy/plugins/apm/e2e/cypress/ingest-data/replay.js rename to x-pack/legacy/plugins/apm/e2e/ingest-data/replay.js index 990fc37bb7b2e..5301eafece06d 100644 --- a/x-pack/legacy/plugins/apm/e2e/cypress/ingest-data/replay.js +++ b/x-pack/legacy/plugins/apm/e2e/ingest-data/replay.js @@ -4,15 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -/* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable no-console */ +/* eslint-disable import/no-extraneous-dependencies */ + /** * This script is useful for ingesting previously generated APM data into Elasticsearch via APM Server * * You can either: * 1. Download a static test data file from: https://storage.googleapis.com/apm-ui-e2e-static-data/events.json - * 2. Or, generate the test data file yourself by following the steps in: https://github.com/elastic/kibana/blob/5207a0b68a66d4f513fe1b0cedb021b296641712/x-pack/legacy/plugins/apm/cypress/README.md#generate-static-data + * 2. Or, generate the test data file yourself: + * git clone https://github.com/elastic/apm-integration-testing.git + * ./scripts/compose.py start master --no-kibana --with-opbeans-node --apm-server-record + * docker cp localtesting_8.0.0_apm-server-2:/app/events.json . && cat events.json | wc -l + * + * * * Run the script: * @@ -26,7 +32,9 @@ const path = require('path'); const axios = require('axios'); const readFile = promisify(fs.readFile); const pLimit = require('p-limit'); +const pRetry = require('p-retry'); const { argv } = require('yargs'); +const ora = require('ora'); const APM_SERVER_URL = argv.serverUrl; const SECRET_TOKEN = argv.secretToken; @@ -42,12 +50,26 @@ if (!EVENTS_PATH) { process.exit(1); } -const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); +const requestProgress = { + succeeded: 0, + failed: 0, + total: 0 +}; + +const spinner = ora({ text: 'Warming up...', stream: process.stdout }); + +function incrementSpinnerCount({ success }) { + success ? requestProgress.succeeded++ : requestProgress.failed++; + const remaining = + requestProgress.total - + (requestProgress.succeeded + requestProgress.failed); + + spinner.text = `Remaining: ${remaining}. Succeeded: ${requestProgress.succeeded}. Failed: ${requestProgress.failed}.`; +} + async function insertItem(item) { try { const url = `${APM_SERVER_URL}${item.url}`; - console.log(Date.now(), url); - const headers = { 'content-type': 'application/x-ndjson' }; @@ -62,21 +84,16 @@ async function insertItem(item) { headers, data: item.body }); - - // add delay to avoid flooding the queue - return delay(500); } catch (e) { - console.log('an error occurred'); - if (e.response) { - console.log(e.response.data); - } else { - console.log('error', e); - } + console.error( + `${e.response ? JSON.stringify(e.response.data) : e.message}` + ); + throw e; } } async function init() { - const content = await readFile(path.resolve(__dirname, EVENTS_PATH)); + const content = await readFile(path.resolve(EVENTS_PATH)); const items = content .toString() .split('\n') @@ -84,10 +101,38 @@ async function init() { .map(item => JSON.parse(item)) .filter(item => item.url === '/intake/v2/events'); + spinner.start(); + requestProgress.total = items.length; + const limit = pLimit(20); // number of concurrent requests - await Promise.all(items.map(item => limit(() => insertItem(item)))); + await Promise.all( + items.map(async item => { + try { + // retry 5 times with exponential backoff + await pRetry(() => limit(() => insertItem(item)), { retries: 5 }); + incrementSpinnerCount({ success: true }); + } catch (e) { + incrementSpinnerCount({ success: false }); + } + }) + ); } -init().catch(e => { - console.log('An error occurred:', e); -}); +init() + .then(() => { + if (requestProgress.succeeded === requestProgress.total) { + spinner.succeed( + `Successfully ingested ${requestProgress.succeeded} of ${requestProgress.total} events` + ); + process.exit(0); + } else { + spinner.fail( + `Ingested ${requestProgress.succeeded} of ${requestProgress.total} events` + ); + process.exit(1); + } + }) + .catch(e => { + console.log('An error occurred:', e); + process.exit(1); + }); diff --git a/x-pack/legacy/plugins/apm/e2e/package.json b/x-pack/legacy/plugins/apm/e2e/package.json index c9026636e64fb..57500dfe3fdc8 100644 --- a/x-pack/legacy/plugins/apm/e2e/package.json +++ b/x-pack/legacy/plugins/apm/e2e/package.json @@ -9,16 +9,19 @@ }, "dependencies": { "@cypress/snapshot": "^2.1.3", - "@cypress/webpack-preprocessor": "^4.1.0", - "@types/cypress-cucumber-preprocessor": "^1.14.0", + "@cypress/webpack-preprocessor": "^4.1.3", + "@types/cypress-cucumber-preprocessor": "^1.14.1", "@types/js-yaml": "^3.12.1", "@types/node": "^10.12.11", - "cypress": "^3.5.0", + "cypress": "^4.2.0", "cypress-cucumber-preprocessor": "^2.0.1", "js-yaml": "^3.13.1", + "ora": "^4.0.3", "p-limit": "^2.2.1", - "ts-loader": "^6.1.0", - "typescript": "3.7.5", - "webpack": "^4.41.5" + "p-retry": "^4.2.0", + "ts-loader": "^6.2.2", + "typescript": "3.8.3", + "wait-on": "^4.0.1", + "webpack": "^4.42.1" } } diff --git a/x-pack/legacy/plugins/apm/e2e/run-e2e.sh b/x-pack/legacy/plugins/apm/e2e/run-e2e.sh new file mode 100755 index 0000000000000..5e55dc1eb834d --- /dev/null +++ b/x-pack/legacy/plugins/apm/e2e/run-e2e.sh @@ -0,0 +1,132 @@ +#!/bin/sh + +# variables +KIBANA_PORT=5701 +ELASTICSEARCH_PORT=9201 +APM_SERVER_PORT=8201 + +# ensure Docker is running +docker ps &> /dev/null +if [ $? -ne 0 ]; then + echo "⚠️ Please start Docker" + exit 1 +fi + +# formatting +bold=$(tput bold) +normal=$(tput sgr0) + +# paths +E2E_DIR="${0%/*}" +TMP_DIR="./tmp" +APM_IT_DIR="./tmp/apm-integration-testing" + +cd ${E2E_DIR} + +# +# Ask user to start Kibana +################################################## +echo "\n${bold}To start Kibana please run the following command:${normal} +node ./scripts/kibana --no-base-path --dev --no-dev-config --config x-pack/legacy/plugins/apm/e2e/ci/kibana.e2e.yml" + +# +# Create tmp folder +################################################## +echo "\n${bold}Temporary folder${normal}" +echo "Temporary files will be stored in: ${TMP_DIR}" +mkdir -p ${TMP_DIR} + +# +# apm-integration-testing +################################################## +printf "\n${bold}apm-integration-testing (logs: ${TMP_DIR}/apm-it.log)\n${normal}" + +# pull if folder already exists +if [ -d ${APM_IT_DIR} ]; then + echo "Pulling from master..." + git -C ${APM_IT_DIR} pull &> ${TMP_DIR}/apm-it.log + +# clone if folder does not exists +else + echo "Cloning repository" + git clone "https://github.com/elastic/apm-integration-testing.git" ${APM_IT_DIR} &> ${TMP_DIR}/apm-it.log +fi + +# Stop if clone/pull failed +if [ $? -ne 0 ]; then + printf "\n⚠️ Initializing apm-integration-testing failed. \n" + exit 1 +fi + +# Start apm-integration-testing +echo "Starting docker-compose" +${APM_IT_DIR}/scripts/compose.py start master \ + --no-kibana \ + --elasticsearch-port $ELASTICSEARCH_PORT \ + --apm-server-port=$APM_SERVER_PORT \ + --elasticsearch-heap 4g \ + &> ${TMP_DIR}/apm-it.log + +# Stop if apm-integration-testing failed to start correctly +if [ $? -ne 0 ]; then + printf "⚠️ apm-integration-testing could not be started.\n" + printf "Please see the logs in ${TMP_DIR}/apm-it.log\n\n" + printf "As a last resort, reset docker with:\n\n cd ${APM_IT_DIR} && scripts/compose.py stop && docker system prune --all --force --volumes\n" + exit 1 +fi + +# +# Static mock data +################################################## +printf "\n${bold}Static mock data (logs: ${TMP_DIR}/ingest-data.log)\n${normal}" + +# Download static data if not already done +if [ ! -e "${TMP_DIR}/events.json" ]; then + echo 'Downloading events.json...' + curl --silent https://storage.googleapis.com/apm-ui-e2e-static-data/events.json --output ${TMP_DIR}/events.json +fi + +# echo "Deleting existing indices (apm* and .apm*)" +curl --silent --user admin:changeme -XDELETE "localhost:${ELASTICSEARCH_PORT}/.apm*" > /dev/null +curl --silent --user admin:changeme -XDELETE "localhost:${ELASTICSEARCH_PORT}/apm*" > /dev/null + +# Ingest data into APM Server +node ingest-data/replay.js --server-url http://localhost:$APM_SERVER_PORT --events ${TMP_DIR}/events.json 2> ${TMP_DIR}/ingest-data.log + +# Stop if not all events were ingested correctly +if [ $? -ne 0 ]; then + printf "\n⚠️ Not all events were ingested correctly. This might affect test tests. \n" + exit 1 +fi + +# +# Cypress +################################################## +echo "\n${bold}Cypress (logs: ${TMP_DIR}/e2e-yarn.log)${normal}" +echo "Installing cypress dependencies " +yarn &> ${TMP_DIR}/e2e-yarn.log + +# +# Wait for Kibana to start +################################################## +echo "\n${bold}Waiting for Kibana to start...${normal}" +echo "Note: you need to start Kibana manually. Find the instructions at the top." +yarn wait-on -i 500 -w 500 http://localhost:$KIBANA_PORT > /dev/null + +echo "\n✅ Setup completed successfully. Running tests...\n" + +# +# run cypress tests +################################################## +yarn cypress run --config pageLoadTimeout=100000,watchForFileChanges=true + +# +# Run interactively +################################################## +echo " + +${bold}If you want to run the test interactively, run:${normal} + +yarn cypress open --config pageLoadTimeout=100000,watchForFileChanges=true +" + diff --git a/x-pack/legacy/plugins/apm/e2e/tsconfig.json b/x-pack/legacy/plugins/apm/e2e/tsconfig.json index de498816e30a4..a7091a20186b2 100644 --- a/x-pack/legacy/plugins/apm/e2e/tsconfig.json +++ b/x-pack/legacy/plugins/apm/e2e/tsconfig.json @@ -1,13 +1,8 @@ { "extends": "../../../../tsconfig.json", - "exclude": [], - "include": [ - "./**/*" - ], + "exclude": ["tmp"], + "include": ["./**/*"], "compilerOptions": { - "types": [ - "cypress", - "node" - ] + "types": ["cypress", "node"] } } diff --git a/x-pack/legacy/plugins/apm/e2e/yarn.lock b/x-pack/legacy/plugins/apm/e2e/yarn.lock index 48e6013fb6986..b7b531a9c73c0 100644 --- a/x-pack/legacy/plugins/apm/e2e/yarn.lock +++ b/x-pack/legacy/plugins/apm/e2e/yarn.lock @@ -932,10 +932,10 @@ snap-shot-compare "2.8.3" snap-shot-store "1.2.3" -"@cypress/webpack-preprocessor@^4.1.0": - version "4.1.1" - resolved "https://registry.yarnpkg.com/@cypress/webpack-preprocessor/-/webpack-preprocessor-4.1.1.tgz#3c0b5b8de6eaac605dac3b1f1c3f5916c1c6eaea" - integrity sha512-SfzDqOvWBSlfGRm8ak/XHUXAnndwHU2qJIRr1LIC7j2UqWcZoJ+286CuNloJbkwfyEAO6tQggLd4E/WHUAcKZQ== +"@cypress/webpack-preprocessor@^4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@cypress/webpack-preprocessor/-/webpack-preprocessor-4.1.3.tgz#d5fad767a304c16ec05ca08034827c601f1c9c0c" + integrity sha512-VtTzStrKtwyftLkcgopwCHzgjefK3uHHL6FgbAQP1o5N1pa/zYUb0g7hH2skrMAlKOmLGdbySlISkUl18Y3wHg== dependencies: bluebird "3.7.1" debug "4.1.1" @@ -952,10 +952,62 @@ debug "^3.1.0" lodash.once "^4.1.1" -"@types/cypress-cucumber-preprocessor@^1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@types/cypress-cucumber-preprocessor/-/cypress-cucumber-preprocessor-1.14.0.tgz#41d8ffb2b608d3ed4ab998a0c4394056f75af1e0" - integrity sha512-bOl4u6seZtxNIGa6J6xydroPntTxxWy8uqIrZ3OY10C96fUes4mZvJKY6NvOoe61/OVafG/UEFa+X2ZWKE6Ltw== +"@hapi/address@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@hapi/address/-/address-4.0.0.tgz#36affb4509b5a6adc628bcc394450f2a7d51d111" + integrity sha512-GDDpkCdSUfkQCznmWUHh9dDN85BWf/V8TFKQ2JLuHdGB4Yy3YTEGBzZxoBNxfNBEvreSR/o+ZxBBSNNEVzY+lQ== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@hapi/formula@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@hapi/formula/-/formula-2.0.0.tgz#edade0619ed58c8e4f164f233cda70211e787128" + integrity sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A== + +"@hapi/hoek@^9.0.0": + version "9.0.3" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.0.3.tgz#e49e637d5de8faa4f0d313c2590b455d7c00afd7" + integrity sha512-jKtjLLDiH95b002sJVc5c74PE6KKYftuyVdVmsuYId5stTaWcRFqE+5ukZI4gDUKjGn8wv2C3zPn3/nyjEI7gg== + +"@hapi/joi@^17.1.0": + version "17.1.0" + resolved "https://registry.yarnpkg.com/@hapi/joi/-/joi-17.1.0.tgz#cc4000b6c928a6a39b9bef092151b6bdee10ce55" + integrity sha512-ob67RcPlwRWxBzLCnWvcwx5qbwf88I3ykD7gcJLWOTRfLLgosK7r6aeChz4thA3XRvuBfI0KB1tPVl2EQFlPXw== + dependencies: + "@hapi/address" "^4.0.0" + "@hapi/formula" "^2.0.0" + "@hapi/hoek" "^9.0.0" + "@hapi/pinpoint" "^2.0.0" + "@hapi/topo" "^5.0.0" + +"@hapi/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@hapi/pinpoint/-/pinpoint-2.0.0.tgz#805b40d4dbec04fc116a73089494e00f073de8df" + integrity sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw== + +"@hapi/topo@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.0.0.tgz#c19af8577fa393a06e9c77b60995af959be721e7" + integrity sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@samverschueren/stream-to-observable@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f" + integrity sha512-MI4Xx6LHs4Webyvi6EbspgyAb4D2Q2VtnCQ1blOJcoLS6mVa8lNN2rkIy1CVxfTUpoyIbCTkXES1rLXztFD1lg== + dependencies: + any-observable "^0.3.0" + +"@types/color-name@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" + integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== + +"@types/cypress-cucumber-preprocessor@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@types/cypress-cucumber-preprocessor/-/cypress-cucumber-preprocessor-1.14.1.tgz#9787f4e89553ebc6359ce157a26ad51ed14aa98b" + integrity sha512-CpYsiQ49UrOmadhFg0G5RkokPUmGGctD01mOWjNxFxHw5VgIRv33L2RyFHL8klaAI4HaedGN3Tcj4HTQ65hn+A== "@types/js-yaml@^3.12.1": version "3.12.2" @@ -967,155 +1019,159 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.14.tgz#b6c60ebf2fb5e4229fdd751ff9ddfae0f5f31541" integrity sha512-G0UmX5uKEmW+ZAhmZ6PLTQ5eu/VPaT+d/tdLd5IFsKRPcbe6lPxocBtcYBFSaLaCW8O60AX90e91Nsp8lVHCNw== +"@types/retry@^0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + "@types/sizzle@2.3.2": version "2.3.2" resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.2.tgz#a811b8c18e2babab7d542b3365887ae2e4d9de47" integrity sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg== -"@webassemblyjs/ast@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359" - integrity sha512-aJMfngIZ65+t71C3y2nBBg5FFG0Okt9m0XEgWZ7Ywgn1oMAT8cNwx00Uv1cQyHtidq0Xn94R4TAywO+LCQ+ZAQ== - dependencies: - "@webassemblyjs/helper-module-context" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/wast-parser" "1.8.5" - -"@webassemblyjs/floating-point-hex-parser@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.8.5.tgz#1ba926a2923613edce496fd5b02e8ce8a5f49721" - integrity sha512-9p+79WHru1oqBh9ewP9zW95E3XAo+90oth7S5Re3eQnECGq59ly1Ri5tsIipKGpiStHsUYmY3zMLqtk3gTcOtQ== - -"@webassemblyjs/helper-api-error@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.8.5.tgz#c49dad22f645227c5edb610bdb9697f1aab721f7" - integrity sha512-Za/tnzsvnqdaSPOUXHyKJ2XI7PDX64kWtURyGiJJZKVEdFOsdKUCPTNEVFZq3zJ2R0G5wc2PZ5gvdTRFgm81zA== - -"@webassemblyjs/helper-buffer@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.8.5.tgz#fea93e429863dd5e4338555f42292385a653f204" - integrity sha512-Ri2R8nOS0U6G49Q86goFIPNgjyl6+oE1abW1pS84BuhP1Qcr5JqMwRFT3Ah3ADDDYGEgGs1iyb1DGX+kAi/c/Q== - -"@webassemblyjs/helper-code-frame@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.8.5.tgz#9a740ff48e3faa3022b1dff54423df9aa293c25e" - integrity sha512-VQAadSubZIhNpH46IR3yWO4kZZjMxN1opDrzePLdVKAZ+DFjkGD/rf4v1jap744uPVU6yjL/smZbRIIJTOUnKQ== - dependencies: - "@webassemblyjs/wast-printer" "1.8.5" - -"@webassemblyjs/helper-fsm@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-fsm/-/helper-fsm-1.8.5.tgz#ba0b7d3b3f7e4733da6059c9332275d860702452" - integrity sha512-kRuX/saORcg8se/ft6Q2UbRpZwP4y7YrWsLXPbbmtepKr22i8Z4O3V5QE9DbZK908dh5Xya4Un57SDIKwB9eow== - -"@webassemblyjs/helper-module-context@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-module-context/-/helper-module-context-1.8.5.tgz#def4b9927b0101dc8cbbd8d1edb5b7b9c82eb245" - integrity sha512-/O1B236mN7UNEU4t9X7Pj38i4VoU8CcMHyy3l2cV/kIF4U5KoHXDVqcDuOs1ltkac90IM4vZdHc52t1x8Yfs3g== - dependencies: - "@webassemblyjs/ast" "1.8.5" - mamacro "^0.0.3" - -"@webassemblyjs/helper-wasm-bytecode@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.8.5.tgz#537a750eddf5c1e932f3744206551c91c1b93e61" - integrity sha512-Cu4YMYG3Ddl72CbmpjU/wbP6SACcOPVbHN1dI4VJNJVgFwaKf1ppeFJrwydOG3NDHxVGuCfPlLZNyEdIYlQ6QQ== - -"@webassemblyjs/helper-wasm-section@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.8.5.tgz#74ca6a6bcbe19e50a3b6b462847e69503e6bfcbf" - integrity sha512-VV083zwR+VTrIWWtgIUpqfvVdK4ff38loRmrdDBgBT8ADXYsEZ5mPQ4Nde90N3UYatHdYoDIFb7oHzMncI02tA== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-buffer" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/wasm-gen" "1.8.5" - -"@webassemblyjs/ieee754@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.8.5.tgz#712329dbef240f36bf57bd2f7b8fb9bf4154421e" - integrity sha512-aaCvQYrvKbY/n6wKHb/ylAJr27GglahUO89CcGXMItrOBqRarUMxWLJgxm9PJNuKULwN5n1csT9bYoMeZOGF3g== +"@webassemblyjs/ast@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964" + integrity sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA== + dependencies: + "@webassemblyjs/helper-module-context" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/wast-parser" "1.9.0" + +"@webassemblyjs/floating-point-hex-parser@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.9.0.tgz#3c3d3b271bddfc84deb00f71344438311d52ffb4" + integrity sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA== + +"@webassemblyjs/helper-api-error@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz#203f676e333b96c9da2eeab3ccef33c45928b6a2" + integrity sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw== + +"@webassemblyjs/helper-buffer@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz#a1442d269c5feb23fcbc9ef759dac3547f29de00" + integrity sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA== + +"@webassemblyjs/helper-code-frame@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.9.0.tgz#647f8892cd2043a82ac0c8c5e75c36f1d9159f27" + integrity sha512-ERCYdJBkD9Vu4vtjUYe8LZruWuNIToYq/ME22igL+2vj2dQ2OOujIZr3MEFvfEaqKoVqpsFKAGsRdBSBjrIvZA== + dependencies: + "@webassemblyjs/wast-printer" "1.9.0" + +"@webassemblyjs/helper-fsm@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-fsm/-/helper-fsm-1.9.0.tgz#c05256b71244214671f4b08ec108ad63b70eddb8" + integrity sha512-OPRowhGbshCb5PxJ8LocpdX9Kl0uB4XsAjl6jH/dWKlk/mzsANvhwbiULsaiqT5GZGT9qinTICdj6PLuM5gslw== + +"@webassemblyjs/helper-module-context@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-module-context/-/helper-module-context-1.9.0.tgz#25d8884b76839871a08a6c6f806c3979ef712f07" + integrity sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g== + dependencies: + "@webassemblyjs/ast" "1.9.0" + +"@webassemblyjs/helper-wasm-bytecode@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz#4fed8beac9b8c14f8c58b70d124d549dd1fe5790" + integrity sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw== + +"@webassemblyjs/helper-wasm-section@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz#5a4138d5a6292ba18b04c5ae49717e4167965346" + integrity sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-buffer" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/wasm-gen" "1.9.0" + +"@webassemblyjs/ieee754@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz#15c7a0fbaae83fb26143bbacf6d6df1702ad39e4" + integrity sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg== dependencies: "@xtuc/ieee754" "^1.2.0" -"@webassemblyjs/leb128@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.8.5.tgz#044edeb34ea679f3e04cd4fd9824d5e35767ae10" - integrity sha512-plYUuUwleLIziknvlP8VpTgO4kqNaH57Y3JnNa6DLpu/sGcP6hbVdfdX5aHAV716pQBKrfuU26BJK29qY37J7A== +"@webassemblyjs/leb128@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.9.0.tgz#f19ca0b76a6dc55623a09cffa769e838fa1e1c95" + integrity sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw== dependencies: "@xtuc/long" "4.2.2" -"@webassemblyjs/utf8@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.8.5.tgz#a8bf3b5d8ffe986c7c1e373ccbdc2a0915f0cedc" - integrity sha512-U7zgftmQriw37tfD934UNInokz6yTmn29inT2cAetAsaU9YeVCveWEwhKL1Mg4yS7q//NGdzy79nlXh3bT8Kjw== - -"@webassemblyjs/wasm-edit@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.8.5.tgz#962da12aa5acc1c131c81c4232991c82ce56e01a" - integrity sha512-A41EMy8MWw5yvqj7MQzkDjU29K7UJq1VrX2vWLzfpRHt3ISftOXqrtojn7nlPsZ9Ijhp5NwuODuycSvfAO/26Q== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-buffer" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/helper-wasm-section" "1.8.5" - "@webassemblyjs/wasm-gen" "1.8.5" - "@webassemblyjs/wasm-opt" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" - "@webassemblyjs/wast-printer" "1.8.5" - -"@webassemblyjs/wasm-gen@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.8.5.tgz#54840766c2c1002eb64ed1abe720aded714f98bc" - integrity sha512-BCZBT0LURC0CXDzj5FXSc2FPTsxwp3nWcqXQdOZE4U7h7i8FqtFK5Egia6f9raQLpEKT1VL7zr4r3+QX6zArWg== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/ieee754" "1.8.5" - "@webassemblyjs/leb128" "1.8.5" - "@webassemblyjs/utf8" "1.8.5" - -"@webassemblyjs/wasm-opt@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.8.5.tgz#b24d9f6ba50394af1349f510afa8ffcb8a63d264" - integrity sha512-HKo2mO/Uh9A6ojzu7cjslGaHaUU14LdLbGEKqTR7PBKwT6LdPtLLh9fPY33rmr5wcOMrsWDbbdCHq4hQUdd37Q== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-buffer" "1.8.5" - "@webassemblyjs/wasm-gen" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" - -"@webassemblyjs/wasm-parser@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.8.5.tgz#21576f0ec88b91427357b8536383668ef7c66b8d" - integrity sha512-pi0SYE9T6tfcMkthwcgCpL0cM9nRYr6/6fjgDtL6q/ZqKHdMWvxitRi5JcZ7RI4SNJJYnYNaWy5UUrHQy998lw== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-api-error" "1.8.5" - "@webassemblyjs/helper-wasm-bytecode" "1.8.5" - "@webassemblyjs/ieee754" "1.8.5" - "@webassemblyjs/leb128" "1.8.5" - "@webassemblyjs/utf8" "1.8.5" - -"@webassemblyjs/wast-parser@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-parser/-/wast-parser-1.8.5.tgz#e10eecd542d0e7bd394f6827c49f3df6d4eefb8c" - integrity sha512-daXC1FyKWHF1i11obK086QRlsMsY4+tIOKgBqI1lxAnkp9xe9YMcgOxm9kLe+ttjs5aWV2KKE1TWJCN57/Btsg== - dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/floating-point-hex-parser" "1.8.5" - "@webassemblyjs/helper-api-error" "1.8.5" - "@webassemblyjs/helper-code-frame" "1.8.5" - "@webassemblyjs/helper-fsm" "1.8.5" +"@webassemblyjs/utf8@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.9.0.tgz#04d33b636f78e6a6813227e82402f7637b6229ab" + integrity sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w== + +"@webassemblyjs/wasm-edit@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz#3fe6d79d3f0f922183aa86002c42dd256cfee9cf" + integrity sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-buffer" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/helper-wasm-section" "1.9.0" + "@webassemblyjs/wasm-gen" "1.9.0" + "@webassemblyjs/wasm-opt" "1.9.0" + "@webassemblyjs/wasm-parser" "1.9.0" + "@webassemblyjs/wast-printer" "1.9.0" + +"@webassemblyjs/wasm-gen@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz#50bc70ec68ded8e2763b01a1418bf43491a7a49c" + integrity sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/ieee754" "1.9.0" + "@webassemblyjs/leb128" "1.9.0" + "@webassemblyjs/utf8" "1.9.0" + +"@webassemblyjs/wasm-opt@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz#2211181e5b31326443cc8112eb9f0b9028721a61" + integrity sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-buffer" "1.9.0" + "@webassemblyjs/wasm-gen" "1.9.0" + "@webassemblyjs/wasm-parser" "1.9.0" + +"@webassemblyjs/wasm-parser@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz#9d48e44826df4a6598294aa6c87469d642fff65e" + integrity sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-api-error" "1.9.0" + "@webassemblyjs/helper-wasm-bytecode" "1.9.0" + "@webassemblyjs/ieee754" "1.9.0" + "@webassemblyjs/leb128" "1.9.0" + "@webassemblyjs/utf8" "1.9.0" + +"@webassemblyjs/wast-parser@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-parser/-/wast-parser-1.9.0.tgz#3031115d79ac5bd261556cecc3fa90a3ef451914" + integrity sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw== + dependencies: + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/floating-point-hex-parser" "1.9.0" + "@webassemblyjs/helper-api-error" "1.9.0" + "@webassemblyjs/helper-code-frame" "1.9.0" + "@webassemblyjs/helper-fsm" "1.9.0" "@xtuc/long" "4.2.2" -"@webassemblyjs/wast-printer@1.8.5": - version "1.8.5" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.8.5.tgz#114bbc481fd10ca0e23b3560fa812748b0bae5bc" - integrity sha512-w0U0pD4EhlnvRyeJzBqaVSJAo9w/ce7/WPogeXLzGkO6hzhr4GnQIZ4W4uUt5b9ooAaXPtnXlj0gzsXEOUNYMg== +"@webassemblyjs/wast-printer@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz#4935d54c85fef637b00ce9f52377451d00d47899" + integrity sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA== dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/wast-parser" "1.8.5" + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/wast-parser" "1.9.0" "@xtuc/long" "4.2.2" "@wildpeaks/snapshot-dom@1.6.0": @@ -1195,10 +1251,10 @@ am-i-a-dependency@1.1.2: resolved "https://registry.yarnpkg.com/am-i-a-dependency/-/am-i-a-dependency-1.1.2.tgz#f9d3422304d6f642f821e4c407565035f6167f1f" integrity sha1-+dNCIwTW9kL4IeTEB1ZQNfYWfx8= -ansi-escapes@^1.0.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" - integrity sha1-06ioOzGapneTZisT52HHkRQiMG4= +ansi-escapes@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" + integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== ansi-regex@^2.0.0: version "2.1.1" @@ -1215,6 +1271,11 @@ ansi-regex@^4.1.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== +ansi-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" + integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== + ansi-styles@^2.0.1, ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" @@ -1227,6 +1288,19 @@ ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" +ansi-styles@^4.1.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359" + integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA== + dependencies: + "@types/color-name" "^1.1.1" + color-convert "^2.0.1" + +any-observable@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b" + integrity sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog== + any-promise@^1.0.0: version "1.3.0" resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" @@ -1240,7 +1314,7 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" -aproba@^1.0.3, aproba@^1.1.1: +aproba@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== @@ -1250,14 +1324,6 @@ arch@2.1.1: resolved "https://registry.yarnpkg.com/arch/-/arch-2.1.1.tgz#8f5c2731aa35a30929221bb0640eed65175ec84e" integrity sha512-BLM56aPo9vLLFVa8+/+pJLnrZ7QGGTVHWsCwieAWT9o9K8UeGaQbzZbGoabWLOo2ksBCztoXdqBZBplqLDDCSg== -are-we-there-yet@~1.1.2: - version "1.1.5" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" - integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== - dependencies: - delegates "^1.0.0" - readable-stream "^2.0.6" - argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -1338,12 +1404,10 @@ async-each@^1.0.1: resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== -async@2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610" - integrity sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ== - dependencies: - lodash "^4.17.10" +async@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" + integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== asynckit@^0.4.0: version "0.4.0" @@ -1447,11 +1511,6 @@ bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" -bluebird@3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c" - integrity sha1-eRQg1/VR7qKJdFOop3ZT+WYG1nw= - bluebird@3.5.3: version "3.5.3" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7" @@ -1462,7 +1521,7 @@ bluebird@3.7.1: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.1.tgz#df70e302b471d7473489acf26a93d63b53f874de" integrity sha512-DdmyoGCleJnkbp3nkbxTLJ18rjDsE4yCggEwKNXkeV123sPNfOCYeDoeuOY+F2FrSjO1YXcTU+dsy96KMy+gcg== -bluebird@^3.4.1, bluebird@^3.5.5: +bluebird@3.7.2, bluebird@^3.4.1, bluebird@^3.5.5: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== @@ -1781,12 +1840,10 @@ cached-path-relative@^1.0.0, cached-path-relative@^1.0.2: resolved "https://registry.yarnpkg.com/cached-path-relative/-/cached-path-relative-1.0.2.tgz#a13df4196d26776220cc3356eb147a52dba2c6db" integrity sha512-5r2GqsoEb4qMTTN9J+WzXfjov+hjxT+j3u5K+kIVNIwAd99DLCJE9pBIMP1qVeybV6JiijL385Oz0DcYxfbOIg== -cachedir@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-1.3.0.tgz#5e01928bf2d95b5edd94b0942188246740e0dbc4" - integrity sha512-O1ji32oyON9laVPJL1IZ5bmwd2cB46VfpxkDequezH+15FDzzVddEyrGEeX4WusDSqKxdyFdDQDEG1yo1GoWkg== - dependencies: - os-homedir "^1.0.1" +cachedir@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.3.0.tgz#0c75892a052198f0b21c7c1804d8331edfcae0e8" + integrity sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw== caniuse-lite@^1.0.30001023: version "1.0.30001027" @@ -1810,7 +1867,7 @@ chai@^4.1.2: pathval "^1.1.0" type-detect "^4.0.5" -chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0: +chalk@2.4.2, chalk@^2.0.0, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -1830,6 +1887,14 @@ chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + check-error@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" @@ -1871,10 +1936,10 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -ci-info@^1.5.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497" - integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A== +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" @@ -1901,10 +1966,34 @@ cli-cursor@^1.0.2: dependencies: restore-cursor "^1.0.1" -cli-spinners@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-0.1.2.tgz#bb764d88e185fb9e1e6a2a1f19772318f605e31c" - integrity sha1-u3ZNiOGF+54eaiofGXcjGPYF4xw= +cli-cursor@^2.0.0, cli-cursor@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" + integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU= + dependencies: + restore-cursor "^2.0.0" + +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + dependencies: + restore-cursor "^3.1.0" + +cli-spinners@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.2.0.tgz#e8b988d9206c692302d8ee834e7a85c0144d8f77" + integrity sha512-tgU3fKwzYjiLEQgPMD9Jt+JjHVL9kW93FiIMX/l7rivvOD4/LL0Mf7gda3+4U2KJBloybwgj5KEoQgGRioMiKQ== + +cli-table3@0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.5.1.tgz#0252372d94dfc40dbd8df06005f48f31f656f202" + integrity sha512-7Qg2Jrep1S/+Q3EceiZtQcDPWxhAvBw+ERf1162v4sikJrvojMHFqXt8QIVha8UlH9rgU0BeWPytZ9/TzYqlUw== + dependencies: + object-assign "^4.1.0" + string-width "^2.1.1" + optionalDependencies: + colors "^1.1.2" cli-table@^0.3.1: version "0.3.1" @@ -1921,6 +2010,11 @@ cli-truncate@^0.2.1: slice-ansi "0.0.4" string-width "^1.0.1" +clone@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" + integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= + code-point-at@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" @@ -1954,11 +2048,23 @@ color-convert@^1.9.0: dependencies: color-name "1.1.3" +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + color-name@1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + colors@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" @@ -1986,10 +2092,10 @@ combined-stream@^1.0.6, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" -commander@2.15.1: - version "2.15.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f" - integrity sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag== +commander@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.0.tgz#545983a0603fe425bc672d66c9e3c89c42121a83" + integrity sha512-NIQrwvv9V39FHgGFm36+U9SMQzbiHvU79k+iADraJTpmrFFfx7Ds0IvDoAdZsDrknlkRk14OYoWXb57uTh7/sw== commander@^2.19.0, commander@^2.20.0, commander@^2.9.0: version "2.20.3" @@ -2039,11 +2145,6 @@ console-browserify@^1.1.0: resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA== -console-control-strings@^1.0.0, console-control-strings@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= - constants-browserify@^1.0.0, constants-browserify@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" @@ -2242,42 +2343,45 @@ cypress-cucumber-preprocessor@^2.0.1: minimist "^1.2.0" through "^2.3.8" -cypress@^3.5.0: - version "3.8.3" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-3.8.3.tgz#e921f5482f1cbe5814891c878f26e704bbffd8f4" - integrity sha512-I9L/d+ilTPPA4vq3NC1OPKmw7jJIpMKNdyfR8t1EXYzYCjyqbc59migOm1YSse/VRbISLJ+QGb5k4Y3bz2lkYw== +cypress@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-4.2.0.tgz#45673fb648b1a77b9a78d73e58b89ed05212d243" + integrity sha512-8LdreL91S/QiTCLYLNbIjLL8Ht4fJmu/4HGLxUI20Tc7JSfqEfCmXELrRfuPT0kjosJwJJZacdSji9XSRkPKUw== dependencies: "@cypress/listr-verbose-renderer" "0.4.1" "@cypress/xvfb" "1.2.4" "@types/sizzle" "2.3.2" arch "2.1.1" - bluebird "3.5.0" - cachedir "1.3.0" + bluebird "3.7.2" + cachedir "2.3.0" chalk "2.4.2" check-more-types "2.24.0" - commander "2.15.1" + cli-table3 "0.5.1" + commander "4.1.0" common-tags "1.8.0" - debug "3.2.6" + debug "4.1.1" eventemitter2 "4.1.2" - execa "0.10.0" + execa "1.0.0" executable "4.1.1" extract-zip "1.6.7" - fs-extra "5.0.0" - getos "3.1.1" - is-ci "1.2.1" + fs-extra "8.1.0" + getos "3.1.4" + is-ci "2.0.0" is-installed-globally "0.1.0" lazy-ass "1.6.0" - listr "0.12.0" + listr "0.14.3" lodash "4.17.15" - log-symbols "2.2.0" - minimist "1.2.0" + log-symbols "3.0.0" + minimist "1.2.2" moment "2.24.0" - ramda "0.24.1" - request "2.88.0" + ospath "1.2.2" + pretty-bytes "5.3.0" + ramda "0.26.1" + request cypress-io/request#b5af0d1fa47eec97ba980cde90a13e69a2afcd16 request-progress "3.0.0" - supports-color "5.5.0" + supports-color "7.1.0" tmp "0.1.0" - untildify "3.0.3" + untildify "4.0.0" url "0.11.0" yauzl "2.10.0" @@ -2320,13 +2424,6 @@ debug@3.1.0: dependencies: ms "2.0.0" -debug@3.2.6, debug@^3.0.1, debug@^3.1.0, debug@^3.2.6: - version "3.2.6" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" - integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== - dependencies: - ms "^2.1.1" - debug@4.1.1, debug@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" @@ -2334,6 +2431,13 @@ debug@4.1.1, debug@^4.1.0: dependencies: ms "^2.1.1" +debug@^3.0.1, debug@^3.1.0: + version "3.2.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" + integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== + dependencies: + ms "^2.1.1" + decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" @@ -2346,10 +2450,12 @@ deep-eql@^3.0.1: dependencies: type-detect "^4.0.0" -deep-extend@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" - integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== +defaults@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d" + integrity sha1-xlYFHpgX2f8I7YgUd/P+QBnz730= + dependencies: + clone "^1.0.2" define-properties@^1.1.2: version "1.1.3" @@ -2390,11 +2496,6 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= - deps-sort@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/deps-sort/-/deps-sort-2.0.1.tgz#9dfdc876d2bcec3386b6829ac52162cda9fa208d" @@ -2413,11 +2514,6 @@ des.js@^1.0.0: inherits "^2.0.1" minimalistic-assert "^1.0.0" -detect-libc@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= - detective@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.0.tgz#feb2a77e85b904ecdea459ad897cc90a99bd2a7b" @@ -2651,13 +2747,13 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: md5.js "^1.3.4" safe-buffer "^5.1.1" -execa@0.10.0: - version "0.10.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-0.10.0.tgz#ff456a8f53f90f8eccc71a96d11bdfc7f082cb50" - integrity sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw== +execa@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" + integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== dependencies: cross-spawn "^6.0.0" - get-stream "^3.0.0" + get-stream "^4.0.0" is-stream "^1.1.0" npm-run-path "^2.0.0" p-finally "^1.0.0" @@ -2784,7 +2880,7 @@ figgy-pudding@^3.5.1: resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" integrity sha512-vNKxJHTEKNThjfrdJwHc7brvM6eVevuO5nTj6ez8ZQ1qbXTvGthucRF7S4vf2cr71QVnT70V34v0S1DyQsti0w== -figures@2.0.0: +figures@2.0.0, figures@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI= @@ -2889,15 +2985,6 @@ from2@^2.1.0: inherits "^2.0.1" readable-stream "^2.0.0" -fs-extra@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-5.0.0.tgz#414d0110cdd06705734d055652c5411260c31abd" - integrity sha512-66Pm4RYbjzdyeuqudYqhFiNBbCIuI9kgRqLPSHIlXHidW8NIQtVdkM1yeZ4lXwuhbTETv3EUGMNHAAw6hiundQ== - dependencies: - graceful-fs "^4.1.2" - jsonfile "^4.0.0" - universalify "^0.1.0" - fs-extra@7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" @@ -2907,12 +2994,14 @@ fs-extra@7.0.1: jsonfile "^4.0.0" universalify "^0.1.0" -fs-minipass@^1.2.5: - version "1.2.7" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7" - integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA== +fs-extra@8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== dependencies: - minipass "^2.6.0" + graceful-fs "^4.2.0" + jsonfile "^4.0.0" + universalify "^0.1.0" fs-write-stream-atomic@^1.0.8: version "1.0.10" @@ -2942,20 +3031,6 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== -gauge@~2.7.3: - version "2.7.4" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" - integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= - dependencies: - aproba "^1.0.3" - console-control-strings "^1.0.0" - has-unicode "^2.0.0" - object-assign "^4.1.0" - signal-exit "^3.0.0" - string-width "^1.0.1" - strip-ansi "^3.0.1" - wide-align "^1.1.0" - gensync@^1.0.0-beta.1: version "1.0.0-beta.1" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269" @@ -2971,22 +3046,24 @@ get-func-name@^2.0.0: resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE= -get-stream@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" - integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ= +get-stream@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" + integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== + dependencies: + pump "^3.0.0" get-value@^2.0.3, get-value@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= -getos@3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/getos/-/getos-3.1.1.tgz#967a813cceafee0156b0483f7cffa5b3eff029c5" - integrity sha512-oUP1rnEhAr97rkitiszGP9EgDVYnmchgFzfqRzSkgtfv7ai6tEi7Ko8GgjNXts7VLWEqrTWyhsOKLe5C5b/Zkg== +getos@3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/getos/-/getos-3.1.4.tgz#29cdf240ed10a70c049add7b6f8cb08c81876faf" + integrity sha512-UORPzguEB/7UG5hqiZai8f0vQ7hzynMQyJLxStoQ8dPGAcmgsfXOPA4iE/fGtweHYkK+z4zc9V0g+CIFRf5HYw== dependencies: - async "2.6.1" + async "^3.1.0" getpass@^0.1.1: version "0.1.7" @@ -3032,7 +3109,7 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6: +graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423" integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ== @@ -3042,7 +3119,7 @@ har-schema@^2.0.0: resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= -har-validator@~5.1.0: +har-validator@~5.1.3: version "5.1.3" resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080" integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g== @@ -3062,16 +3139,16 @@ has-flag@^3.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + has-symbols@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== -has-unicode@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= - has-value@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" @@ -3154,13 +3231,6 @@ https-browserify@^1.0.0: resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= -iconv-lite@^0.4.4: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - ieee754@^1.1.4: version "1.1.13" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" @@ -3171,25 +3241,11 @@ iferr@^0.1.5: resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= -ignore-walk@^3.0.1: - version "3.0.3" - resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.3.tgz#017e2447184bfeade7c238e4aefdd1e8f95b1e37" - integrity sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw== - dependencies: - minimatch "^3.0.4" - imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= -indent-string@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80" - integrity sha1-ji1INIdCEhtKghi3oTfppSBJ3IA= - dependencies: - repeating "^2.0.0" - indent-string@^3.0.0, indent-string@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289" @@ -3223,7 +3279,7 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= -ini@^1.3.4, ini@~1.3.0: +ini@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== @@ -3289,12 +3345,12 @@ is-buffer@^1.1.0, is-buffer@^1.1.5: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-ci@1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c" - integrity sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg== +is-ci@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== dependencies: - ci-info "^1.5.0" + ci-info "^2.0.0" is-data-descriptor@^0.1.4: version "0.1.4" @@ -3350,11 +3406,6 @@ is-extglob@^2.1.0, is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= -is-finite@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.1.0.tgz#904135c77fb42c0641d6aa1bcdbc4daa8da082f3" - integrity sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w== - is-fullwidth-code-point@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" @@ -3394,6 +3445,11 @@ is-installed-globally@0.1.0: global-dirs "^0.1.0" is-path-inside "^1.0.0" +is-interactive@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" + integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== + is-number@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" @@ -3406,6 +3462,13 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +is-observable@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-observable/-/is-observable-1.1.0.tgz#b3e986c8f44de950867cab5403f5a3465005975e" + integrity sha512-NqCa4Sa2d+u7BWc6CukaObG3Fh+CU9bvixbpcXYhy2VvYS7vVGIdAgnIS5Ks3A/cqk4rebLJ9s8zBstT2aKnIA== + dependencies: + symbol-observable "^1.1.0" + is-path-inside@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" @@ -3655,10 +3718,10 @@ listr-silent-renderer@^1.1.1: resolved "https://registry.yarnpkg.com/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e" integrity sha1-kktaN1cVN3C/Go4/v3S4u/P5JC4= -listr-update-renderer@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/listr-update-renderer/-/listr-update-renderer-0.2.0.tgz#ca80e1779b4e70266807e8eed1ad6abe398550f9" - integrity sha1-yoDhd5tOcCZoB+ju0a1qvjmFUPk= +listr-update-renderer@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/listr-update-renderer/-/listr-update-renderer-0.5.0.tgz#4ea8368548a7b8aecb7e06d8c95cb45ae2ede6a2" + integrity sha512-tKRsZpKz8GSGqoI/+caPmfrypiaq+OQCbd+CovEC24uk1h952lVj5sC7SqyFUm+OaJ5HN/a1YLt5cit2FMNsFA== dependencies: chalk "^1.1.3" cli-truncate "^0.2.1" @@ -3666,40 +3729,33 @@ listr-update-renderer@^0.2.0: figures "^1.7.0" indent-string "^3.0.0" log-symbols "^1.0.2" - log-update "^1.0.2" + log-update "^2.3.0" strip-ansi "^3.0.1" -listr-verbose-renderer@^0.4.0: - version "0.4.1" - resolved "https://registry.yarnpkg.com/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz#8206f4cf6d52ddc5827e5fd14989e0e965933a35" - integrity sha1-ggb0z21S3cWCfl/RSYng6WWTOjU= +listr-verbose-renderer@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/listr-verbose-renderer/-/listr-verbose-renderer-0.5.0.tgz#f1132167535ea4c1261102b9f28dac7cba1e03db" + integrity sha512-04PDPqSlsqIOaaaGZ+41vq5FejI9auqTInicFRndCBgE3bXG8D6W1I+mWhk+1nqbHmyhla/6BUrd5OSiHwKRXw== dependencies: - chalk "^1.1.3" - cli-cursor "^1.0.2" + chalk "^2.4.1" + cli-cursor "^2.1.0" date-fns "^1.27.2" - figures "^1.7.0" + figures "^2.0.0" -listr@0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/listr/-/listr-0.12.0.tgz#6bce2c0f5603fa49580ea17cd6a00cc0e5fa451a" - integrity sha1-a84sD1YD+klYDqF81qAMwOX6RRo= +listr@0.14.3: + version "0.14.3" + resolved "https://registry.yarnpkg.com/listr/-/listr-0.14.3.tgz#2fea909604e434be464c50bddba0d496928fa586" + integrity sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA== dependencies: - chalk "^1.1.3" - cli-truncate "^0.2.1" - figures "^1.7.0" - indent-string "^2.1.0" + "@samverschueren/stream-to-observable" "^0.3.0" + is-observable "^1.1.0" is-promise "^2.1.0" is-stream "^1.1.0" listr-silent-renderer "^1.1.1" - listr-update-renderer "^0.2.0" - listr-verbose-renderer "^0.4.0" - log-symbols "^1.0.2" - log-update "^1.0.2" - ora "^0.2.3" - p-map "^1.1.1" - rxjs "^5.0.0-beta.11" - stream-to-observable "^0.1.0" - strip-ansi "^3.0.1" + listr-update-renderer "^0.5.0" + listr-verbose-renderer "^0.5.0" + p-map "^2.0.0" + rxjs "^6.3.3" loader-runner@^2.4.0: version "2.4.0" @@ -3738,17 +3794,17 @@ lodash.once@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= -lodash@4.17.15, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.4: +lodash@4.17.15, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.15, lodash@^4.17.4: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== -log-symbols@2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a" - integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg== +log-symbols@3.0.0, log-symbols@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4" + integrity sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ== dependencies: - chalk "^2.0.1" + chalk "^2.4.2" log-symbols@^1.0.2: version "1.0.2" @@ -3757,13 +3813,14 @@ log-symbols@^1.0.2: dependencies: chalk "^1.0.0" -log-update@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/log-update/-/log-update-1.0.2.tgz#19929f64c4093d2d2e7075a1dad8af59c296b8d1" - integrity sha1-GZKfZMQJPS0ucHWh2tivWcKWuNE= +log-update@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/log-update/-/log-update-2.3.0.tgz#88328fd7d1ce7938b29283746f0b1bc126b24708" + integrity sha1-iDKP19HOeTiykoN0bwsbwSayRwg= dependencies: - ansi-escapes "^1.0.0" - cli-cursor "^1.0.2" + ansi-escapes "^3.0.0" + cli-cursor "^2.0.0" + wrap-ansi "^3.0.1" loose-envify@^1.0.0: version "1.4.0" @@ -3800,11 +3857,6 @@ make-dir@^2.0.0: pify "^4.0.1" semver "^5.6.0" -mamacro@^0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/mamacro/-/mamacro-0.0.3.tgz#ad2c9576197c9f1abf308d0787865bd975a3f3e4" - integrity sha512-qMEwh+UujcQ+kbz3T6V+wAmO2U8veoq2w+3wY8MquqwVA3jChfwY+Tk52GZKDfACEPjuZ7r2oJLejwpt8jtwTA== - map-cache@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" @@ -3889,6 +3941,16 @@ mime-types@^2.1.12, mime-types@~2.1.19: dependencies: mime-db "1.43.0" +mimic-fn@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" + integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ== + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" @@ -3911,25 +3973,20 @@ minimist@0.0.8: resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= -minimist@1.2.0, minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0: +minimist@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.2.tgz#b00a00230a1108c48c169e69a291aafda3aacd63" + integrity sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA== + +minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= -minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" - integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg== - dependencies: - safe-buffer "^5.1.2" - yallist "^3.0.0" - -minizlib@^1.2.1: - version "1.3.3" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d" - integrity sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q== - dependencies: - minipass "^2.9.0" +minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== mississippi@^3.0.0: version "3.0.0" @@ -3962,6 +4019,13 @@ mkdirp@0.5.1, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1: dependencies: minimist "0.0.8" +mkdirp@^0.5.3: + version "0.5.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.4.tgz#fd01504a6797ec5c9be81ff43d204961ed64a512" + integrity sha512-iG9AK/dJLtJ0XNgTuDbSyNS3zECqDlAhnQW4CsNxBG3LQJBbHmRX1egw39DmtOdCAqY+dKXV+sgPgilNWUKMVw== + dependencies: + minimist "^1.2.5" + module-deps@^6.0.0: version "6.2.2" resolved "https://registry.yarnpkg.com/module-deps/-/module-deps-6.2.2.tgz#d8a15c2265dfc119153c29bb47386987d0ee423b" @@ -4010,6 +4074,11 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +mute-stream@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" + integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== + mz@^2.4.0: version "2.7.0" resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" @@ -4041,15 +4110,6 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" -needle@^2.2.1: - version "2.3.2" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.3.2.tgz#3342dea100b7160960a450dc8c22160ac712a528" - integrity sha512-DUzITvPVDUy6vczKKYTnWc/pBZ0EnjMJnQ3y+Jo5zfKFimJs7S3HFCxCRZYB9FUZcrzUQr3WsmvZgddMEIZv6w== - dependencies: - debug "^3.2.6" - iconv-lite "^0.4.4" - sax "^1.2.4" - neo-async@^2.5.0, neo-async@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" @@ -4101,22 +4161,6 @@ node-libs-browser@^2.2.1: util "^0.11.0" vm-browserify "^1.0.1" -node-pre-gyp@*: - version "0.14.0" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.14.0.tgz#9a0596533b877289bcad4e143982ca3d904ddc83" - integrity sha512-+CvDC7ZttU/sSt9rFjix/P05iS43qHCOOGzcr3Ry99bXG7VX953+vFyEuph/tfqoYu8dttBkE86JSKBO2OzcxA== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4.4.2" - node-releases@^1.1.47: version "1.1.48" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.48.tgz#7f647f0c453a0495bcd64cbd4778c26035c2f03a" @@ -4124,7 +4168,7 @@ node-releases@^1.1.47: dependencies: semver "^6.3.0" -nopt@^4.0.1, nopt@~4.0.1: +nopt@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= @@ -4144,27 +4188,6 @@ normalize-path@^3.0.0: resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== -npm-bundled@^1.0.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b" - integrity sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA== - dependencies: - npm-normalize-package-bin "^1.0.1" - -npm-normalize-package-bin@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2" - integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA== - -npm-packlist@^1.1.6: - version "1.4.8" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.8.tgz#56ee6cc135b9f98ad3d51c1c95da22bbb9b2ef3e" - integrity sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A== - dependencies: - ignore-walk "^3.0.1" - npm-bundled "^1.0.1" - npm-normalize-package-bin "^1.0.1" - npm-run-path@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" @@ -4172,16 +4195,6 @@ npm-run-path@^2.0.0: dependencies: path-key "^2.0.0" -npmlog@^4.0.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" - integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== - dependencies: - are-we-there-yet "~1.1.2" - console-control-strings "~1.1.0" - gauge "~2.7.3" - set-blocking "~2.0.0" - number-is-nan@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" @@ -4247,22 +4260,40 @@ onetime@^1.0.0: resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k= -ora@^0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/ora/-/ora-0.2.3.tgz#37527d220adcd53c39b73571d754156d5db657a4" - integrity sha1-N1J9Igrc1Tw5tzVx11QVbV22V6Q= +onetime@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" + integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ= dependencies: - chalk "^1.1.1" - cli-cursor "^1.0.2" - cli-spinners "^0.1.2" - object-assign "^4.0.1" + mimic-fn "^1.0.0" + +onetime@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.0.tgz#fff0f3c91617fe62bb50189636e99ac8a6df7be5" + integrity sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q== + dependencies: + mimic-fn "^2.1.0" + +ora@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/ora/-/ora-4.0.3.tgz#752a1b7b4be4825546a7a3d59256fa523b6b6d05" + integrity sha512-fnDebVFyz309A73cqCipVL1fBZewq4vwgSHfxh43vVy31mbyoQ8sCH3Oeaog/owYOs/lLlGVPCISQonTneg6Pg== + dependencies: + chalk "^3.0.0" + cli-cursor "^3.1.0" + cli-spinners "^2.2.0" + is-interactive "^1.0.0" + log-symbols "^3.0.0" + mute-stream "0.0.8" + strip-ansi "^6.0.0" + wcwidth "^1.0.1" os-browserify@^0.3.0, os-browserify@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= -os-homedir@^1.0.0, os-homedir@^1.0.1: +os-homedir@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= @@ -4280,6 +4311,11 @@ osenv@^0.1.4: os-homedir "^1.0.0" os-tmpdir "^1.0.0" +ospath@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/ospath/-/ospath-1.2.2.tgz#1276639774a3f8ef2572f7fe4280e0ea4550c07b" + integrity sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs= + outpipe@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/outpipe/-/outpipe-1.1.1.tgz#50cf8616365e87e031e29a5ec9339a3da4725fa2" @@ -4306,10 +4342,18 @@ p-locate@^3.0.0: dependencies: p-limit "^2.0.0" -p-map@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" - integrity sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA== +p-map@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" + integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw== + +p-retry@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.2.0.tgz#ea9066c6b44f23cab4cd42f6147cdbbc6604da5d" + integrity sha512-jPH38/MRh263KKcq0wBNOGFJbm+U6784RilTmHjB/HM9kH9V8WlCpVUcdOmip9cjXOh6MxZ5yk1z2SjDUJfWmA== + dependencies: + "@types/retry" "^0.12.0" + retry "^0.12.0" p-try@^2.0.0: version "2.2.0" @@ -4462,6 +4506,11 @@ posix-character-classes@^0.1.0: resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= +pretty-bytes@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.3.0.tgz#f2849e27db79fb4d6cfe24764fc4134f165989f2" + integrity sha512-hjGrh+P926p4R4WbaB6OckyRtO0F0/lQBiT+0gnxjV+5kjPBrfVBFCsCLbMqVQeydvIoouYTCmmEURiH3R1Bdg== + private@^0.1.6: version "0.1.8" resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" @@ -4502,7 +4551,7 @@ pseudomap@^1.0.2: resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= -psl@^1.1.24: +psl@^1.1.28: version "1.7.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.7.0.tgz#f1c4c47a8ef97167dea5d6bbf4816d736e884a3c" integrity sha512-5NsSEDv8zY70ScRnOTn7bK7eanl2MvFrOrS/R6x+dBt5g1ghnj9Zv90kO8GwT8gxcu2ANyFprnFYB85IogIJOQ== @@ -4549,12 +4598,12 @@ punycode@1.3.2: resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= -punycode@^1.2.4, punycode@^1.3.2, punycode@^1.4.1: +punycode@^1.2.4, punycode@^1.3.2: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= -punycode@^2.1.0: +punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== @@ -4574,16 +4623,16 @@ querystring@0.2.0: resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= -ramda@0.24.1: - version "0.24.1" - resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.24.1.tgz#c3b7755197f35b8dc3502228262c4c91ddb6b857" - integrity sha1-w7d1UZfzW43DUCIoJixMkd22uFc= - ramda@0.25.0: version "0.25.0" resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.25.0.tgz#8fdf68231cffa90bc2f9460390a0cb74a29b29a9" integrity sha512-GXpfrYVPwx3K7RQ6aYT8KPS8XViSXUVJT1ONhoKPE9VAleW42YE+U+8VEyGWt41EnEQW7gwecYJriTI0pKoecQ== +ramda@0.26.1: + version "0.26.1" + resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06" + integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ== + randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -4599,16 +4648,6 @@ randomfill@^1.0.3: randombytes "^2.0.5" safe-buffer "^5.1.0" -rc@^1.2.7: - version "1.2.8" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" - integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== - dependencies: - deep-extend "^0.6.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~2.0.1" - read-only-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/read-only-stream/-/read-only-stream-2.0.0.tgz#2724fd6a8113d73764ac288d4386270c1dbf17f0" @@ -4616,7 +4655,7 @@ read-only-stream@^2.0.0: dependencies: readable-stream "^2.0.2" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -4723,13 +4762,6 @@ repeat-string@^1.5.2, repeat-string@^1.6.1: resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= -repeating@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" - integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo= - dependencies: - is-finite "^1.0.0" - request-progress@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/request-progress/-/request-progress-3.0.0.tgz#4ca754081c7fec63f505e4faa825aa06cd669dbe" @@ -4737,10 +4769,26 @@ request-progress@3.0.0: dependencies: throttleit "^1.0.0" -request@2.88.0: - version "2.88.0" - resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" - integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== +request-promise-core@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.3.tgz#e9a3c081b51380dfea677336061fea879a829ee9" + integrity sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ== + dependencies: + lodash "^4.17.15" + +request-promise-native@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.8.tgz#a455b960b826e44e2bf8999af64dff2bfe58cb36" + integrity sha512-dapwLGqkHtwL5AEbfenuzjTYg35Jd6KPytsC2/TLkVMz8rm+tNt72MGUWT1RP/aYawMpN6HqbNGBQaRcBtjQMQ== + dependencies: + request-promise-core "1.1.3" + stealthy-require "^1.1.1" + tough-cookie "^2.3.3" + +request@^2.88.0: + version "2.88.2" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" + integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== dependencies: aws-sign2 "~0.7.0" aws4 "^1.8.0" @@ -4749,7 +4797,7 @@ request@2.88.0: extend "~3.0.2" forever-agent "~0.6.1" form-data "~2.3.2" - har-validator "~5.1.0" + har-validator "~5.1.3" http-signature "~1.2.0" is-typedarray "~1.0.0" isstream "~0.1.2" @@ -4759,7 +4807,32 @@ request@2.88.0: performance-now "^2.1.0" qs "~6.5.2" safe-buffer "^5.1.2" - tough-cookie "~2.4.3" + tough-cookie "~2.5.0" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + +request@cypress-io/request#b5af0d1fa47eec97ba980cde90a13e69a2afcd16: + version "2.88.1" + resolved "https://codeload.github.com/cypress-io/request/tar.gz/b5af0d1fa47eec97ba980cde90a13e69a2afcd16" + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.3" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.5.0" tunnel-agent "^0.6.0" uuid "^3.3.2" @@ -4793,12 +4866,33 @@ restore-cursor@^1.0.1: exit-hook "^1.0.0" onetime "^1.0.0" +restore-cursor@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" + integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368= + dependencies: + onetime "^2.0.0" + signal-exit "^3.0.2" + +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + ret@~0.1.10: version "0.1.15" resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== -rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.3: +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= + +rimraf@^2.5.4, rimraf@^2.6.3: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -4820,12 +4914,12 @@ run-queue@^1.0.0, run-queue@^1.0.3: dependencies: aproba "^1.1.1" -rxjs@^5.0.0-beta.11: - version "5.5.12" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.5.12.tgz#6fa61b8a77c3d793dbaf270bee2f43f652d741cc" - integrity sha512-xx2itnL5sBbqeeiVgNPVuQQ1nC8Jp2WfNJhXWHmElW9YmrpS9UVnNzhP3EH3HFqexO5Tlp8GhYY+WEcqcVMvGw== +rxjs@^6.3.3, rxjs@^6.5.4: + version "6.5.4" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c" + integrity sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q== dependencies: - symbol-observable "1.0.1" + tslib "^1.9.0" safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: version "5.2.0" @@ -4844,16 +4938,11 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sax@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" - integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== - schema-utils@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770" @@ -4873,7 +4962,7 @@ semver@7.0.0: resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0: +semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -4893,11 +4982,6 @@ serialize-javascript@^2.1.2: resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61" integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ== -set-blocking@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= - set-value@^2.0.0, set-value@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" @@ -4958,7 +5042,7 @@ sigmund@^1.0.1: resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" integrity sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA= -signal-exit@^3.0.0: +signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= @@ -5147,6 +5231,11 @@ static-extend@^0.1.1: define-property "^0.2.5" object-copy "^0.1.0" +stealthy-require@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" + integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= + stream-browserify@^2.0.0, stream-browserify@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b" @@ -5205,11 +5294,6 @@ stream-splicer@^2.0.0: inherits "^2.0.1" readable-stream "^2.0.2" -stream-to-observable@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/stream-to-observable/-/stream-to-observable-0.1.0.tgz#45bf1d9f2d7dc09bed81f1c307c430e68b84cffe" - integrity sha1-Rb8dny19wJvtgfHDB8Qw5ouEz/4= - string-argv@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.0.2.tgz#dac30408690c21f3c3630a3ff3a05877bdcbd736" @@ -5224,7 +5308,7 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2": +string-width@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== @@ -5267,16 +5351,18 @@ strip-ansi@^4.0.0: dependencies: ansi-regex "^3.0.0" +strip-ansi@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" + integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== + dependencies: + ansi-regex "^5.0.0" + strip-eof@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= -strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= - subarg@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2" @@ -5284,22 +5370,29 @@ subarg@^1.0.0: dependencies: minimist "^1.1.0" -supports-color@5.5.0, supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== +supports-color@7.1.0, supports-color@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" + integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== dependencies: - has-flag "^3.0.0" + has-flag "^4.0.0" supports-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= -symbol-observable@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.1.tgz#8340fc4702c3122df5d22288f88283f513d3fdd4" - integrity sha1-g0D8RwLDEi310iKI+IKD9RPT/dQ= +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +symbol-observable@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== syntax-error@^1.1.1: version "1.4.0" @@ -5313,19 +5406,6 @@ tapable@^1.0.0, tapable@^1.1.3: resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== -tar@^4.4.2: - version "4.4.13" - resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" - integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA== - dependencies: - chownr "^1.1.1" - fs-minipass "^1.2.5" - minipass "^2.8.6" - minizlib "^1.2.1" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.3" - terser-webpack-plugin@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz#5ecaf2dbdc5fb99745fd06791f46fc9ddb1c9a7c" @@ -5453,18 +5533,18 @@ to-regex@^3.0.1, to-regex@^3.0.2: regex-not "^1.0.2" safe-regex "^1.1.0" -tough-cookie@~2.4.3: - version "2.4.3" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" - integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ== +tough-cookie@^2.3.3, tough-cookie@~2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" + integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== dependencies: - psl "^1.1.24" - punycode "^1.4.1" + psl "^1.1.28" + punycode "^2.1.1" -ts-loader@^6.1.0: - version "6.2.1" - resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-6.2.1.tgz#67939d5772e8a8c6bdaf6277ca023a4812da02ef" - integrity sha512-Dd9FekWuABGgjE1g0TlQJ+4dFUfYGbYcs52/HQObE0ZmUNjQlmLAS7xXsSzy23AMaMwipsx5sNHvoEpT2CZq1g== +ts-loader@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-6.2.2.tgz#dffa3879b01a1a1e0a4b85e2b8421dc0dfff1c58" + integrity sha512-HDo5kXZCBml3EUPcc7RlZOV/JGlLHwppTLEHb3SHnr5V7NXD4klMEkrhJe5wgRbaWsSXi+Y1SIBN/K9B6zWGWQ== dependencies: chalk "^2.3.0" enhanced-resolve "^4.0.0" @@ -5519,10 +5599,10 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@3.7.5: - version "3.7.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae" - integrity sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw== +typescript@3.8.3: + version "3.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061" + integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w== umd@^3.0.0: version "3.0.3" @@ -5600,10 +5680,10 @@ unset-value@^1.0.0: has-value "^0.3.1" isobject "^3.0.0" -untildify@3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/untildify/-/untildify-3.0.3.tgz#1e7b42b140bcfd922b22e70ca1265bfe3634c7c9" - integrity sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA== +untildify@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b" + integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw== upath@^1.1.1: version "1.2.0" @@ -5698,6 +5778,18 @@ vm-browserify@^1.0.0, vm-browserify@^1.0.1: resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== +wait-on@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/wait-on/-/wait-on-4.0.1.tgz#c49ca18b1ea60580404feed9df76ab3af2425a56" + integrity sha512-x83fmTH2X0KL7vXoGt9aV5x4SMCvO8A/NbwWpaYYh4NJ16d3KSgbHwBy9dVdHj0B30cEhOFRvDob4fnpUmZxvA== + dependencies: + "@hapi/joi" "^17.1.0" + lodash "^4.17.15" + minimist "^1.2.0" + request "^2.88.0" + request-promise-native "^1.0.8" + rxjs "^6.5.4" + watchify@3.11.1: version "3.11.1" resolved "https://registry.yarnpkg.com/watchify/-/watchify-3.11.1.tgz#8e4665871fff1ef64c0430d1a2c9d084d9721881" @@ -5720,6 +5812,13 @@ watchpack@^1.6.0: graceful-fs "^4.1.2" neo-async "^2.5.0" +wcwidth@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" + integrity sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g= + dependencies: + defaults "^1.0.3" + webpack-sources@^1.4.0, webpack-sources@^1.4.1: version "1.4.3" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" @@ -5728,15 +5827,15 @@ webpack-sources@^1.4.0, webpack-sources@^1.4.1: source-list-map "^2.0.0" source-map "~0.6.1" -webpack@^4.40.2: - version "4.41.5" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.41.5.tgz#3210f1886bce5310e62bb97204d18c263341b77c" - integrity sha512-wp0Co4vpyumnp3KlkmpM5LWuzvZYayDwM2n17EHFr4qxBBbRokC7DJawPJC7TfSFZ9HZ6GsdH40EBj4UV0nmpw== +webpack@^4.42.1: + version "4.42.1" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.42.1.tgz#ae707baf091f5ca3ef9c38b884287cfe8f1983ef" + integrity sha512-SGfYMigqEfdGchGhFFJ9KyRpQKnipvEvjc1TwrXEPCM6H5Wywu10ka8o3KGrMzSMxMQKt8aCHUFh5DaQ9UmyRg== dependencies: - "@webassemblyjs/ast" "1.8.5" - "@webassemblyjs/helper-module-context" "1.8.5" - "@webassemblyjs/wasm-edit" "1.8.5" - "@webassemblyjs/wasm-parser" "1.8.5" + "@webassemblyjs/ast" "1.9.0" + "@webassemblyjs/helper-module-context" "1.9.0" + "@webassemblyjs/wasm-edit" "1.9.0" + "@webassemblyjs/wasm-parser" "1.9.0" acorn "^6.2.1" ajv "^6.10.2" ajv-keywords "^3.4.1" @@ -5748,7 +5847,7 @@ webpack@^4.40.2: loader-utils "^1.2.3" memory-fs "^0.4.1" micromatch "^3.1.10" - mkdirp "^0.5.1" + mkdirp "^0.5.3" neo-async "^2.6.1" node-libs-browser "^2.2.1" schema-utils "^1.0.0" @@ -5764,13 +5863,6 @@ which@^1.2.9: dependencies: isexe "^2.0.0" -wide-align@^1.1.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" - integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== - dependencies: - string-width "^1.0.2 || 2" - worker-farm@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8" @@ -5778,6 +5870,14 @@ worker-farm@^1.7.0: dependencies: errno "~0.1.7" +wrap-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-3.0.1.tgz#288a04d87eda5c286e060dfe8f135ce8d007f8ba" + integrity sha1-KIoE2H7aXChuBg3+jxNc6NAH+Lo= + dependencies: + string-width "^2.1.1" + strip-ansi "^4.0.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -5798,7 +5898,7 @@ yallist@^2.1.2: resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= -yallist@^3.0.0, yallist@^3.0.2, yallist@^3.0.3: +yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 502e910caae51..d1f7ce325d23e 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -96,6 +96,7 @@ export const apm: LegacyPluginInitializer = kibana => { name: i18n.translate('xpack.apm.featureRegistry.apmFeatureName', { defaultMessage: 'APM' }), + order: 900, icon: 'apmApp', navLinkId: 'apm', app: ['apm', 'kibana'], @@ -103,6 +104,7 @@ export const apm: LegacyPluginInitializer = kibana => { // see x-pack/plugins/features/common/feature_kibana_privileges.ts privileges: { all: { + app: ['apm', 'kibana'], api: ['apm', 'apm_write', 'actions-read', 'alerting-read'], catalogue: ['apm'], savedObject: { @@ -121,6 +123,7 @@ export const apm: LegacyPluginInitializer = kibana => { ] }, read: { + app: ['apm', 'kibana'], api: ['apm', 'actions-read', 'alerting-read'], catalogue: ['apm'], savedObject: { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index a4cd6f4ed09a9..54a1b4347e29b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -20,6 +20,7 @@ import { cytoscapeOptions, nodeHeight } from './cytoscapeOptions'; +import { useUiTracker } from '../../../../../../../plugins/observability/public'; export const CytoscapeContext = createContext( undefined @@ -117,6 +118,8 @@ export function Cytoscape({ // is required and can trigger rendering when changed. const divStyle = { ...style, height }; + const trackApmEvent = useUiTracker({ app: 'apm' }); + // Trigger a custom "data" event when data changes useEffect(() => { if (cy && elements.length > 0) { @@ -169,6 +172,7 @@ export function Cytoscape({ }); }; const mouseoverHandler: cytoscape.EventHandler = event => { + trackApmEvent({ metric: 'service_map_node_or_edge_hover' }); event.target.addClass('hover'); event.target.connectedEdges().addClass('nodeHover'); }; @@ -177,6 +181,7 @@ export function Cytoscape({ event.target.connectedEdges().removeClass('nodeHover'); }; const selectHandler: cytoscape.EventHandler = event => { + trackApmEvent({ metric: 'service_map_node_select' }); resetConnectedEdgeStyle(event.target); }; const unselectHandler: cytoscape.EventHandler = event => { @@ -215,7 +220,7 @@ export function Cytoscape({ cy.removeListener('unselect', 'node', unselectHandler); } }; - }, [cy, height, serviceName, width]); + }, [cy, height, serviceName, trackApmEvent, width]); return ( diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index 4974553f6ca93..0abaa9d76fc07 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -23,6 +23,7 @@ import { EmptyBanner } from './EmptyBanner'; import { Popover } from './Popover'; import { useRefDimensions } from './useRefDimensions'; import { BetaBadge } from './BetaBadge'; +import { useTrackPageview } from '../../../../../../../plugins/observability/public'; interface ServiceMapProps { serviceName?: string; @@ -30,7 +31,7 @@ interface ServiceMapProps { export function ServiceMap({ serviceName }: ServiceMapProps) { const license = useLicense(); - const { urlParams, uiFilters } = useUrlParams(); + const { urlParams } = useUrlParams(); const { data } = useFetcher(() => { const { start, end, environment } = urlParams; @@ -42,19 +43,18 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { start, end, environment, - serviceName, - uiFilters: JSON.stringify({ - ...uiFilters, - environment: undefined - }) + serviceName } } }); } - }, [serviceName, uiFilters, urlParams]); + }, [serviceName, urlParams]); const { ref, height, width } = useRefDimensions(); + useTrackPageview({ app: 'apm', path: 'service_map' }); + useTrackPageview({ app: 'apm', path: 'service_map', delay: 15000 }); + if (!license) { return null; } diff --git a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx index f95767492d85b..e30bed1810c1d 100644 --- a/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx +++ b/x-pack/legacy/plugins/apm/public/new-platform/plugin.tsx @@ -173,6 +173,7 @@ export class ApmPlugin +x-pack/legacy/plugins/apm/e2e/run-e2e.sh ``` -The users will be created with the password specified in kibana.dev.yml for `elasticsearch.password` - -### Debugging Elasticsearch queries - -All APM api endpoints accept `_debug=true` as a query param that will result in the underlying ES query being outputted in the Kibana backend process. - -Example: -`/api/apm/services/my_service?_debug=true` +_Starts Kibana (:5701), APM Server (:8201) and Elasticsearch (:9201). Ingests sample data into Elasticsearch via APM Server and runs the Cypress tests_ ### Unit testing @@ -74,11 +56,13 @@ node scripts/jest.js plugins/apm --updateSnapshot ### Functional tests **Start server** + ``` node scripts/functional_tests_server --config x-pack/test/functional/config.js ``` **Run tests** + ``` node scripts/functional_test_runner --config x-pack/test/functional/config.js --grep='APM specs' ``` @@ -89,11 +73,13 @@ For debugging access Elasticsearch on http://localhost:9220` (elastic/changeme) ### API integration tests **Start server** + ``` node scripts/functional_tests_server --config x-pack/test/api_integration/config.js ``` **Run tests** + ``` node scripts/functional_test_runner --config x-pack/test/api_integration/config.js --grep='APM specs' ``` @@ -117,6 +103,32 @@ yarn prettier "./x-pack/legacy/plugins/apm/**/*.{tsx,ts,js}" --write yarn eslint ./x-pack/legacy/plugins/apm --fix ``` +### Setup default APM users + +APM behaves differently depending on which the role and permissions a logged in user has. +For testing purposes APM uses 3 custom users: + +**apm_read_user**: Apps: read. Indices: read (`apm-*`) + +**apm_write_user**: Apps: read/write. Indices: read (`apm-*`) + +**kibana_write_user** Apps: read/write. Indices: None + +To create the users with the correct roles run the following script: + +```sh +node x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js --role-suffix +``` + +The users will be created with the password specified in kibana.dev.yml for `elasticsearch.password` + +### Debugging Elasticsearch queries + +All APM api endpoints accept `_debug=true` as a query param that will result in the underlying ES query being outputted in the Kibana backend process. + +Example: +`/api/apm/services/my_service?_debug=true` + #### Storybook Start the [Storybook](https://storybook.js.org/) development environment with diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts index 9c3e80bc22af1..754a113b87554 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.test.ts @@ -5,7 +5,7 @@ */ jest.mock('ui/new_platform'); import { savedVisualization } from './saved_visualization'; -import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; const filterContext = { and: [ @@ -24,20 +24,21 @@ describe('savedVisualization', () => { const fn = savedVisualization().fn; const args = { id: 'some-id', + timerange: null, + colors: null, + hideLegend: null, }; it('accepts null context', () => { const expression = fn(null, args, {} as any); expect(expression.input.filters).toEqual([]); - expect(expression.input.timeRange).toBeUndefined(); }); it('accepts filter context', () => { const expression = fn(filterContext, args, {} as any); - const embeddableFilters = buildEmbeddableFilters(filterContext.and); + const embeddableFilters = getQueryFilters(filterContext.and); - expect(expression.input.filters).toEqual(embeddableFilters.filters); - expect(expression.input.timeRange).toEqual(embeddableFilters.timeRange); + expect(expression.input.filters).toEqual(embeddableFilters); }); }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts index 5b612b7cbd666..9777eaebb36ed 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_visualization.ts @@ -11,16 +11,24 @@ import { EmbeddableExpressionType, EmbeddableExpression, } from '../../expression_types'; -import { buildEmbeddableFilters } from '../../../public/lib/build_embeddable_filters'; -import { Filter } from '../../../types'; +import { getQueryFilters } from '../../../public/lib/build_embeddable_filters'; +import { Filter, TimeRange as TimeRangeArg, SeriesStyle } from '../../../types'; import { getFunctionHelp } from '../../../i18n'; interface Arguments { id: string; + timerange: TimeRangeArg | null; + colors: SeriesStyle[] | null; + hideLegend: boolean | null; } type Output = EmbeddableExpression; +const defaultTimeRange = { + from: 'now-15m', + to: 'now', +}; + export function savedVisualization(): ExpressionFunctionDefinition< 'savedVisualization', Filter | null, @@ -37,17 +45,51 @@ export function savedVisualization(): ExpressionFunctionDefinition< required: false, help: argHelp.id, }, + timerange: { + types: ['timerange'], + help: argHelp.timerange, + required: false, + }, + colors: { + types: ['seriesStyle'], + help: argHelp.colors, + multi: true, + required: false, + }, + hideLegend: { + types: ['boolean'], + help: argHelp.hideLegend, + required: false, + }, }, type: EmbeddableExpressionType, - fn: (input, { id }) => { + fn: (input, { id, timerange, colors, hideLegend }) => { const filters = input ? input.and : []; + const visOptions: VisualizeInput['vis'] = {}; + + if (colors) { + visOptions.colors = colors.reduce((reduction, color) => { + if (color.label && color.color) { + reduction[color.label] = color.color; + } + return reduction; + }, {} as Record); + } + + if (hideLegend === true) { + // @ts-ignore LegendOpen missing on VisualizeInput + visOptions.legendOpen = false; + } + return { type: EmbeddableExpressionType, input: { id, disableTriggers: true, - ...buildEmbeddableFilters(filters), + timeRange: timerange || defaultTimeRange, + filters: getQueryFilters(filters), + vis: visOptions, }, embeddableType: EmbeddableTypes.visualization, }; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index d91e70e43bfd5..3cdb6eb460224 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -82,8 +82,13 @@ const embeddable = () => ({ ReactDOM.unmountComponentAtNode(domNode); const subscription = embeddableObject.getInput$().subscribe(function(updatedInput) { - handlers.onEmbeddableInputChange(embeddableInputToExpression(updatedInput, embeddableType)); + const updatedExpression = embeddableInputToExpression(updatedInput, embeddableType); + + if (updatedExpression) { + handlers.onEmbeddableInputChange(updatedExpression); + } }); + ReactDOM.render(renderEmbeddable(embeddableObject, domNode), domNode, () => handlers.done()); handlers.onResize(() => { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts index 4c622b0c247fa..9dee40c0f683b 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts @@ -4,119 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -jest.mock('ui/new_platform'); -import { embeddableInputToExpression } from './embeddable_input_to_expression'; -import { SavedMapInput } from '../../functions/common/saved_map'; -import { SavedLensInput } from '../../functions/common/saved_lens'; -import { EmbeddableTypes } from '../../expression_types'; -import { fromExpression, Ast } from '@kbn/interpreter/common'; +import { + embeddableInputToExpression, + inputToExpressionTypeMap, +} from './embeddable_input_to_expression'; -const baseEmbeddableInput = { +const input = { id: 'embeddableId', filters: [], -}; - -const baseSavedMapInput = { - ...baseEmbeddableInput, - isLayerTOCOpen: false, - refreshConfig: { - isPaused: true, - interval: 0, - }, hideFilterActions: true as true, }; describe('input to expression', () => { - describe('Map Embeddable', () => { - it('converts to a savedMap expression', () => { - const input: SavedMapInput = { - ...baseSavedMapInput, - }; - - const expression = embeddableInputToExpression(input, EmbeddableTypes.map); - const ast = fromExpression(expression); - - expect(ast.type).toBe('expression'); - expect(ast.chain[0].function).toBe('savedMap'); - - expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); - - expect(ast.chain[0].arguments).not.toHaveProperty('title'); - expect(ast.chain[0].arguments).not.toHaveProperty('center'); - expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); - }); - - it('includes optional input values', () => { - const input: SavedMapInput = { - ...baseSavedMapInput, - mapCenter: { - lat: 1, - lon: 2, - zoom: 3, - }, - title: 'title', - timeRange: { - from: 'now-1h', - to: 'now', - }, - }; - - const expression = embeddableInputToExpression(input, EmbeddableTypes.map); - const ast = fromExpression(expression); - - const centerExpression = ast.chain[0].arguments.center[0] as Ast; - - expect(centerExpression.chain[0].function).toBe('mapCenter'); - expect(centerExpression.chain[0].arguments.lat[0]).toEqual(input.mapCenter?.lat); - expect(centerExpression.chain[0].arguments.lon[0]).toEqual(input.mapCenter?.lon); - expect(centerExpression.chain[0].arguments.zoom[0]).toEqual(input.mapCenter?.zoom); - - const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; - - expect(timerangeExpression.chain[0].function).toBe('timerange'); - expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); - expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); - }); - }); - - describe('Lens Embeddable', () => { - it('converts to a savedLens expression', () => { - const input: SavedLensInput = { - ...baseEmbeddableInput, - }; - - const expression = embeddableInputToExpression(input, EmbeddableTypes.lens); - const ast = fromExpression(expression); - - expect(ast.type).toBe('expression'); - expect(ast.chain[0].function).toBe('savedLens'); - - expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); - - expect(ast.chain[0].arguments).not.toHaveProperty('title'); - expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); - }); - - it('includes optional input values', () => { - const input: SavedLensInput = { - ...baseEmbeddableInput, - title: 'title', - timeRange: { - from: 'now-1h', - to: 'now', - }, - }; - - const expression = embeddableInputToExpression(input, EmbeddableTypes.map); - const ast = fromExpression(expression); + it('converts to expression if method is available', () => { + const newType = 'newType'; + const mockReturn = 'expression'; + inputToExpressionTypeMap[newType] = jest.fn().mockReturnValue(mockReturn); - expect(ast.chain[0].arguments).toHaveProperty('title', [input.title]); - expect(ast.chain[0].arguments).toHaveProperty('timerange'); + const expression = embeddableInputToExpression(input, newType); - const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; - expect(timerangeExpression.chain[0].function).toBe('timerange'); - expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); - expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); - }); + expect(expression).toBe(mockReturn); }); }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts index 6428507b16a0c..5cba012fcb8e3 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts @@ -5,8 +5,15 @@ */ import { EmbeddableTypes, EmbeddableInput } from '../../expression_types'; -import { SavedMapInput } from '../../functions/common/saved_map'; -import { SavedLensInput } from '../../functions/common/saved_lens'; +import { toExpression as mapToExpression } from './input_type_to_expression/map'; +import { toExpression as visualizationToExpression } from './input_type_to_expression/visualization'; +import { toExpression as lensToExpression } from './input_type_to_expression/lens'; + +export const inputToExpressionTypeMap = { + [EmbeddableTypes.map]: mapToExpression, + [EmbeddableTypes.visualization]: visualizationToExpression, + [EmbeddableTypes.lens]: lensToExpression, +}; /* Take the input from an embeddable and the type of embeddable and convert it into an expression @@ -14,56 +21,8 @@ import { SavedLensInput } from '../../functions/common/saved_lens'; export function embeddableInputToExpression( input: EmbeddableInput, embeddableType: string -): string { - const expressionParts: string[] = []; - - if (embeddableType === EmbeddableTypes.map) { - const mapInput = input as SavedMapInput; - - expressionParts.push('savedMap'); - - expressionParts.push(`id="${input.id}"`); - - if (input.title) { - expressionParts.push(`title="${input.title}"`); - } - - if (mapInput.mapCenter) { - expressionParts.push( - `center={mapCenter lat=${mapInput.mapCenter.lat} lon=${mapInput.mapCenter.lon} zoom=${mapInput.mapCenter.zoom}}` - ); - } - - if (mapInput.timeRange) { - expressionParts.push( - `timerange={timerange from="${mapInput.timeRange.from}" to="${mapInput.timeRange.to}"}` - ); - } - - if (mapInput.hiddenLayers && mapInput.hiddenLayers.length) { - for (const layerId of mapInput.hiddenLayers) { - expressionParts.push(`hideLayer="${layerId}"`); - } - } +): string | undefined { + if (inputToExpressionTypeMap[embeddableType]) { + return inputToExpressionTypeMap[embeddableType](input as any); } - - if (embeddableType === EmbeddableTypes.lens) { - const lensInput = input as SavedLensInput; - - expressionParts.push('savedLens'); - - expressionParts.push(`id="${input.id}"`); - - if (input.title) { - expressionParts.push(`title="${input.title}"`); - } - - if (lensInput.timeRange) { - expressionParts.push( - `timerange={timerange from="${lensInput.timeRange.from}" to="${lensInput.timeRange.to}"}` - ); - } - } - - return expressionParts.join(' '); } diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.test.ts new file mode 100644 index 0000000000000..c4a9a22be3202 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { toExpression } from './lens'; +import { SavedLensInput } from '../../../functions/common/saved_lens'; +import { fromExpression, Ast } from '@kbn/interpreter/common'; + +const baseEmbeddableInput = { + id: 'embeddableId', + filters: [], +}; + +describe('toExpression', () => { + it('converts to a savedLens expression', () => { + const input: SavedLensInput = { + ...baseEmbeddableInput, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedLens'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + + expect(ast.chain[0].arguments).not.toHaveProperty('title'); + expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); + }); + + it('includes optional input values', () => { + const input: SavedLensInput = { + ...baseEmbeddableInput, + title: 'title', + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + expect(ast.chain[0].arguments).toHaveProperty('title', [input.title]); + expect(ast.chain[0].arguments).toHaveProperty('timerange'); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.ts new file mode 100644 index 0000000000000..445cb7480ff80 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/lens.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 { SavedLensInput } from '../../../functions/common/saved_lens'; + +export function toExpression(input: SavedLensInput): string { + const expressionParts = [] as string[]; + + expressionParts.push('savedLens'); + + expressionParts.push(`id="${input.id}"`); + + if (input.title) { + expressionParts.push(`title="${input.title}"`); + } + + if (input.timeRange) { + expressionParts.push( + `timerange={timerange from="${input.timeRange.from}" to="${input.timeRange.to}"}` + ); + } + + return expressionParts.join(' '); +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.test.ts new file mode 100644 index 0000000000000..4c294fb37c2db --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.test.ts @@ -0,0 +1,72 @@ +/* + * 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 { toExpression } from './map'; +import { SavedMapInput } from '../../../functions/common/saved_map'; +import { fromExpression, Ast } from '@kbn/interpreter/common'; + +const baseSavedMapInput = { + id: 'embeddableId', + filters: [], + isLayerTOCOpen: false, + refreshConfig: { + isPaused: true, + interval: 0, + }, + hideFilterActions: true as true, +}; + +describe('toExpression', () => { + it('converts to a savedMap expression', () => { + const input: SavedMapInput = { + ...baseSavedMapInput, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedMap'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + + expect(ast.chain[0].arguments).not.toHaveProperty('title'); + expect(ast.chain[0].arguments).not.toHaveProperty('center'); + expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); + }); + + it('includes optional input values', () => { + const input: SavedMapInput = { + ...baseSavedMapInput, + mapCenter: { + lat: 1, + lon: 2, + zoom: 3, + }, + title: 'title', + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + const centerExpression = ast.chain[0].arguments.center[0] as Ast; + + expect(centerExpression.chain[0].function).toBe('mapCenter'); + expect(centerExpression.chain[0].arguments.lat[0]).toEqual(input.mapCenter?.lat); + expect(centerExpression.chain[0].arguments.lon[0]).toEqual(input.mapCenter?.lon); + expect(centerExpression.chain[0].arguments.zoom[0]).toEqual(input.mapCenter?.zoom); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.ts new file mode 100644 index 0000000000000..e3f9eca61ae28 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/map.ts @@ -0,0 +1,38 @@ +/* + * 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 { SavedMapInput } from '../../../functions/common/saved_map'; + +export function toExpression(input: SavedMapInput): string { + const expressionParts = [] as string[]; + + expressionParts.push('savedMap'); + expressionParts.push(`id="${input.id}"`); + + if (input.title) { + expressionParts.push(`title="${input.title}"`); + } + + if (input.mapCenter) { + expressionParts.push( + `center={mapCenter lat=${input.mapCenter.lat} lon=${input.mapCenter.lon} zoom=${input.mapCenter.zoom}}` + ); + } + + if (input.timeRange) { + expressionParts.push( + `timerange={timerange from="${input.timeRange.from}" to="${input.timeRange.to}"}` + ); + } + + if (input.hiddenLayers && input.hiddenLayers.length) { + for (const layerId of input.hiddenLayers) { + expressionParts.push(`hideLayer="${layerId}"`); + } + } + + return expressionParts.join(' '); +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.test.ts new file mode 100644 index 0000000000000..306020293abe6 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.test.ts @@ -0,0 +1,72 @@ +/* + * 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 { toExpression } from './visualization'; +import { fromExpression, Ast } from '@kbn/interpreter/common'; + +const baseInput = { + id: 'embeddableId', +}; + +describe('toExpression', () => { + it('converts to a savedVisualization expression', () => { + const input = { + ...baseInput, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedVisualization'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + }); + + it('includes timerange if given', () => { + const input = { + ...baseInput, + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); + + it('includes colors if given', () => { + const colorMap = { a: 'red', b: 'blue' }; + + const input = { + ...baseInput, + vis: { + colors: { + a: 'red', + b: 'blue', + }, + }, + }; + + const expression = toExpression(input); + const ast = fromExpression(expression); + + const colors = ast.chain[0].arguments.colors as Ast[]; + + const aColor = colors.find(color => color.chain[0].arguments.label[0] === 'a'); + const bColor = colors.find(color => color.chain[0].arguments.label[0] === 'b'); + + expect(aColor?.chain[0].arguments.color[0]).toBe(colorMap.a); + expect(bColor?.chain[0].arguments.color[0]).toBe(colorMap.b); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.ts new file mode 100644 index 0000000000000..be0dd6a79292f --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/input_type_to_expression/visualization.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 { VisualizeInput } from 'src/legacy/core_plugins/visualizations/public'; + +export function toExpression(input: VisualizeInput): string { + const expressionParts = [] as string[]; + + expressionParts.push('savedVisualization'); + expressionParts.push(`id="${input.id}"`); + + if (input.timeRange) { + expressionParts.push( + `timerange={timerange from="${input.timeRange.from}" to="${input.timeRange.to}"}` + ); + } + + if (input.vis?.colors) { + Object.entries(input.vis.colors) + .map(([label, color]) => { + return `colors={seriesStyle label="${label}" color="${color}"}`; + }) + .reduce((_, part) => expressionParts.push(part), 0); + } + + // @ts-ignore LegendOpen missing on VisualizeInput type + if (input.vis?.legendOpen !== undefined && input.vis.legendOpen === false) { + expressionParts.push(`hideLegend=true`); + } + + return expressionParts.join(' '); +} diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_visualization.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_visualization.ts index e3b412284442d..21a2e1c1b8800 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_visualization.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_visualization.ts @@ -14,6 +14,20 @@ export const help: FunctionHelp> = { defaultMessage: `Returns an embeddable for a saved visualization object`, }), args: { - id: 'The id of the saved visualization object', + id: i18n.translate('xpack.canvas.functions.savedVisualization.args.idHelpText', { + defaultMessage: `The ID of the Saved Visualization Object`, + }), + timerange: i18n.translate('xpack.canvas.functions.savedVisualization.args.timerangeHelpText', { + defaultMessage: `The timerange of data that should be included`, + }), + colors: i18n.translate('xpack.canvas.functions.savedVisualization.args.colorsHelpText', { + defaultMessage: `Define the color to use for a specific series`, + }), + hideLegend: i18n.translate( + 'xpack.canvas.functions.savedVisualization.args.hideLegendHelpText', + { + defaultMessage: `Should the legend be hidden`, + } + ), }, }; diff --git a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx index 353a59397d6b6..a86784d374f49 100644 --- a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx @@ -24,10 +24,10 @@ const allowedEmbeddables = { [EmbeddableTypes.lens]: (id: string) => { return `savedLens id="${id}" | render`; }, - // FIX: Only currently allow Map embeddables - /* [EmbeddableTypes.visualization]: (id: string) => { - return `filters | savedVisualization id="${id}" | render`; + [EmbeddableTypes.visualization]: (id: string) => { + return `savedVisualization id="${id}" | render`; }, + /* [EmbeddableTypes.search]: (id: string) => { return `filters | savedSearch id="${id}" | render`; },*/ diff --git a/x-pack/legacy/plugins/graph/index.ts b/x-pack/legacy/plugins/graph/index.ts index 5122796335e45..53d32a836cfa1 100644 --- a/x-pack/legacy/plugins/graph/index.ts +++ b/x-pack/legacy/plugins/graph/index.ts @@ -37,6 +37,7 @@ export const graph: LegacyPluginInitializer = kibana => { name: i18n.translate('xpack.graph.featureRegistry.graphFeatureName', { defaultMessage: 'Graph', }), + order: 1200, icon: 'graphApp', navLinkId: 'graph', app: ['graph', 'kibana'], @@ -44,6 +45,8 @@ export const graph: LegacyPluginInitializer = kibana => { validLicenses: ['platinum', 'enterprise', 'trial'], privileges: { all: { + app: ['graph', 'kibana'], + catalogue: ['graph'], savedObject: { all: ['graph-workspace'], read: ['index-pattern'], @@ -51,6 +54,8 @@ export const graph: LegacyPluginInitializer = kibana => { ui: ['save', 'delete'], }, read: { + app: ['graph', 'kibana'], + catalogue: ['graph'], savedObject: { all: [], read: ['index-pattern', 'graph-workspace'], diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx index fbda18cc0e307..be72dd4b4edef 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx @@ -77,6 +77,20 @@ function createMockFilterManager() { }; } +function createMockTimefilter() { + const unsubscribe = jest.fn(); + + return { + getTime: jest.fn(() => ({ from: 'now-7d', to: 'now' })), + setTime: jest.fn(), + getTimeUpdate$: () => ({ + subscribe: ({ next }: { next: () => void }) => { + return unsubscribe; + }, + }), + }; +} + describe('Lens App', () => { let frame: jest.Mocked; let core: ReturnType; @@ -108,10 +122,7 @@ describe('Lens App', () => { query: { filterManager: createMockFilterManager(), timefilter: { - timefilter: { - getTime: jest.fn(() => ({ from: 'now-7d', to: 'now' })), - setTime: jest.fn(), - }, + timefilter: createMockTimefilter(), }, }, indexPatterns: { diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx index a0c6e4c21a34b..dfea2e39fcbc5 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -94,8 +94,23 @@ export function App({ trackUiEvent('app_filters_updated'); }, }); + + const timeSubscription = data.query.timefilter.timefilter.getTimeUpdate$().subscribe({ + next: () => { + const currentRange = data.query.timefilter.timefilter.getTime(); + setState(s => ({ + ...s, + dateRange: { + fromDate: currentRange.from, + toDate: currentRange.to, + }, + })); + }, + }); + return () => { filterSubscription.unsubscribe(); + timeSubscription.unsubscribe(); }; }, []); diff --git a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index 252ba5c9bc0bc..d18174baacdb9 100644 --- a/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/legacy/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -14,8 +14,11 @@ import { IIndexPattern, TimefilterContract, } from 'src/plugins/data/public'; + import { Subscription } from 'rxjs'; import { ReactExpressionRendererType } from '../../../../../../../src/plugins/expressions/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/events'; + import { Embeddable as AbstractEmbeddable, EmbeddableOutput, @@ -90,6 +93,18 @@ export class Embeddable extends AbstractEmbeddable !filter.meta.disabled) diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts b/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts index 276f24433c670..62f47a21c85b0 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts +++ b/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.test.ts @@ -72,6 +72,46 @@ describe('metric_visualization', () => { }); }); + describe('#getConfiguration', () => { + it('can add a metric when there is no accessor', () => { + expect( + metricVisualization.getConfiguration({ + state: { + accessor: undefined, + layerId: 'l1', + }, + layerId: 'l1', + frame: mockFrame(), + }) + ).toEqual({ + groups: [ + expect.objectContaining({ + supportsMoreColumns: true, + }), + ], + }); + }); + + it('is not allowed to add a metric once one accessor is set', () => { + expect( + metricVisualization.getConfiguration({ + state: { + accessor: 'a', + layerId: 'l1', + }, + layerId: 'l1', + frame: mockFrame(), + }) + ).toEqual({ + groups: [ + expect.objectContaining({ + supportsMoreColumns: false, + }), + ], + }); + }); + }); + describe('#setDimension', () => { it('sets the accessor', () => { expect( diff --git a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx b/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx index 44256df5aed6d..73b8019a31eaa 100644 --- a/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx +++ b/x-pack/legacy/plugins/lens/public/metric_visualization/metric_visualization.tsx @@ -94,7 +94,7 @@ export const metricVisualization: Visualization = { groupLabel: i18n.translate('xpack.lens.metric.label', { defaultMessage: 'Metric' }), layerId: props.state.layerId, accessors: props.state.accessor ? [props.state.accessor] : [], - supportsMoreColumns: false, + supportsMoreColumns: !props.state.accessor, filterOperations: (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number', }, ], diff --git a/x-pack/legacy/plugins/lens/public/plugin.tsx b/x-pack/legacy/plugins/lens/public/plugin.tsx index c74653c70703c..16f1d194b240a 100644 --- a/x-pack/legacy/plugins/lens/public/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/plugin.tsx @@ -28,6 +28,8 @@ import { stopReportManager, trackUiEvent, } from './lens_ui_telemetry'; + +import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; import { KibanaLegacySetup } from '../../../../../src/plugins/kibana_legacy/public'; import { NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../../../../plugins/lens/common'; import { @@ -40,7 +42,6 @@ import { EmbeddableSetup, EmbeddableStart } from '../../../../../src/plugins/emb import { EditorFrameStart } from './types'; import { getLensAliasConfig } from './vis_type_alias'; import { VisualizationsSetup } from './legacy_imports'; - export interface LensPluginSetupDependencies { kibanaLegacy: KibanaLegacySetup; expressions: ExpressionsSetup; @@ -56,6 +57,7 @@ export interface LensPluginStartDependencies { data: DataPublicPluginStart; embeddable: EmbeddableStart; expressions: ExpressionsStart; + uiActions: UiActionsStart; } export const isRisonObject = (value: RisonValue): value is RisonObject => { @@ -217,6 +219,7 @@ export class LensPlugin { start(core: CoreStart, startDependencies: LensPluginStartDependencies) { this.createEditorFrame = this.editorFrameService.start(core, startDependencies).createInstance; + this.xyVisualization.start(core, startDependencies); } stop() { diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap b/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap index 4b19ad288ddaa..bef53c2fd266e 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap +++ b/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap @@ -6,6 +6,7 @@ exports[`xy_expression XYChart component it renders area 1`] = ` > ('executeTriggerActions'); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx index adf64fece2942..d6abee101db31 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx @@ -12,6 +12,8 @@ import { LineSeries, Settings, ScaleType, + GeometryValue, + XYChartSeriesIdentifier, SeriesNameFn, } from '@elastic/charts'; import { xyChart, XYChart } from './xy_expression'; @@ -20,6 +22,9 @@ import React from 'react'; import { shallow } from 'enzyme'; import { XYArgs, LegendConfig, legendConfig, layerConfig, LayerArgs } from './types'; import { createMockExecutionContext } from '../../../../../../src/plugins/expressions/common/mocks'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; + +const executeTriggerActions = jest.fn(); function sampleArgs() { const data: LensMultiTable = { @@ -141,6 +146,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -166,6 +172,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(Settings).prop('xDomain')).toMatchInlineSnapshot(` @@ -195,6 +202,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(Settings).prop('xDomain')).toBeUndefined(); @@ -209,6 +217,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -224,6 +233,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -239,6 +249,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -246,6 +257,69 @@ describe('xy_expression', () => { expect(component.find(Settings).prop('rotation')).toEqual(90); }); + test('onElementClick returns correct context data', () => { + const geometry: GeometryValue = { x: 5, y: 1, accessor: 'y1' }; + const series = { + key: 'spec{d}yAccessor{d}splitAccessors{b-2}', + specId: 'd', + yAccessor: 'd', + splitAccessors: {}, + seriesKeys: [2, 'd'], + }; + + const { args, data } = sampleArgs(); + + const wrapper = mountWithIntl( + + ); + + wrapper + .find(Settings) + .first() + .prop('onElementClick')!([[geometry, series as XYChartSeriesIdentifier]]); + + expect(executeTriggerActions).toHaveBeenCalledWith('VALUE_CLICK_TRIGGER', { + data: { + data: [ + { + column: 1, + row: 1, + table: data.tables.first, + value: 5, + }, + { + column: 1, + row: 0, + table: data.tables.first, + value: 2, + }, + ], + }, + }); + }); + test('it renders stacked bar', () => { const { data, args } = sampleArgs(); const component = shallow( @@ -255,6 +329,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -271,6 +346,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -290,6 +366,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component).toMatchSnapshot(); @@ -307,6 +384,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="CEST" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(LineSeries).prop('timeZone')).toEqual('CEST'); @@ -323,6 +401,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); @@ -346,6 +425,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); @@ -363,6 +443,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(false); @@ -378,6 +459,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; @@ -414,6 +496,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; @@ -442,6 +525,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(LineSeries).prop('xScaleType')).toEqual(ScaleType.Ordinal); @@ -457,6 +541,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); expect(component.find(LineSeries).prop('yScaleType')).toEqual(ScaleType.Sqrt); @@ -472,6 +557,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); @@ -488,6 +574,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); @@ -504,6 +591,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} chartTheme={{}} timeZone="UTC" + executeTriggerActions={executeTriggerActions} /> ); expect(getFormatSpy).toHaveBeenCalledWith({ @@ -522,6 +610,7 @@ describe('xy_expression', () => { formatFactory={getFormatSpy} timeZone="UTC" chartTheme={{}} + executeTriggerActions={executeTriggerActions} /> ); diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx index ce966ee6150a0..98d95c2ea7715 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -15,6 +15,8 @@ import { BarSeries, Position, PartialTheme, + GeometryValue, + XYChartSeriesIdentifier, } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { @@ -26,11 +28,15 @@ import { import { EuiIcon, EuiText, IconType, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { FormatFactory } from '../legacy_imports'; +import { EmbeddableVisTriggerContext } from '../../../../../../src/plugins/embeddable/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../../src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/events'; +import { FormatFactory } from '../../../../../../src/legacy/ui/public/visualize/loader/pipeline_helpers/utilities'; import { LensMultiTable } from '../types'; import { XYArgs, SeriesType, visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart } from './state_helpers'; +import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public'; +import { getExecuteTriggerActions } from './services'; type InferPropType = T extends React.FunctionComponent ? P : T; type SeriesSpec = InferPropType & @@ -52,6 +58,7 @@ type XYChartRenderProps = XYChartProps & { chartTheme: PartialTheme; formatFactory: FormatFactory; timeZone: string; + executeTriggerActions: UiActionsStart['executeTriggerActions']; }; export const xyChart: ExpressionFunctionDefinition< @@ -113,10 +120,15 @@ export const getXyChartRenderer = (dependencies: { validate: () => undefined, reuseDomNode: true, render: (domNode: Element, config: XYChartProps, handlers: IInterpreterRenderHandlers) => { + const executeTriggerActions = getExecuteTriggerActions(); handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); ReactDOM.render( - + , domNode, () => handlers.done() @@ -148,7 +160,14 @@ export function XYChartReportable(props: XYChartRenderProps) { ); } -export function XYChart({ data, args, formatFactory, timeZone, chartTheme }: XYChartRenderProps) { +export function XYChart({ + data, + args, + formatFactory, + timeZone, + chartTheme, + executeTriggerActions, +}: XYChartRenderProps) { const { legend, layers } = args; if (Object.values(data.tables).every(table => table.rows.length === 0)) { @@ -189,7 +208,13 @@ export function XYChart({ data, args, formatFactory, timeZone, chartTheme }: XYC const shouldRotate = isHorizontalChart(layers); const xTitle = (xAxisColumn && xAxisColumn.name) || args.xTitle; - + const xDomain = + data.dateRange && layers.every(l => l.xScaleType === 'time') + ? { + min: data.dateRange.fromDate.getTime(), + max: data.dateRange.toDate.getTime(), + } + : undefined; return ( l.xScaleType === 'time') - ? { - min: data.dateRange.fromDate.getTime(), - max: data.dateRange.toDate.getTime(), - } - : undefined - } + xDomain={xDomain} + onElementClick={([[geometry, series]]) => { + // for xyChart series is always XYChartSeriesIdentifier and geometry is always type of GeometryValue + const xySeries = series as XYChartSeriesIdentifier; + const xyGeometry = geometry as GeometryValue; + + const layer = layers.find(l => + xySeries.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString())) + ); + if (!layer) { + return; + } + + const table = data.tables[layer.layerId]; + + const points = [ + { + row: table.rows.findIndex( + row => layer.xAccessor && row[layer.xAccessor] === xyGeometry.x + ), + column: table.columns.findIndex(col => col.id === layer.xAccessor), + value: xyGeometry.x, + }, + ]; + + if (xySeries.seriesKeys.length > 1) { + const pointValue = xySeries.seriesKeys[0]; + + points.push({ + row: table.rows.findIndex( + row => layer.splitAccessor && row[layer.splitAccessor] === pointValue + ), + column: table.columns.findIndex(col => col.id === layer.splitAccessor), + value: pointValue, + }); + } + + const xAxisFieldName: string | undefined = table.columns.find( + col => col.id === layer.xAccessor + )?.meta?.aggConfigParams?.field; + + const timeFieldName = xDomain && xAxisFieldName; + + const context: EmbeddableVisTriggerContext = { + data: { + data: points.map(point => ({ + row: point.row, + column: point.column, + value: point.value, + table, + })), + }, + timeFieldName, + }; + + executeTriggerActions(VIS_EVENT_TO_TRIGGER.filter, context); + }} /> = columnToLabel ? JSON.parse(columnToLabel) : {}; + const table = data.tables[layerId]; + + // For date histogram chart type, we're getting the rows that represent intervals without data. + // To not display them in the legend, they need to be filtered out. + const rows = table.rows.filter( + row => + !(splitAccessor && !row[splitAccessor] && accessors.every(accessor => !row[accessor])) + ); + const seriesProps: SeriesSpec = { splitSeriesAccessors: splitAccessor ? [splitAccessor] : [], stackAccessors: seriesType.includes('stacked') ? [xAccessor] : [], id: splitAccessor || accessors.join(','), xAccessor, yAccessors: accessors, - data: table.rows, + data: rows, xScaleType, yScaleType, enableHistogramMode: isHistogram && (seriesType.includes('stacked') || !splitAccessor), @@ -276,16 +359,17 @@ export function XYChart({ data, args, formatFactory, timeZone, chartTheme }: XYC }, }; - return seriesType === 'line' ? ( - - ) : seriesType === 'bar' || - seriesType === 'bar_stacked' || - seriesType === 'bar_horizontal' || - seriesType === 'bar_horizontal_stacked' ? ( - - ) : ( - - ); + switch (seriesType) { + case 'line': + return ; + case 'bar': + case 'bar_stacked': + case 'bar_horizontal': + case 'bar_horizontal_stacked': + return ; + default: + return ; + } } )} diff --git a/x-pack/legacy/plugins/maps/common/data_request_descriptor_types.d.ts b/x-pack/legacy/plugins/maps/common/data_request_descriptor_types.d.ts index 3281fb5892eac..a0102a4249a59 100644 --- a/x-pack/legacy/plugins/maps/common/data_request_descriptor_types.d.ts +++ b/x-pack/legacy/plugins/maps/common/data_request_descriptor_types.d.ts @@ -5,25 +5,41 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ +import { Query } from './map_descriptor'; + +type Extent = { + maxLat: number; + maxLon: number; + minLat: number; + minLon: number; +}; + // Global map state passed to every layer. export type MapFilters = { - buffer: unknown; - extent: unknown; + buffer: Extent; // extent with additional buffer + extent: Extent; // map viewport filters: unknown[]; - query: unknown; + query: Query; refreshTimerLastTriggeredAt: string; timeFilters: unknown; zoom: number; }; -export type VectorLayerRequestMeta = MapFilters & { +export type VectorSourceRequestMeta = MapFilters & { applyGlobalQuery: boolean; fieldNames: string[]; geogridPrecision: number; - sourceQuery: unknown; + sourceQuery: Query; sourceMeta: unknown; }; +export type VectorStyleRequestMeta = MapFilters & { + dynamicStyleFields: string[]; + isTimeAware: boolean; + sourceQuery: Query; + timeFilters: unknown; +}; + export type ESSearchSourceResponseMeta = { areResultsTrimmed?: boolean; sourceType?: string; @@ -35,7 +51,9 @@ export type ESSearchSourceResponseMeta = { }; // Partial because objects are justified downstream in constructors -export type DataMeta = Partial & Partial; +export type DataMeta = Partial & + Partial & + Partial; export type DataRequestDescriptor = { dataId: string; diff --git a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts b/x-pack/legacy/plugins/maps/common/map_descriptor.ts similarity index 60% rename from x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts rename to x-pack/legacy/plugins/maps/common/map_descriptor.ts index 66e2a4eafa880..570398e37c5d4 100644 --- a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts +++ b/x-pack/legacy/plugins/maps/common/map_descriptor.ts @@ -4,8 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -/* eslint-disable */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ -export { - ActionFactory -} from '../../../../../../src/plugins/ui_actions/public/actions/action_factory'; +export type Query = { + language: string; + query: string; + queryLastTriggeredAt: string; +}; diff --git a/x-pack/legacy/plugins/maps/common/migrations/migrate_symbol_style_descriptor.test.js b/x-pack/legacy/plugins/maps/common/migrations/migrate_symbol_style_descriptor.test.js index 2811b83f46d8f..fc0151083855c 100644 --- a/x-pack/legacy/plugins/maps/common/migrations/migrate_symbol_style_descriptor.test.js +++ b/x-pack/legacy/plugins/maps/common/migrations/migrate_symbol_style_descriptor.test.js @@ -120,7 +120,7 @@ describe('migrateSymbolStyleDescriptor', () => { }, icon: { type: STYLE_TYPE.STATIC, - options: { value: 'airfield' }, + options: { value: 'marker' }, }, }, }, diff --git a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js index 413d66fce7f70..a2850d2bb6c23 100644 --- a/x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js +++ b/x-pack/legacy/plugins/maps/public/connected_components/map/mb/utils.js @@ -110,7 +110,9 @@ function getImageData(img) { export async function loadSpriteSheetImageData(imgUrl) { return new Promise((resolve, reject) => { const image = new Image(); - image.crossOrigin = 'Anonymous'; + if (isCrossOriginUrl(imgUrl)) { + image.crossOrigin = 'Anonymous'; + } image.onload = el => { const imgData = getImageData(el.currentTarget); resolve(imgData); @@ -142,3 +144,13 @@ export async function addSpritesheetToMap(json, imgUrl, mbMap) { const imgData = await loadSpriteSheetImageData(imgUrl); addSpriteSheetToMapFromImageData(json, imgData, mbMap); } + +function isCrossOriginUrl(url) { + const a = window.document.createElement('a'); + a.href = url; + return ( + a.protocol !== window.document.location.protocol || + a.host !== window.document.location.host || + a.port !== window.document.location.port + ); +} diff --git a/x-pack/legacy/plugins/maps/public/layers/blended_vector_layer.ts b/x-pack/legacy/plugins/maps/public/layers/blended_vector_layer.ts index 22990538bd5d3..8c54720987e41 100644 --- a/x-pack/legacy/plugins/maps/public/layers/blended_vector_layer.ts +++ b/x-pack/legacy/plugins/maps/public/layers/blended_vector_layer.ts @@ -23,7 +23,6 @@ import { FIELD_ORIGIN, } from '../../common/constants'; import { ESGeoGridSource } from './sources/es_geo_grid_source/es_geo_grid_source'; -// @ts-ignore import { canSkipSourceUpdate } from './util/can_skip_fetch'; import { IVectorLayer, VectorLayerArguments } from './vector_layer'; import { IESSource } from './sources/es_source'; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts index 963a30c7413e8..b565cb9108aea 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_source.d.ts @@ -7,7 +7,7 @@ import { AbstractVectorSource } from './vector_source'; import { IVectorSource } from './vector_source'; import { IndexPattern, SearchSource } from '../../../../../../../src/plugins/data/public'; -import { VectorLayerRequestMeta } from '../../../common/data_request_descriptor_types'; +import { VectorSourceRequestMeta } from '../../../common/data_request_descriptor_types'; export interface IESSource extends IVectorSource { getId(): string; @@ -16,7 +16,7 @@ export interface IESSource extends IVectorSource { getGeoFieldName(): string; getMaxResultWindow(): Promise; makeSearchSource( - searchFilters: VectorLayerRequestMeta, + searchFilters: VectorSourceRequestMeta, limit: number, initialSearchContext?: object ): Promise; @@ -29,7 +29,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource getGeoFieldName(): string; getMaxResultWindow(): Promise; makeSearchSource( - searchFilters: VectorLayerRequestMeta, + searchFilters: VectorSourceRequestMeta, limit: number, initialSearchContext?: object ): Promise; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/source.d.ts index 2ca18e47a4bf9..e1706ad7b7d77 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/source.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/source.d.ts @@ -9,15 +9,28 @@ import { ILayer } from '../layer'; export interface ISource { createDefaultLayer(): ILayer; - getDisplayName(): Promise; destroy(): void; + getDisplayName(): Promise; getInspectorAdapters(): object; + isFieldAware(): boolean; + isFilterByMapBounds(): boolean; + isGeoGridPrecisionAware(): boolean; + isQueryAware(): boolean; + isRefreshTimerAware(): Promise; + isTimeAware(): Promise; } export class AbstractSource implements ISource { constructor(sourceDescriptor: AbstractSourceDescriptor, inspectorAdapters: object); + + destroy(): void; createDefaultLayer(): ILayer; getDisplayName(): Promise; - destroy(): void; getInspectorAdapters(): object; + isFieldAware(): boolean; + isFilterByMapBounds(): boolean; + isGeoGridPrecisionAware(): boolean; + isQueryAware(): boolean; + isRefreshTimerAware(): Promise; + isTimeAware(): Promise; } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.d.ts b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.d.ts index 14fc23751ac1a..fd585e100924e 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/sources/vector_source.d.ts @@ -5,6 +5,7 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ +import { FeatureCollection } from 'geojson'; import { AbstractSource, ISource } from './source'; import { IField } from '../fields/field'; import { ESSearchSourceResponseMeta } from '../../../common/data_request_descriptor_types'; @@ -12,7 +13,7 @@ import { ESSearchSourceResponseMeta } from '../../../common/data_request_descrip export type GeoJsonFetchMeta = ESSearchSourceResponseMeta; export type GeoJsonWithMeta = { - data: unknown; // geojson feature collection + data: FeatureCollection; meta?: GeoJsonFetchMeta; }; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/_color_stops.scss b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/_color_stops.scss index 519e97f4b30cd..09a9ad59bce3c 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/_color_stops.scss +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/_color_stops.scss @@ -4,15 +4,5 @@ & + & { margin-top: $euiSizeS; } - - &:hover, - &:focus { - .mapColorStop__icons { - visibility: visible; - opacity: 1; - display: block; - animation: mapColorStopBecomeVisible $euiAnimSpeedFast $euiAnimSlightResistance; - } - } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops.js index 3e9b9e2aafc47..059543d705fc7 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/color_stops.js @@ -12,7 +12,7 @@ import { EuiButtonIcon, EuiColorPicker, EuiFlexGroup, EuiFlexItem, EuiFormRow } function getColorStopRow({ index, errors, stopInput, onColorChange, color, deleteButton, onAdd }) { const colorPickerButtons = ( -
+
{deleteButton} { @@ -19,9 +20,31 @@ function isDuplicateStop(targetStop, iconStops) { return stops.length > 1; } +export function getFirstUnusedSymbol(symbolOptions, iconStops) { + const firstUnusedPreferredIconId = PREFERRED_ICONS.find(iconId => { + const isSymbolBeingUsed = iconStops.some(({ icon }) => { + return icon === iconId; + }); + return !isSymbolBeingUsed; + }); + + if (firstUnusedPreferredIconId) { + return firstUnusedPreferredIconId; + } + + const firstUnusedSymbol = symbolOptions.find(({ value }) => { + const isSymbolBeingUsed = iconStops.some(({ icon }) => { + return icon === value; + }); + return !isSymbolBeingUsed; + }); + + return firstUnusedSymbol ? firstUnusedSymbol.value : DEFAULT_ICON; +} + const DEFAULT_ICON_STOPS = [ - { stop: null, icon: DEFAULT_ICON }, //first stop is the "other" color - { stop: '', icon: DEFAULT_ICON }, + { stop: null, icon: PREFERRED_ICONS[0] }, //first stop is the "other" color + { stop: '', icon: PREFERRED_ICONS[1] }, ]; export function IconStops({ @@ -58,7 +81,7 @@ export function IconStops({ ...iconStops.slice(0, index + 1), { stop: '', - icon: DEFAULT_ICON, + icon: getFirstUnusedSymbol(symbolOptions, iconStops), }, ...iconStops.slice(index + 1), ], @@ -66,12 +89,12 @@ export function IconStops({ }; const onRemove = () => { onChange({ - iconStops: [...iconStops.slice(0, index), ...iconStops.slice(index + 1)], + customMapStops: [...iconStops.slice(0, index), ...iconStops.slice(index + 1)], }); }; let deleteButton; - if (index > 0) { + if (iconStops.length > 2 && index !== 0) { deleteButton = ( + {deleteButton} + +
+ ); + const errors = []; // TODO check for duplicate values and add error messages here @@ -116,29 +152,20 @@ export function IconStops({ error={errors} display="rowCompressed" > -
- - {stopInput} - - - - -
- {deleteButton} - + + {stopInput} + + + -
-
+ + ); }); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/symbol/icon_stops.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/symbol/icon_stops.test.js new file mode 100644 index 0000000000000..ffe9b6feef462 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/symbol/icon_stops.test.js @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getFirstUnusedSymbol } from './icon_stops'; + +describe('getFirstUnusedSymbol', () => { + const symbolOptions = [{ value: 'icon1' }, { value: 'icon2' }]; + + test('Should return first unused icon from PREFERRED_ICONS', () => { + const iconStops = [ + { stop: 'category1', icon: 'circle' }, + { stop: 'category2', icon: 'marker' }, + ]; + const nextIcon = getFirstUnusedSymbol(symbolOptions, iconStops); + expect(nextIcon).toBe('square'); + }); + + test('Should fallback to first unused general icons when all PREFERRED_ICONS are used', () => { + const iconStops = [ + { stop: 'category1', icon: 'circle' }, + { stop: 'category2', icon: 'marker' }, + { stop: 'category3', icon: 'square' }, + { stop: 'category4', icon: 'star' }, + { stop: 'category5', icon: 'triangle' }, + { stop: 'category6', icon: 'hospital' }, + { stop: 'category7', icon: 'circle-stroked' }, + { stop: 'category8', icon: 'marker-stroked' }, + { stop: 'category9', icon: 'square-stroked' }, + { stop: 'category10', icon: 'star-stroked' }, + { stop: 'category11', icon: 'triangle-stroked' }, + { stop: 'category12', icon: 'icon1' }, + ]; + const nextIcon = getFirstUnusedSymbol(symbolOptions, iconStops); + expect(nextIcon).toBe('icon2'); + }); + + test('Should fallback to default icon when all icons are used', () => { + const iconStops = [ + { stop: 'category1', icon: 'circle' }, + { stop: 'category2', icon: 'marker' }, + { stop: 'category3', icon: 'square' }, + { stop: 'category4', icon: 'star' }, + { stop: 'category5', icon: 'triangle' }, + { stop: 'category6', icon: 'hospital' }, + { stop: 'category7', icon: 'circle-stroked' }, + { stop: 'category8', icon: 'marker-stroked' }, + { stop: 'category9', icon: 'square-stroked' }, + { stop: 'category10', icon: 'star-stroked' }, + { stop: 'category11', icon: 'triangle-stroked' }, + { stop: 'category12', icon: 'icon1' }, + { stop: 'category13', icon: 'icon2' }, + ]; + const nextIcon = getFirstUnusedSymbol(symbolOptions, iconStops); + expect(nextIcon).toBe('marker'); + }); +}); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/symbol_utils.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/symbol_utils.js index affb9c1805170..c1c4375faaeb1 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/symbol_utils.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/symbol_utils.js @@ -101,6 +101,16 @@ const ICON_PALETTES = [ }, ]; +// PREFERRED_ICONS is used to provide less random default icon values for forms that need default icon values +export const PREFERRED_ICONS = []; +ICON_PALETTES.forEach(iconPalette => { + iconPalette.icons.forEach(iconId => { + if (!PREFERRED_ICONS.includes(iconId)) { + PREFERRED_ICONS.push(iconId); + } + }); +}); + export function getIconPaletteOptions(isDarkMode) { return ICON_PALETTES.map(({ id, icons }) => { const iconsDisplay = icons.map(iconId => { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js index d669fd280e32c..426f1d6afa952 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js @@ -96,7 +96,7 @@ describe('getDescriptorWithMissingStylePropsRemoved', () => { }, icon: { options: { - value: 'airfield', + value: 'marker', }, type: 'STATIC', }, diff --git a/x-pack/legacy/plugins/maps/public/layers/tile_layer.test.ts b/x-pack/legacy/plugins/maps/public/layers/tile_layer.test.ts index 8ce38a128ebc4..1cb99dcbc1a70 100644 --- a/x-pack/legacy/plugins/maps/public/layers/tile_layer.test.ts +++ b/x-pack/legacy/plugins/maps/public/layers/tile_layer.test.ts @@ -7,7 +7,7 @@ import { TileLayer } from './tile_layer'; import { EMS_XYZ } from '../../common/constants'; import { XYZTMSSourceDescriptor } from '../../common/descriptor_types'; -import { ITMSSource } from './sources/tms_source'; +import { ITMSSource, AbstractTMSSource } from './sources/tms_source'; import { ILayer } from './layer'; const sourceDescriptor: XYZTMSSourceDescriptor = { @@ -16,9 +16,10 @@ const sourceDescriptor: XYZTMSSourceDescriptor = { id: 'foobar', }; -class MockTileSource implements ITMSSource { +class MockTileSource extends AbstractTMSSource implements ITMSSource { private readonly _descriptor: XYZTMSSourceDescriptor; constructor(descriptor: XYZTMSSourceDescriptor) { + super(descriptor, {}); this._descriptor = descriptor; } createDefaultLayer(): ILayer { @@ -32,14 +33,6 @@ class MockTileSource implements ITMSSource { async getUrlTemplate(): Promise { return 'template/{x}/{y}/{z}.png'; } - - destroy(): void { - // no-op - } - - getInspectorAdapters(): object { - return {}; - } } describe('TileLayer', () => { diff --git a/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.js b/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.ts similarity index 74% rename from x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.js rename to x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.ts index cc8dff14ec4f0..fb07a523e1e07 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.test.ts @@ -6,19 +6,25 @@ import { assignFeatureIds } from './assign_feature_ids'; import { FEATURE_ID_PROPERTY_NAME } from '../../../common/constants'; +import { FeatureCollection, Feature, Point } from 'geojson'; const featureId = 'myFeature1'; +const geometry: Point = { + type: 'Point', + coordinates: [0, 0], +}; + +const defaultFeature: Feature = { + type: 'Feature', + geometry, + properties: {}, +}; + test('should provide unique id when feature.id is not provided', () => { - const featureCollection = { - features: [ - { - properties: {}, - }, - { - properties: {}, - }, - ], + const featureCollection: FeatureCollection = { + type: 'FeatureCollection', + features: [{ ...defaultFeature }, { ...defaultFeature }], }; const updatedFeatureCollection = assignFeatureIds(featureCollection); @@ -26,16 +32,18 @@ test('should provide unique id when feature.id is not provided', () => { const feature2 = updatedFeatureCollection.features[1]; expect(typeof feature1.id).toBe('number'); expect(typeof feature2.id).toBe('number'); + // @ts-ignore expect(feature1.id).toBe(feature1.properties[FEATURE_ID_PROPERTY_NAME]); expect(feature1.id).not.toBe(feature2.id); }); test('should preserve feature id when provided', () => { - const featureCollection = { + const featureCollection: FeatureCollection = { + type: 'FeatureCollection', features: [ { + ...defaultFeature, id: featureId, - properties: {}, }, ], }; @@ -43,16 +51,19 @@ test('should preserve feature id when provided', () => { const updatedFeatureCollection = assignFeatureIds(featureCollection); const feature1 = updatedFeatureCollection.features[0]; expect(typeof feature1.id).toBe('number'); + // @ts-ignore expect(feature1.id).not.toBe(feature1.properties[FEATURE_ID_PROPERTY_NAME]); + // @ts-ignore expect(feature1.properties[FEATURE_ID_PROPERTY_NAME]).toBe(featureId); }); test('should preserve feature id for falsy value', () => { - const featureCollection = { + const featureCollection: FeatureCollection = { + type: 'FeatureCollection', features: [ { + ...defaultFeature, id: 0, - properties: {}, }, ], }; @@ -60,15 +71,19 @@ test('should preserve feature id for falsy value', () => { const updatedFeatureCollection = assignFeatureIds(featureCollection); const feature1 = updatedFeatureCollection.features[0]; expect(typeof feature1.id).toBe('number'); + // @ts-ignore expect(feature1.id).not.toBe(feature1.properties[FEATURE_ID_PROPERTY_NAME]); + // @ts-ignore expect(feature1.properties[FEATURE_ID_PROPERTY_NAME]).toBe(0); }); test('should not modify original feature properties', () => { const featureProperties = {}; - const featureCollection = { + const featureCollection: FeatureCollection = { + type: 'FeatureCollection', features: [ { + ...defaultFeature, id: featureId, properties: featureProperties, }, @@ -77,6 +92,7 @@ test('should not modify original feature properties', () => { const updatedFeatureCollection = assignFeatureIds(featureCollection); const feature1 = updatedFeatureCollection.features[0]; + // @ts-ignore expect(feature1.properties[FEATURE_ID_PROPERTY_NAME]).toBe(featureId); expect(featureProperties).not.toHaveProperty(FEATURE_ID_PROPERTY_NAME); }); diff --git a/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.js b/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.ts similarity index 90% rename from x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.js rename to x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.ts index a943b0b22a189..e5c170a803174 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/assign_feature_ids.ts @@ -5,17 +5,18 @@ */ import _ from 'lodash'; +import { FeatureCollection, Feature } from 'geojson'; import { FEATURE_ID_PROPERTY_NAME } from '../../../common/constants'; let idCounter = 0; -function generateNumericalId() { +function generateNumericalId(): number { const newId = idCounter < Number.MAX_SAFE_INTEGER ? idCounter : 0; idCounter = newId + 1; return newId; } -export function assignFeatureIds(featureCollection) { +export function assignFeatureIds(featureCollection: FeatureCollection): FeatureCollection { // wrt https://github.com/elastic/kibana/issues/39317 // In constrained resource environments, mapbox-gl may throw a stackoverflow error due to hitting the browser's recursion limit. This crashes Kibana. // This error is thrown in mapbox-gl's quicksort implementation, when it is sorting all the features by id. @@ -32,7 +33,7 @@ export function assignFeatureIds(featureCollection) { } const randomizedIds = _.shuffle(ids); - const features = []; + const features: Feature[] = []; for (let i = 0; i < featureCollection.features.length; i++) { const numericId = randomizedIds[i]; const feature = featureCollection.features[i]; diff --git a/x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.js b/x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.ts similarity index 84% rename from x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.js rename to x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.ts index 7abfee1b184f0..7b75bb0f21b79 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/can_skip_fetch.ts @@ -4,14 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ import _ from 'lodash'; +// @ts-ignore import turf from 'turf'; import turfBooleanContains from '@turf/boolean-contains'; import { isRefreshOnlyQuery } from './is_refresh_only_query'; +import { ISource } from '../sources/source'; +import { DataMeta } from '../../../common/data_request_descriptor_types'; +import { DataRequest } from './data_request'; const SOURCE_UPDATE_REQUIRED = true; const NO_SOURCE_UPDATE_REQUIRED = false; -export function updateDueToExtent(source, prevMeta = {}, nextMeta = {}) { +export function updateDueToExtent( + source: ISource, + prevMeta: DataMeta = {}, + nextMeta: DataMeta = {} +) { const extentAware = source.isFilterByMapBounds(); if (!extentAware) { return NO_SOURCE_UPDATE_REQUIRED; @@ -20,7 +28,7 @@ export function updateDueToExtent(source, prevMeta = {}, nextMeta = {}) { const { buffer: previousBuffer } = prevMeta; const { buffer: newBuffer } = nextMeta; - if (!previousBuffer) { + if (!previousBuffer || !previousBuffer || !newBuffer) { return SOURCE_UPDATE_REQUIRED; } @@ -51,7 +59,15 @@ export function updateDueToExtent(source, prevMeta = {}, nextMeta = {}) { : SOURCE_UPDATE_REQUIRED; } -export async function canSkipSourceUpdate({ source, prevDataRequest, nextMeta }) { +export async function canSkipSourceUpdate({ + source, + prevDataRequest, + nextMeta, +}: { + source: ISource; + prevDataRequest: DataRequest | undefined; + nextMeta: DataMeta; +}): Promise { const timeAware = await source.isTimeAware(); const refreshTimerAware = await source.isRefreshTimerAware(); const extentAware = source.isFilterByMapBounds(); @@ -67,7 +83,7 @@ export async function canSkipSourceUpdate({ source, prevDataRequest, nextMeta }) !isQueryAware && !isGeoGridPrecisionAware ) { - return prevDataRequest && prevDataRequest.hasDataOrRequestInProgress(); + return !!prevDataRequest && prevDataRequest.hasDataOrRequestInProgress(); } if (!prevDataRequest) { @@ -136,7 +152,13 @@ export async function canSkipSourceUpdate({ source, prevDataRequest, nextMeta }) ); } -export function canSkipStyleMetaUpdate({ prevDataRequest, nextMeta }) { +export function canSkipStyleMetaUpdate({ + prevDataRequest, + nextMeta, +}: { + prevDataRequest: DataRequest | undefined; + nextMeta: DataMeta; +}): boolean { if (!prevDataRequest) { return false; } @@ -159,7 +181,13 @@ export function canSkipStyleMetaUpdate({ prevDataRequest, nextMeta }) { ); } -export function canSkipFormattersUpdate({ prevDataRequest, nextMeta }) { +export function canSkipFormattersUpdate({ + prevDataRequest, + nextMeta, +}: { + prevDataRequest: DataRequest | undefined; + nextMeta: DataMeta; +}): boolean { if (!prevDataRequest) { return false; } diff --git a/x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.js b/x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.ts similarity index 78% rename from x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.js rename to x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.ts index f3dc08a7a7a58..48b1340207fd4 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/is_refresh_only_query.ts @@ -4,9 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Query } from '../../../common/map_descriptor'; + // Refresh only query is query where timestamps are different but query is the same. // Triggered by clicking "Refresh" button in QueryBar -export function isRefreshOnlyQuery(prevQuery, newQuery) { +export function isRefreshOnlyQuery( + prevQuery: Query | undefined, + newQuery: Query | undefined +): boolean { if (!prevQuery || !newQuery) { return false; } diff --git a/x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.js b/x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.ts similarity index 87% rename from x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.js rename to x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.ts index 36841dc727dd3..8da6fa2318de9 100644 --- a/x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.js +++ b/x-pack/legacy/plugins/maps/public/layers/util/mb_filter_expressions.ts @@ -34,14 +34,14 @@ const POINT_MB_FILTER = [ const VISIBLE_POINT_MB_FILTER = [...VISIBILITY_FILTER_CLAUSE, POINT_MB_FILTER]; -export function getFillFilterExpression(hasJoins) { +export function getFillFilterExpression(hasJoins: boolean): unknown[] { return hasJoins ? VISIBLE_CLOSED_SHAPE_MB_FILTER : CLOSED_SHAPE_MB_FILTER; } -export function getLineFilterExpression(hasJoins) { +export function getLineFilterExpression(hasJoins: boolean): unknown[] { return hasJoins ? VISIBLE_ALL_SHAPE_MB_FILTER : ALL_SHAPE_MB_FILTER; } -export function getPointFilterExpression(hasJoins) { +export function getPointFilterExpression(hasJoins: boolean): unknown[] { return hasJoins ? VISIBLE_POINT_MB_FILTER : POINT_MB_FILTER; } diff --git a/x-pack/legacy/plugins/maps/public/layers/vector_layer.d.ts b/x-pack/legacy/plugins/maps/public/layers/vector_layer.d.ts index 77e8ab768cd00..390374f761fc7 100644 --- a/x-pack/legacy/plugins/maps/public/layers/vector_layer.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/vector_layer.d.ts @@ -8,7 +8,7 @@ import { AbstractLayer } from './layer'; import { IVectorSource } from './sources/vector_source'; import { VectorLayerDescriptor } from '../../common/descriptor_types'; -import { MapFilters, VectorLayerRequestMeta } from '../../common/data_request_descriptor_types'; +import { MapFilters, VectorSourceRequestMeta } from '../../common/data_request_descriptor_types'; import { ILayer } from './layer'; import { IJoin } from './joins/join'; import { IVectorStyle } from './styles/vector/vector_style'; @@ -45,6 +45,6 @@ export class VectorLayer extends AbstractLayer implements IVectorLayer { dataFilters: MapFilters, source: IVectorSource, style: IVectorStyle - ): VectorLayerRequestMeta; + ): VectorSourceRequestMeta; _syncData(syncContext: SyncContext, source: IVectorSource, style: IVectorStyle): Promise; } diff --git a/x-pack/legacy/plugins/maps/server/plugin.js b/x-pack/legacy/plugins/maps/server/plugin.js index 02e38ff54b300..5b52a3eba2f23 100644 --- a/x-pack/legacy/plugins/maps/server/plugin.js +++ b/x-pack/legacy/plugins/maps/server/plugin.js @@ -23,12 +23,15 @@ export class MapPlugin { name: i18n.translate('xpack.maps.featureRegistry.mapsFeatureName', { defaultMessage: 'Maps', }), + order: 600, icon: APP_ICON, navLinkId: APP_ID, app: [APP_ID, 'kibana'], catalogue: [APP_ID], privileges: { all: { + app: [APP_ID, 'kibana'], + catalogue: [APP_ID], savedObject: { all: [MAP_SAVED_OBJECT_TYPE, 'query'], read: ['index-pattern'], @@ -36,6 +39,8 @@ export class MapPlugin { ui: ['save', 'show', 'saveQuery'], }, read: { + app: [APP_ID, 'kibana'], + catalogue: [APP_ID], savedObject: { all: [], read: [MAP_SAVED_OBJECT_TYPE, 'index-pattern', 'query'], diff --git a/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap b/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap new file mode 100644 index 0000000000000..757677f1d4f82 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap @@ -0,0 +1,383 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`config schema with context {"dev":false,"dist":false} produces correct config 1`] = ` +Object { + "capture": Object { + "browser": Object { + "autoDownload": true, + "chromium": Object { + "disableSandbox": "", + "maxScreenshotDimension": 1950, + "proxy": Object { + "enabled": false, + }, + }, + "type": "chromium", + }, + "concurrency": 4, + "loadDelay": 3000, + "maxAttempts": 1, + "networkPolicy": Object { + "enabled": true, + "rules": Array [ + Object { + "allow": true, + "protocol": "http:", + }, + Object { + "allow": true, + "protocol": "https:", + }, + Object { + "allow": true, + "protocol": "ws:", + }, + Object { + "allow": true, + "protocol": "wss:", + }, + Object { + "allow": true, + "protocol": "data:", + }, + Object { + "allow": false, + }, + ], + }, + "settleTime": 1000, + "timeout": 20000, + "timeouts": Object { + "openUrl": 30000, + "renderComplete": 30000, + "waitForElements": 30000, + }, + "viewport": Object { + "height": 1200, + "width": 1950, + }, + "zoom": 2, + }, + "csv": Object { + "checkForFormulas": true, + "enablePanelActionDownload": true, + "maxSizeBytes": 10485760, + "scroll": Object { + "duration": "30s", + "size": 500, + }, + }, + "enabled": true, + "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "index": ".reporting", + "kibanaServer": Object {}, + "poll": Object { + "jobCompletionNotifier": Object { + "interval": 10000, + "intervalErrorMultiplier": 5, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 5, + }, + }, + "queue": Object { + "indexInterval": "week", + "pollEnabled": true, + "pollInterval": 3000, + "pollIntervalErrorMultiplier": 10, + "timeout": 120000, + }, + "roles": Object { + "allow": Array [ + "reporting_user", + ], + }, +} +`; + +exports[`config schema with context {"dev":false,"dist":true} produces correct config 1`] = ` +Object { + "capture": Object { + "browser": Object { + "autoDownload": false, + "chromium": Object { + "disableSandbox": "", + "maxScreenshotDimension": 1950, + "proxy": Object { + "enabled": false, + }, + }, + "type": "chromium", + }, + "concurrency": 4, + "loadDelay": 3000, + "maxAttempts": 3, + "networkPolicy": Object { + "enabled": true, + "rules": Array [ + Object { + "allow": true, + "protocol": "http:", + }, + Object { + "allow": true, + "protocol": "https:", + }, + Object { + "allow": true, + "protocol": "ws:", + }, + Object { + "allow": true, + "protocol": "wss:", + }, + Object { + "allow": true, + "protocol": "data:", + }, + Object { + "allow": false, + }, + ], + }, + "settleTime": 1000, + "timeout": 20000, + "timeouts": Object { + "openUrl": 30000, + "renderComplete": 30000, + "waitForElements": 30000, + }, + "viewport": Object { + "height": 1200, + "width": 1950, + }, + "zoom": 2, + }, + "csv": Object { + "checkForFormulas": true, + "enablePanelActionDownload": true, + "maxSizeBytes": 10485760, + "scroll": Object { + "duration": "30s", + "size": 500, + }, + }, + "enabled": true, + "index": ".reporting", + "kibanaServer": Object {}, + "poll": Object { + "jobCompletionNotifier": Object { + "interval": 10000, + "intervalErrorMultiplier": 5, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 5, + }, + }, + "queue": Object { + "indexInterval": "week", + "pollEnabled": true, + "pollInterval": 3000, + "pollIntervalErrorMultiplier": 10, + "timeout": 120000, + }, + "roles": Object { + "allow": Array [ + "reporting_user", + ], + }, +} +`; + +exports[`config schema with context {"dev":true,"dist":false} produces correct config 1`] = ` +Object { + "capture": Object { + "browser": Object { + "autoDownload": true, + "chromium": Object { + "disableSandbox": "", + "maxScreenshotDimension": 1950, + "proxy": Object { + "enabled": false, + }, + }, + "type": "chromium", + }, + "concurrency": 4, + "loadDelay": 3000, + "maxAttempts": 1, + "networkPolicy": Object { + "enabled": true, + "rules": Array [ + Object { + "allow": true, + "protocol": "http:", + }, + Object { + "allow": true, + "protocol": "https:", + }, + Object { + "allow": true, + "protocol": "ws:", + }, + Object { + "allow": true, + "protocol": "wss:", + }, + Object { + "allow": true, + "protocol": "data:", + }, + Object { + "allow": false, + }, + ], + }, + "settleTime": 1000, + "timeout": 20000, + "timeouts": Object { + "openUrl": 30000, + "renderComplete": 30000, + "waitForElements": 30000, + }, + "viewport": Object { + "height": 1200, + "width": 1950, + }, + "zoom": 2, + }, + "csv": Object { + "checkForFormulas": true, + "enablePanelActionDownload": true, + "maxSizeBytes": 10485760, + "scroll": Object { + "duration": "30s", + "size": 500, + }, + }, + "enabled": true, + "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "index": ".reporting", + "kibanaServer": Object {}, + "poll": Object { + "jobCompletionNotifier": Object { + "interval": 10000, + "intervalErrorMultiplier": 5, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 5, + }, + }, + "queue": Object { + "indexInterval": "week", + "pollEnabled": true, + "pollInterval": 3000, + "pollIntervalErrorMultiplier": 10, + "timeout": 120000, + }, + "roles": Object { + "allow": Array [ + "reporting_user", + ], + }, +} +`; + +exports[`config schema with context {"dev":true,"dist":true} produces correct config 1`] = ` +Object { + "capture": Object { + "browser": Object { + "autoDownload": false, + "chromium": Object { + "disableSandbox": "", + "maxScreenshotDimension": 1950, + "proxy": Object { + "enabled": false, + }, + }, + "type": "chromium", + }, + "concurrency": 4, + "loadDelay": 3000, + "maxAttempts": 3, + "networkPolicy": Object { + "enabled": true, + "rules": Array [ + Object { + "allow": true, + "protocol": "http:", + }, + Object { + "allow": true, + "protocol": "https:", + }, + Object { + "allow": true, + "protocol": "ws:", + }, + Object { + "allow": true, + "protocol": "wss:", + }, + Object { + "allow": true, + "protocol": "data:", + }, + Object { + "allow": false, + }, + ], + }, + "settleTime": 1000, + "timeout": 20000, + "timeouts": Object { + "openUrl": 30000, + "renderComplete": 30000, + "waitForElements": 30000, + }, + "viewport": Object { + "height": 1200, + "width": 1950, + }, + "zoom": 2, + }, + "csv": Object { + "checkForFormulas": true, + "enablePanelActionDownload": true, + "maxSizeBytes": 10485760, + "scroll": Object { + "duration": "30s", + "size": 500, + }, + }, + "enabled": true, + "index": ".reporting", + "kibanaServer": Object {}, + "poll": Object { + "jobCompletionNotifier": Object { + "interval": 10000, + "intervalErrorMultiplier": 5, + }, + "jobsRefresh": Object { + "interval": 5000, + "intervalErrorMultiplier": 5, + }, + }, + "queue": Object { + "indexInterval": "week", + "pollEnabled": true, + "pollInterval": 3000, + "pollIntervalErrorMultiplier": 10, + "timeout": 120000, + }, + "roles": Object { + "allow": Array [ + "reporting_user", + ], + }, +} +`; diff --git a/x-pack/legacy/plugins/reporting/config.ts b/x-pack/legacy/plugins/reporting/config.ts new file mode 100644 index 0000000000000..211fa70301bbf --- /dev/null +++ b/x-pack/legacy/plugins/reporting/config.ts @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { BROWSER_TYPE } from './common/constants'; +// @ts-ignore untyped module +import { config as appConfig } from './server/config/config'; +import { getDefaultChromiumSandboxDisabled } from './server/browsers'; + +export async function config(Joi: any) { + return Joi.object({ + enabled: Joi.boolean().default(true), + kibanaServer: Joi.object({ + protocol: Joi.string().valid(['http', 'https']), + hostname: Joi.string().invalid('0'), + port: Joi.number().integer(), + }).default(), + queue: Joi.object({ + indexInterval: Joi.string().default('week'), + pollEnabled: Joi.boolean().default(true), + pollInterval: Joi.number() + .integer() + .default(3000), + pollIntervalErrorMultiplier: Joi.number() + .integer() + .default(10), + timeout: Joi.number() + .integer() + .default(120000), + }).default(), + capture: Joi.object({ + timeouts: Joi.object({ + openUrl: Joi.number() + .integer() + .default(30000), + waitForElements: Joi.number() + .integer() + .default(30000), + renderComplete: Joi.number() + .integer() + .default(30000), + }).default(), + networkPolicy: Joi.object({ + enabled: Joi.boolean().default(true), + rules: Joi.array() + .items( + Joi.object({ + allow: Joi.boolean().required(), + protocol: Joi.string(), + host: Joi.string(), + }) + ) + .default([ + { allow: true, protocol: 'http:' }, + { allow: true, protocol: 'https:' }, + { allow: true, protocol: 'ws:' }, + { allow: true, protocol: 'wss:' }, + { allow: true, protocol: 'data:' }, + { allow: false }, // Default action is to deny! + ]), + }).default(), + zoom: Joi.number() + .integer() + .default(2), + viewport: Joi.object({ + width: Joi.number() + .integer() + .default(1950), + height: Joi.number() + .integer() + .default(1200), + }).default(), + timeout: Joi.number() + .integer() + .default(20000), // deprecated + loadDelay: Joi.number() + .integer() + .default(3000), + settleTime: Joi.number() + .integer() + .default(1000), // deprecated + concurrency: Joi.number() + .integer() + .default(appConfig.concurrency), // deprecated + browser: Joi.object({ + type: Joi.any() + .valid(BROWSER_TYPE) + .default(BROWSER_TYPE), + autoDownload: Joi.boolean().when('$dist', { + is: true, + then: Joi.default(false), + otherwise: Joi.default(true), + }), + chromium: Joi.object({ + inspect: Joi.boolean() + .when('$dev', { + is: false, + then: Joi.valid(false), + else: Joi.default(false), + }) + .default(), + disableSandbox: Joi.boolean().default(await getDefaultChromiumSandboxDisabled()), + proxy: Joi.object({ + enabled: Joi.boolean().default(false), + server: Joi.string() + .uri({ scheme: ['http', 'https'] }) + .when('enabled', { + is: Joi.valid(false), + then: Joi.valid(null), + else: Joi.required(), + }), + bypass: Joi.array() + .items(Joi.string().regex(/^[^\s]+$/)) + .when('enabled', { + is: Joi.valid(false), + then: Joi.valid(null), + else: Joi.default([]), + }), + }).default(), + maxScreenshotDimension: Joi.number() + .integer() + .default(1950), + }).default(), + }).default(), + maxAttempts: Joi.number() + .integer() + .greater(0) + .when('$dist', { + is: true, + then: Joi.default(3), + otherwise: Joi.default(1), + }) + .default(), + }).default(), + csv: Joi.object({ + checkForFormulas: Joi.boolean().default(true), + enablePanelActionDownload: Joi.boolean().default(true), + maxSizeBytes: Joi.number() + .integer() + .default(1024 * 1024 * 10), // bytes in a kB * kB in a mB * 10 + scroll: Joi.object({ + duration: Joi.string() + .regex(/^[0-9]+(d|h|m|s|ms|micros|nanos)$/, { name: 'DurationString' }) + .default('30s'), + size: Joi.number() + .integer() + .default(500), + }).default(), + }).default(), + encryptionKey: Joi.when(Joi.ref('$dist'), { + is: true, + then: Joi.string(), + otherwise: Joi.string().default('a'.repeat(32)), + }), + roles: Joi.object({ + allow: Joi.array() + .items(Joi.string()) + .default(['reporting_user']), + }).default(), + index: Joi.string().default('.reporting'), + poll: Joi.object({ + jobCompletionNotifier: Joi.object({ + interval: Joi.number() + .integer() + .default(10000), + intervalErrorMultiplier: Joi.number() + .integer() + .default(5), + }).default(), + jobsRefresh: Joi.object({ + interval: Joi.number() + .integer() + .default(5000), + intervalErrorMultiplier: Joi.number() + .integer() + .default(5), + }).default(), + }).default(), + }).default(); +} diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts index 9085fb3cbc876..468caf93ec5dd 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts @@ -5,27 +5,33 @@ */ import { cryptoFactory } from '../../../server/lib/crypto'; +import { createMockServer } from '../../../test_helpers'; import { Logger } from '../../../types'; import { decryptJobHeaders } from './decrypt_job_headers'; -const encryptHeaders = async (encryptionKey: string, headers: Record) => { - const crypto = cryptoFactory(encryptionKey); +let mockServer: any; +beforeEach(() => { + mockServer = createMockServer(''); +}); + +const encryptHeaders = async (headers: Record) => { + const crypto = cryptoFactory(mockServer); return await crypto.encrypt(headers); }; describe('headers', () => { test(`fails if it can't decrypt headers`, async () => { - const getDecryptedHeaders = () => + await expect( decryptJobHeaders({ - encryptionKey: 'abcsecretsauce', job: { headers: 'Q53+9A+zf+Xe+ceR/uB/aR/Sw/8e+M+qR+WiG+8z+EY+mo+HiU/zQL+Xn', }, logger: ({ error: jest.fn(), } as unknown) as Logger, - }); - await expect(getDecryptedHeaders()).rejects.toMatchInlineSnapshot( + server: mockServer, + }) + ).rejects.toMatchInlineSnapshot( `[Error: Failed to decrypt report job data. Please ensure that xpack.reporting.encryptionKey is set and re-generate this report. Error: Invalid IV length]` ); }); @@ -36,15 +42,15 @@ describe('headers', () => { baz: 'quix', }; - const encryptedHeaders = await encryptHeaders('abcsecretsauce', headers); + const encryptedHeaders = await encryptHeaders(headers); const decryptedHeaders = await decryptJobHeaders({ - encryptionKey: 'abcsecretsauce', job: { title: 'cool-job-bro', type: 'csv', headers: encryptedHeaders, }, logger: {} as Logger, + server: mockServer, }); expect(decryptedHeaders).toEqual(headers); }); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts index 6f415d7ee5ea9..436b2c2dab1ad 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { cryptoFactory } from '../../../server/lib/crypto'; -import { CryptoFactory, Logger } from '../../../types'; +import { CryptoFactory, ServerFacade, Logger } from '../../../types'; interface HasEncryptedHeaders { headers?: string; @@ -17,15 +17,15 @@ export const decryptJobHeaders = async < JobParamsType, JobDocPayloadType extends HasEncryptedHeaders >({ - encryptionKey, + server, job, logger, }: { - encryptionKey?: string; + server: ServerFacade; job: JobDocPayloadType; logger: Logger; }): Promise> => { - const crypto: CryptoFactory = cryptoFactory(encryptionKey); + const crypto: CryptoFactory = cryptoFactory(server); try { const decryptedHeaders: Record = await crypto.decrypt(job.headers); return decryptedHeaders; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts index 09527621fa49f..eedb742ad7597 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts @@ -4,33 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ -import sinon from 'sinon'; -import { createMockReportingCore } from '../../../test_helpers'; -import { ReportingConfig, ReportingCore } from '../../../server/types'; +import { createMockReportingCore, createMockServer } from '../../../test_helpers'; +import { ReportingCore } from '../../../server'; import { JobDocPayload } from '../../../types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; import { getConditionalHeaders, getCustomLogo } from './index'; -let mockConfig: ReportingConfig; let mockReportingPlugin: ReportingCore; - -const getMockConfig = (mockConfigGet: sinon.SinonStub) => ({ - get: mockConfigGet, - kbnConfig: { get: mockConfigGet }, -}); - +let mockServer: any; beforeEach(async () => { mockReportingPlugin = await createMockReportingCore(); - - const mockConfigGet = sinon - .stub() - .withArgs('kibanaServer', 'hostname') - .returns('custom-hostname'); - mockConfig = getMockConfig(mockConfigGet); + mockServer = createMockServer(''); }); describe('conditions', () => { test(`uses hostname from reporting config if set`, async () => { + const settings: any = { + 'xpack.reporting.kibanaServer.hostname': 'custom-hostname', + }; + + mockServer = createMockServer({ settings }); + const permittedHeaders = { foo: 'bar', baz: 'quix', @@ -39,20 +33,121 @@ describe('conditions', () => { const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: permittedHeaders, - config: mockConfig, + server: mockServer, }); expect(conditionalHeaders.conditions.hostname).toEqual( - mockConfig.get('kibanaServer', 'hostname') + mockServer.config().get('xpack.reporting.kibanaServer.hostname') ); - expect(conditionalHeaders.conditions.port).toEqual(mockConfig.get('kibanaServer', 'port')); - expect(conditionalHeaders.conditions.protocol).toEqual( - mockConfig.get('kibanaServer', 'protocol') + }); + + test(`uses hostname from server.config if reporting config not set`, async () => { + const permittedHeaders = { + foo: 'bar', + baz: 'quix', + }; + + const conditionalHeaders = await getConditionalHeaders({ + job: {} as JobDocPayload, + filteredHeaders: permittedHeaders, + server: mockServer, + }); + + expect(conditionalHeaders.conditions.hostname).toEqual(mockServer.config().get('server.host')); + }); + + test(`uses port from reporting config if set`, async () => { + const settings = { + 'xpack.reporting.kibanaServer.port': 443, + }; + + mockServer = createMockServer({ settings }); + + const permittedHeaders = { + foo: 'bar', + baz: 'quix', + }; + + const conditionalHeaders = await getConditionalHeaders({ + job: {} as JobDocPayload, + filteredHeaders: permittedHeaders, + server: mockServer, + }); + + expect(conditionalHeaders.conditions.port).toEqual( + mockServer.config().get('xpack.reporting.kibanaServer.port') ); + }); + + test(`uses port from server if reporting config not set`, async () => { + const permittedHeaders = { + foo: 'bar', + baz: 'quix', + }; + + const conditionalHeaders = await getConditionalHeaders({ + job: {} as JobDocPayload, + filteredHeaders: permittedHeaders, + server: mockServer, + }); + + expect(conditionalHeaders.conditions.port).toEqual(mockServer.config().get('server.port')); + }); + + test(`uses basePath from server config`, async () => { + const permittedHeaders = { + foo: 'bar', + baz: 'quix', + }; + + const conditionalHeaders = await getConditionalHeaders({ + job: {} as JobDocPayload, + filteredHeaders: permittedHeaders, + server: mockServer, + }); + expect(conditionalHeaders.conditions.basePath).toEqual( - mockConfig.kbnConfig.get('server', 'basePath') + mockServer.config().get('server.basePath') ); }); + + test(`uses protocol from reporting config if set`, async () => { + const settings = { + 'xpack.reporting.kibanaServer.protocol': 'https', + }; + + mockServer = createMockServer({ settings }); + + const permittedHeaders = { + foo: 'bar', + baz: 'quix', + }; + + const conditionalHeaders = await getConditionalHeaders({ + job: {} as JobDocPayload, + filteredHeaders: permittedHeaders, + server: mockServer, + }); + + expect(conditionalHeaders.conditions.protocol).toEqual( + mockServer.config().get('xpack.reporting.kibanaServer.protocol') + ); + }); + + test(`uses protocol from server.info`, async () => { + const permittedHeaders = { + foo: 'bar', + baz: 'quix', + }; + + const conditionalHeaders = await getConditionalHeaders({ + job: {} as JobDocPayload, + filteredHeaders: permittedHeaders, + server: mockServer, + }); + + expect(conditionalHeaders.conditions.protocol).toEqual(mockServer.info.protocol); + }); }); test('uses basePath from job when creating saved object service', async () => { @@ -66,14 +161,14 @@ test('uses basePath from job when creating saved object service', async () => { const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: permittedHeaders, - config: mockConfig, + server: mockServer, }); const jobBasePath = '/sbp/s/marketing'; await getCustomLogo({ reporting: mockReportingPlugin, job: { basePath: jobBasePath } as JobDocPayloadPDF, conditionalHeaders, - config: mockConfig, + server: mockServer, }); const getBasePath = mockGetSavedObjectsClient.mock.calls[0][0].getBasePath; @@ -84,11 +179,6 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav const mockGetSavedObjectsClient = jest.fn(); mockReportingPlugin.getSavedObjectsClient = mockGetSavedObjectsClient; - const mockConfigGet = sinon.stub(); - mockConfigGet.withArgs('kibanaServer', 'hostname').returns('localhost'); - mockConfigGet.withArgs('server', 'basePath').returns('/sbp'); - mockConfig = getMockConfig(mockConfigGet); - const permittedHeaders = { foo: 'bar', baz: 'quix', @@ -96,14 +186,14 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: permittedHeaders, - config: mockConfig, + server: mockServer, }); await getCustomLogo({ reporting: mockReportingPlugin, job: {} as JobDocPayloadPDF, conditionalHeaders, - config: mockConfig, + server: mockServer, }); const getBasePath = mockGetSavedObjectsClient.mock.calls[0][0].getBasePath; @@ -135,26 +225,19 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav describe('config formatting', () => { test(`lowercases server.host`, async () => { - const mockConfigGet = sinon - .stub() - .withArgs('server', 'host') - .returns('COOL-HOSTNAME'); - mockConfig = getMockConfig(mockConfigGet); - + mockServer = createMockServer({ settings: { 'server.host': 'COOL-HOSTNAME' } }); const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: {}, - config: mockConfig, + server: mockServer, }); expect(conditionalHeaders.conditions.hostname).toEqual('cool-hostname'); }); - test(`lowercases kibanaServer.hostname`, async () => { - const mockConfigGet = sinon - .stub() - .withArgs('kibanaServer', 'hostname') - .returns('GREAT-HOSTNAME'); - mockConfig = getMockConfig(mockConfigGet); + test(`lowercases xpack.reporting.kibanaServer.hostname`, async () => { + mockServer = createMockServer({ + settings: { 'xpack.reporting.kibanaServer.hostname': 'GREAT-HOSTNAME' }, + }); const conditionalHeaders = await getConditionalHeaders({ job: { title: 'cool-job-bro', @@ -166,7 +249,7 @@ describe('config formatting', () => { }, }, filteredHeaders: {}, - config: mockConfig, + server: mockServer, }); expect(conditionalHeaders.conditions.hostname).toEqual('great-hostname'); }); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts index bd7999d697ca9..975060a8052f0 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts @@ -3,31 +3,29 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { ReportingConfig } from '../../../server/types'; -import { ConditionalHeaders } from '../../../types'; +import { ConditionalHeaders, ServerFacade } from '../../../types'; export const getConditionalHeaders = ({ - config, + server, job, filteredHeaders, }: { - config: ReportingConfig; + server: ServerFacade; job: JobDocPayloadType; filteredHeaders: Record; }) => { - const { kbnConfig } = config; + const config = server.config(); const [hostname, port, basePath, protocol] = [ - config.get('kibanaServer', 'hostname'), - config.get('kibanaServer', 'port'), - kbnConfig.get('server', 'basePath'), - config.get('kibanaServer', 'protocol'), + config.get('xpack.reporting.kibanaServer.hostname') || config.get('server.host'), + config.get('xpack.reporting.kibanaServer.port') || config.get('server.port'), + config.get('server.basePath'), + config.get('xpack.reporting.kibanaServer.protocol') || server.info.protocol, ] as [string, number, string, string]; const conditionalHeaders: ConditionalHeaders = { headers: filteredHeaders, conditions: { - hostname: hostname ? hostname.toLowerCase() : hostname, + hostname: hostname.toLowerCase(), port, basePath, protocol, diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts index 7c4c889e3e14f..fa53f474dfba7 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts @@ -5,18 +5,16 @@ */ import { ReportingCore } from '../../../server'; -import { createMockReportingCore } from '../../../test_helpers'; +import { createMockReportingCore, createMockServer } from '../../../test_helpers'; +import { ServerFacade } from '../../../types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; import { getConditionalHeaders, getCustomLogo } from './index'; -const mockConfigGet = jest.fn().mockImplementation((key: string) => { - return 'localhost'; -}); -const mockConfig = { get: mockConfigGet, kbnConfig: { get: mockConfigGet } }; - let mockReportingPlugin: ReportingCore; +let mockServer: ServerFacade; beforeEach(async () => { mockReportingPlugin = await createMockReportingCore(); + mockServer = createMockServer(''); }); test(`gets logo from uiSettings`, async () => { @@ -39,14 +37,14 @@ test(`gets logo from uiSettings`, async () => { const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayloadPDF, filteredHeaders: permittedHeaders, - config: mockConfig, + server: mockServer, }); const { logo } = await getCustomLogo({ reporting: mockReportingPlugin, - config: mockConfig, job: {} as JobDocPayloadPDF, conditionalHeaders, + server: mockServer, }); expect(mockGet).toBeCalledWith('xpackReporting:customPdfLogo'); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts index a13f992e7867c..7af5edab41ab7 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts @@ -5,22 +5,23 @@ */ import { UI_SETTINGS_CUSTOM_PDF_LOGO } from '../../../common/constants'; -import { ReportingConfig, ReportingCore } from '../../../server/types'; -import { ConditionalHeaders } from '../../../types'; +import { ReportingCore } from '../../../server'; +import { ConditionalHeaders, ServerFacade } from '../../../types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; // Logo is PDF only export const getCustomLogo = async ({ reporting, - config, + server, job, conditionalHeaders, }: { reporting: ReportingCore; - config: ReportingConfig; + server: ServerFacade; job: JobDocPayloadPDF; conditionalHeaders: ConditionalHeaders; }) => { - const serverBasePath: string = config.kbnConfig.get('server', 'basePath'); + const serverBasePath: string = server.config().get('server.basePath'); + const fakeRequest: any = { headers: conditionalHeaders.headers, // This is used by the spaces SavedObjectClientWrapper to determine the existing space. diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts index 5f55617724ff6..27e772195f726 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts @@ -4,41 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ReportingConfig } from '../../../server'; +import { createMockServer } from '../../../test_helpers'; +import { ServerFacade } from '../../../types'; import { JobDocPayloadPNG } from '../../png/types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; import { getFullUrls } from './get_full_urls'; interface FullUrlsOpts { job: JobDocPayloadPNG & JobDocPayloadPDF; - config: ReportingConfig; + server: ServerFacade; + conditionalHeaders: any; } -let mockConfig: ReportingConfig; -const getMockConfig = (mockConfigGet: jest.Mock) => { - return { - get: mockConfigGet, - kbnConfig: { get: mockConfigGet }, - }; -}; - +let mockServer: any; beforeEach(() => { - const reportingConfig: Record = { - 'kibanaServer.hostname': 'localhost', - 'kibanaServer.port': 5601, - 'kibanaServer.protocol': 'http', - 'server.basePath': '/sbp', - }; - const mockConfigGet = jest.fn().mockImplementation((...keys: string[]) => { - return reportingConfig[keys.join('.') as string]; - }); - mockConfig = getMockConfig(mockConfigGet); + mockServer = createMockServer(''); }); -const getMockJob = (base: object) => base as JobDocPayloadPNG & JobDocPayloadPDF; - test(`fails if no URL is passed`, async () => { - const fn = () => getFullUrls({ job: getMockJob({}), config: mockConfig } as FullUrlsOpts); + const fn = () => + getFullUrls({ + job: {}, + server: mockServer, + } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"No valid URL fields found in Job Params! Expected \`job.relativeUrl: string\` or \`job.relativeUrls: string[]\`"` ); @@ -49,8 +37,8 @@ test(`fails if URLs are file-protocols for PNGs`, async () => { const relativeUrl = 'file://etc/passwd/#/something'; const fn = () => getFullUrls({ - job: getMockJob({ relativeUrl, forceNow }), - config: mockConfig, + job: { relativeUrl, forceNow }, + server: mockServer, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: file://etc/passwd/#/something"` @@ -63,8 +51,8 @@ test(`fails if URLs are absolute for PNGs`, async () => { 'http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something'; const fn = () => getFullUrls({ - job: getMockJob({ relativeUrl, forceNow }), - config: mockConfig, + job: { relativeUrl, forceNow }, + server: mockServer, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something"` @@ -76,11 +64,11 @@ test(`fails if URLs are file-protocols for PDF`, async () => { const relativeUrl = 'file://etc/passwd/#/something'; const fn = () => getFullUrls({ - job: getMockJob({ + job: { relativeUrls: [relativeUrl], forceNow, - }), - config: mockConfig, + }, + server: mockServer, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: file://etc/passwd/#/something"` @@ -93,11 +81,11 @@ test(`fails if URLs are absolute for PDF`, async () => { 'http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something'; const fn = () => getFullUrls({ - job: getMockJob({ + job: { relativeUrls: [relativeUrl], forceNow, - }), - config: mockConfig, + }, + server: mockServer, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something"` @@ -114,8 +102,8 @@ test(`fails if any URLs are absolute or file's for PDF`, async () => { const fn = () => getFullUrls({ - job: getMockJob({ relativeUrls, forceNow }), - config: mockConfig, + job: { relativeUrls, forceNow }, + server: mockServer, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something file://etc/passwd/#/something"` @@ -125,8 +113,8 @@ test(`fails if any URLs are absolute or file's for PDF`, async () => { test(`fails if URL does not route to a visualization`, async () => { const fn = () => getFullUrls({ - job: getMockJob({ relativeUrl: '/app/phoney' }), - config: mockConfig, + job: { relativeUrl: '/app/phoney' }, + server: mockServer, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"No valid hash in the URL! A hash is expected for the application to route to the intended visualization."` @@ -136,8 +124,8 @@ test(`fails if URL does not route to a visualization`, async () => { test(`adds forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const urls = await getFullUrls({ - job: getMockJob({ relativeUrl: '/app/kibana#/something', forceNow }), - config: mockConfig, + job: { relativeUrl: '/app/kibana#/something', forceNow }, + server: mockServer, } as FullUrlsOpts); expect(urls[0]).toEqual( @@ -149,8 +137,8 @@ test(`appends forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const urls = await getFullUrls({ - job: getMockJob({ relativeUrl: '/app/kibana#/something?_g=something', forceNow }), - config: mockConfig, + job: { relativeUrl: '/app/kibana#/something?_g=something', forceNow }, + server: mockServer, } as FullUrlsOpts); expect(urls[0]).toEqual( @@ -160,8 +148,8 @@ test(`appends forceNow to hash's query, if it exists`, async () => { test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { const urls = await getFullUrls({ - job: getMockJob({ relativeUrl: '/app/kibana#/something' }), - config: mockConfig, + job: { relativeUrl: '/app/kibana#/something' }, + server: mockServer, } as FullUrlsOpts); expect(urls[0]).toEqual('http://localhost:5601/sbp/app/kibana#/something'); @@ -170,7 +158,7 @@ test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { test(`adds forceNow to each of multiple urls`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const urls = await getFullUrls({ - job: getMockJob({ + job: { relativeUrls: [ '/app/kibana#/something_aaa', '/app/kibana#/something_bbb', @@ -178,8 +166,8 @@ test(`adds forceNow to each of multiple urls`, async () => { '/app/kibana#/something_ddd', ], forceNow, - }), - config: mockConfig, + }, + server: mockServer, } as FullUrlsOpts); expect(urls).toEqual([ diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts index c4b6f31019fdf..ca64d8632dbfe 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts @@ -12,7 +12,7 @@ import { } from 'url'; import { getAbsoluteUrlFactory } from '../../../common/get_absolute_url'; import { validateUrls } from '../../../common/validate_urls'; -import { ReportingConfig } from '../../../server/types'; +import { ServerFacade } from '../../../types'; import { JobDocPayloadPNG } from '../../png/types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; @@ -24,23 +24,19 @@ function isPdfJob(job: JobDocPayloadPNG | JobDocPayloadPDF): job is JobDocPayloa } export function getFullUrls({ - config, + server, job, }: { - config: ReportingConfig; + server: ServerFacade; job: JobDocPayloadPDF | JobDocPayloadPNG; }) { - const [basePath, protocol, hostname, port] = [ - config.kbnConfig.get('server', 'basePath'), - config.get('kibanaServer', 'protocol'), - config.get('kibanaServer', 'hostname'), - config.get('kibanaServer', 'port'), - ] as string[]; + const config = server.config(); + const getAbsoluteUrl = getAbsoluteUrlFactory({ - defaultBasePath: basePath, - protocol, - hostname, - port, + defaultBasePath: config.get('server.basePath'), + protocol: config.get('xpack.reporting.kibanaServer.protocol') || server.info.protocol, + hostname: config.get('xpack.reporting.kibanaServer.hostname') || config.get('server.host'), + port: config.get('xpack.reporting.kibanaServer.port') || config.get('server.port'), }); // PDF and PNG job params put in the url differently diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts index 07fceb603e451..0cb83352d4606 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts @@ -3,18 +3,17 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { CaptureConfig } from '../../../server/types'; +import { ServerFacade } from '../../../types'; import { LayoutTypes } from '../constants'; import { Layout, LayoutParams } from './layout'; import { PreserveLayout } from './preserve_layout'; import { PrintLayout } from './print_layout'; -export function createLayout(captureConfig: CaptureConfig, layoutParams?: LayoutParams): Layout { +export function createLayout(server: ServerFacade, layoutParams?: LayoutParams): Layout { if (layoutParams && layoutParams.id === LayoutTypes.PRESERVE_LAYOUT) { return new PreserveLayout(layoutParams.dimensions); } // this is the default because some jobs won't have anything specified - return new PrintLayout(captureConfig); + return new PrintLayout(server); } diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts index 98d8dc2983653..6007c2960057a 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts @@ -3,12 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import path from 'path'; import { EvaluateFn, SerializableOrJSHandle } from 'puppeteer'; -import { HeadlessChromiumDriver } from '../../../server/browsers'; import { LevelLogger } from '../../../server/lib'; -import { ReportingConfigType } from '../../../server/core'; +import { HeadlessChromiumDriver } from '../../../server/browsers'; +import { ServerFacade } from '../../../types'; import { LayoutTypes } from '../constants'; import { getDefaultLayoutSelectors, Layout, LayoutSelectorDictionary, Size } from './layout'; import { CaptureConfig } from './types'; @@ -21,9 +20,9 @@ export class PrintLayout extends Layout { public readonly groupCount = 2; private captureConfig: CaptureConfig; - constructor(captureConfig: ReportingConfigType['capture']) { + constructor(server: ServerFacade) { super(LayoutTypes.PRINT); - this.captureConfig = captureConfig; + this.captureConfig = server.config().get('xpack.reporting.capture'); } public getCssOverridesPath() { diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts index 57d025890d3e2..16eb433e8a75e 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts @@ -7,16 +7,17 @@ import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; -import { CaptureConfig } from '../../../../server/types'; +import { ServerFacade } from '../../../../types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_GETNUMBEROFITEMS, CONTEXT_READMETADATA } from './constants'; export const getNumberOfItems = async ( - captureConfig: CaptureConfig, + server: ServerFacade, browser: HeadlessBrowser, layout: LayoutInstance, logger: LevelLogger ): Promise => { + const config = server.config(); const { renderComplete: renderCompleteSelector, itemsCountAttribute } = layout.selectors; let itemsCount: number; @@ -32,7 +33,7 @@ export const getNumberOfItems = async ( // we have to use this hint to wait for all of them await browser.waitForSelector( `${renderCompleteSelector},[${itemsCountAttribute}]`, - { timeout: captureConfig.timeouts.waitForElements }, + { timeout: config.get('xpack.reporting.capture.timeouts.waitForElements') }, { context: CONTEXT_READMETADATA }, logger ); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts index 75ac3dca4ffa0..13d07bcdd6baf 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts @@ -19,9 +19,12 @@ import * as Rx from 'rxjs'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { loggingServiceMock } from '../../../../../../../../src/core/server/mocks'; import { LevelLogger } from '../../../../server/lib'; -import { createMockBrowserDriverFactory, createMockLayoutInstance } from '../../../../test_helpers'; +import { + createMockBrowserDriverFactory, + createMockLayoutInstance, + createMockServer, +} from '../../../../test_helpers'; import { ConditionalHeaders, HeadlessChromiumDriver } from '../../../../types'; -import { CaptureConfig } from '../../../../server/types'; import { screenshotsObservableFactory } from './observable'; import { ElementsPositionAndAttribute } from './types'; @@ -31,8 +34,8 @@ import { ElementsPositionAndAttribute } from './types'; const mockLogger = jest.fn(loggingServiceMock.create); const logger = new LevelLogger(mockLogger()); -const mockConfig = { timeouts: { openUrl: 13 } } as CaptureConfig; -const mockLayout = createMockLayoutInstance(mockConfig); +const __LEGACY = createMockServer({ settings: { 'xpack.reporting.capture': { loadDelay: 13 } } }); +const mockLayout = createMockLayoutInstance(__LEGACY); /* * Tests @@ -45,7 +48,7 @@ describe('Screenshot Observable Pipeline', () => { }); it('pipelines a single url into screenshot and timeRange', async () => { - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); const result = await getScreenshots$({ logger, urls: ['/welcome/home/start/index.htm'], @@ -83,7 +86,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); const result = await getScreenshots$({ logger, urls: ['/welcome/home/start/index2.htm', '/welcome/home/start/index.php3?page=./home.php'], @@ -133,7 +136,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); const getScreenshot = async () => { return await getScreenshots$({ logger, @@ -194,7 +197,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); const getScreenshot = async () => { return await getScreenshots$({ logger, diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts index 53a11c18abd79..44c04c763f840 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts @@ -6,22 +6,24 @@ import * as Rx from 'rxjs'; import { catchError, concatMap, first, mergeMap, take, takeUntil, toArray } from 'rxjs/operators'; -import { CaptureConfig } from '../../../../server/types'; -import { HeadlessChromiumDriverFactory } from '../../../../types'; +import { CaptureConfig, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; import { getElementPositionAndAttributes } from './get_element_position_data'; import { getNumberOfItems } from './get_number_of_items'; import { getScreenshots } from './get_screenshots'; import { getTimeRange } from './get_time_range'; -import { injectCustomCss } from './inject_css'; import { openUrl } from './open_url'; import { ScreenSetupData, ScreenshotObservableOpts, ScreenshotResults } from './types'; import { waitForRenderComplete } from './wait_for_render'; import { waitForVisualizations } from './wait_for_visualizations'; +import { injectCustomCss } from './inject_css'; export function screenshotsObservableFactory( - captureConfig: CaptureConfig, + server: ServerFacade, browserDriverFactory: HeadlessChromiumDriverFactory ) { + const config = server.config(); + const captureConfig: CaptureConfig = config.get('xpack.reporting.capture'); + return function screenshotsObservable({ logger, urls, @@ -39,13 +41,13 @@ export function screenshotsObservableFactory( mergeMap(({ driver, exit$ }) => { const setup$: Rx.Observable = Rx.of(1).pipe( takeUntil(exit$), - mergeMap(() => openUrl(captureConfig, driver, url, conditionalHeaders, logger)), - mergeMap(() => getNumberOfItems(captureConfig, driver, layout, logger)), + mergeMap(() => openUrl(server, driver, url, conditionalHeaders, logger)), + mergeMap(() => getNumberOfItems(server, driver, layout, logger)), mergeMap(async itemsCount => { const viewport = layout.getViewport(itemsCount); await Promise.all([ driver.setViewport(viewport, logger), - waitForVisualizations(captureConfig, driver, itemsCount, layout, logger), + waitForVisualizations(server, driver, itemsCount, layout, logger), ]); }), mergeMap(async () => { @@ -58,7 +60,7 @@ export function screenshotsObservableFactory( await layout.positionElements(driver, logger); } - await waitForRenderComplete(captureConfig, driver, layout, logger); + await waitForRenderComplete(driver, layout, captureConfig, logger); }), mergeMap(async () => { return await Promise.all([ diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts index a484dfb243563..fbae1f91a7a6a 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts @@ -5,26 +5,27 @@ */ import { i18n } from '@kbn/i18n'; -import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; +import { ConditionalHeaders, ServerFacade } from '../../../../types'; import { LevelLogger } from '../../../../server/lib'; -import { CaptureConfig } from '../../../../server/types'; -import { ConditionalHeaders } from '../../../../types'; +import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { PAGELOAD_SELECTOR } from '../../constants'; export const openUrl = async ( - captureConfig: CaptureConfig, + server: ServerFacade, browser: HeadlessBrowser, url: string, conditionalHeaders: ConditionalHeaders, logger: LevelLogger ): Promise => { + const config = server.config(); + try { await browser.open( url, { conditionalHeaders, waitForSelector: PAGELOAD_SELECTOR, - timeout: captureConfig.timeouts.openUrl, + timeout: config.get('xpack.reporting.capture.timeouts.openUrl'), }, logger ); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts index 76613c2d631d6..ab81a952f345c 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ElementPosition, ConditionalHeaders } from '../../../../types'; import { LevelLogger } from '../../../../server/lib'; -import { ConditionalHeaders, ElementPosition } from '../../../../types'; import { LayoutInstance } from '../../layouts/layout'; export interface ScreenshotObservableOpts { diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts index 069896c8d9e90..2f6dc2829dfd8 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts @@ -5,16 +5,16 @@ */ import { i18n } from '@kbn/i18n'; +import { CaptureConfig } from '../../../../types'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; -import { CaptureConfig } from '../../../../server/types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_WAITFORRENDER } from './constants'; export const waitForRenderComplete = async ( - captureConfig: CaptureConfig, browser: HeadlessBrowser, layout: LayoutInstance, + captureConfig: CaptureConfig, logger: LevelLogger ) => { logger.debug( diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts index 7960e1552e559..93ad40026dff8 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts @@ -5,9 +5,9 @@ */ import { i18n } from '@kbn/i18n'; +import { ServerFacade } from '../../../../types'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; -import { CaptureConfig } from '../../../../server/types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants'; @@ -23,12 +23,13 @@ const getCompletedItemsCount = ({ renderCompleteSelector }: SelectorArgs) => { * 3. Wait for the render complete event to be fired once for each item */ export const waitForVisualizations = async ( - captureConfig: CaptureConfig, + server: ServerFacade, browser: HeadlessBrowser, itemsCount: number, layout: LayoutInstance, logger: LevelLogger ): Promise => { + const config = server.config(); const { renderComplete: renderCompleteSelector } = layout.selectors; logger.debug( @@ -44,7 +45,7 @@ export const waitForVisualizations = async ( fn: getCompletedItemsCount, args: [{ renderCompleteSelector }], toEqual: itemsCount, - timeout: captureConfig.timeouts.renderComplete, + timeout: config.get('xpack.reporting.capture.timeouts.renderComplete'), }, { context: CONTEXT_WAITFORELEMENTSTOBEINDOM }, logger diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts index b87403ac74f89..7ea67277015ab 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts @@ -11,14 +11,14 @@ import { CreateJobFactory, ESQueueCreateJobFn, RequestFacade, + ServerFacade, } from '../../../types'; import { JobParamsDiscoverCsv } from '../types'; export const createJobFactory: CreateJobFactory> = async function createJobFactoryFn(reporting: ReportingCore) { - const config = await reporting.getConfig(); - const crypto = cryptoFactory(config.get('encryptionKey')); +>> = function createJobFactoryFn(reporting: ReportingCore, server: ServerFacade) { + const crypto = cryptoFactory(server); return async function createJob( jobParams: JobParamsDiscoverCsv, diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js index 7dfa705901fbe..f12916b734dbf 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js @@ -36,12 +36,11 @@ describe('CSV Execute Job', function() { let defaultElasticsearchResponse; let encryptedHeaders; - let clusterStub; - let configGetStub; - let mockReportingConfig; + let cancellationToken; let mockReportingPlugin; + let mockServer; + let clusterStub; let callAsCurrentUserStub; - let cancellationToken; const mockElasticsearch = { dataClient: { @@ -59,17 +58,7 @@ describe('CSV Execute Job', function() { beforeEach(async function() { mockReportingPlugin = await createMockReportingCore(); - - configGetStub = sinon.stub(); - configGetStub.withArgs('encryptionKey').returns(encryptionKey); - configGetStub.withArgs('csv', 'maxSizeBytes').returns(1024 * 1000); // 1mB - configGetStub.withArgs('csv', 'scroll').returns({}); - mockReportingConfig = { get: configGetStub, kbnConfig: { get: configGetStub } }; - - mockReportingPlugin.getConfig = () => Promise.resolve(mockReportingConfig); - mockReportingPlugin.getUiSettingsServiceFactory = () => Promise.resolve(mockUiSettingsClient); - mockReportingPlugin.getElasticsearchService = () => Promise.resolve(mockElasticsearch); - + mockReportingPlugin.getUiSettingsServiceFactory = () => mockUiSettingsClient; cancellationToken = new CancellationToken(); defaultElasticsearchResponse = { @@ -86,6 +75,7 @@ describe('CSV Execute Job', function() { .stub(clusterStub, 'callAsCurrentUser') .resolves(defaultElasticsearchResponse); + const configGetStub = sinon.stub(); mockUiSettingsClient.get.withArgs('csv:separator').returns(','); mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(true); @@ -103,11 +93,36 @@ describe('CSV Execute Job', function() { return fieldFormatsRegistry; }, }); + + mockServer = { + config: function() { + return { + get: configGetStub, + }; + }, + }; + mockServer + .config() + .get.withArgs('xpack.reporting.encryptionKey') + .returns(encryptionKey); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.maxSizeBytes') + .returns(1024 * 1000); // 1mB + mockServer + .config() + .get.withArgs('xpack.reporting.csv.scroll') + .returns({}); }); describe('basic Elasticsearch call behavior', function() { it('should decrypt encrypted headers and pass to callAsCurrentUser', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -123,7 +138,12 @@ describe('CSV Execute Job', function() { testBody: true, }; - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const job = { headers: encryptedHeaders, fields: [], @@ -150,7 +170,12 @@ describe('CSV Execute Job', function() { _scroll_id: scrollId, }); callAsCurrentUserStub.onSecondCall().resolves(defaultElasticsearchResponse); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -164,7 +189,12 @@ describe('CSV Execute Job', function() { }); it('should not execute scroll if there are no hits from the search', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -194,7 +224,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -229,7 +264,12 @@ describe('CSV Execute Job', function() { _scroll_id: lastScrollId, }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -257,7 +297,12 @@ describe('CSV Execute Job', function() { _scroll_id: lastScrollId, }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -276,7 +321,10 @@ describe('CSV Execute Job', function() { describe('Cells with formula values', () => { it('returns `csv_contains_formulas` when cells contain formulas', async function() { - configGetStub.withArgs('csv', 'checkForFormulas').returns(true); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.checkForFormulas') + .returns(true); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { one: '=SUM(A1:A2)', two: 'bar' } }], @@ -284,7 +332,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -301,7 +354,10 @@ describe('CSV Execute Job', function() { }); it('returns warnings when headings contain formulas', async function() { - configGetStub.withArgs('csv', 'checkForFormulas').returns(true); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.checkForFormulas') + .returns(true); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { '=SUM(A1:A2)': 'foo', two: 'bar' } }], @@ -309,7 +365,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['=SUM(A1:A2)', 'two'], @@ -326,7 +387,10 @@ describe('CSV Execute Job', function() { }); it('returns no warnings when cells have no formulas', async function() { - configGetStub.withArgs('csv', 'checkForFormulas').returns(true); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.checkForFormulas') + .returns(true); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -334,7 +398,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -351,7 +420,10 @@ describe('CSV Execute Job', function() { }); it('returns no warnings when configured not to', async () => { - configGetStub.withArgs('csv', 'checkForFormulas').returns(false); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.checkForFormulas') + .returns(false); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { one: '=SUM(A1:A2)', two: 'bar' } }], @@ -359,7 +431,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -379,7 +456,12 @@ describe('CSV Execute Job', function() { describe('Elasticsearch call errors', function() { it('should reject Promise if search call errors out', async function() { callAsCurrentUserStub.rejects(new Error()); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: [], @@ -398,7 +480,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); callAsCurrentUserStub.onSecondCall().rejects(new Error()); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: [], @@ -419,7 +506,12 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: [], @@ -440,7 +532,12 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: [], @@ -468,7 +565,12 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: [], @@ -496,7 +598,12 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: [], @@ -532,7 +639,12 @@ describe('CSV Execute Job', function() { }); it('should stop calling Elasticsearch when cancellationToken.cancel is called', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); executeJob( 'job345', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -547,7 +659,12 @@ describe('CSV Execute Job', function() { }); it(`shouldn't call clearScroll if it never got a scrollId`, async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); executeJob( 'job345', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -561,7 +678,12 @@ describe('CSV Execute Job', function() { }); it('should call clearScroll if it got a scrollId', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); executeJob( 'job345', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -579,7 +701,12 @@ describe('CSV Execute Job', function() { describe('csv content', function() { it('should write column headers to output, even if there are no results', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -591,7 +718,12 @@ describe('CSV Execute Job', function() { it('should use custom uiSettings csv:separator for header', async function() { mockUiSettingsClient.get.withArgs('csv:separator').returns(';'); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -603,7 +735,12 @@ describe('CSV Execute Job', function() { it('should escape column headers if uiSettings csv:quoteValues is true', async function() { mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(true); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one and a half', 'two', 'three-and-four', 'five & six'], @@ -615,7 +752,12 @@ describe('CSV Execute Job', function() { it(`shouldn't escape column headers if uiSettings csv:quoteValues is false`, async function() { mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(false); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one and a half', 'two', 'three-and-four', 'five & six'], @@ -626,7 +768,12 @@ describe('CSV Execute Job', function() { }); it('should write column headers to output, when there are results', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ one: '1', two: '2' }], @@ -646,7 +793,12 @@ describe('CSV Execute Job', function() { }); it('should use comma separated values of non-nested fields from _source', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -667,7 +819,12 @@ describe('CSV Execute Job', function() { }); it('should concatenate the hits from multiple responses', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -695,7 +852,12 @@ describe('CSV Execute Job', function() { }); it('should use field formatters to format fields', async function() { - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -735,9 +897,17 @@ describe('CSV Execute Job', function() { let maxSizeReached; beforeEach(async function() { - configGetStub.withArgs('csv', 'maxSizeBytes').returns(1); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.maxSizeBytes') + .returns(1); + + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -765,9 +935,17 @@ describe('CSV Execute Job', function() { let maxSizeReached; beforeEach(async function() { - configGetStub.withArgs('csv', 'maxSizeBytes').returns(9); - - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.maxSizeBytes') + .returns(9); + + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -795,7 +973,10 @@ describe('CSV Execute Job', function() { let maxSizeReached; beforeEach(async function() { - configGetStub.withArgs('csv', 'maxSizeBytes').returns(9); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.maxSizeBytes') + .returns(9); callAsCurrentUserStub.onFirstCall().returns({ hits: { @@ -804,7 +985,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -834,7 +1020,10 @@ describe('CSV Execute Job', function() { beforeEach(async function() { mockReportingPlugin.getUiSettingsServiceFactory = () => mockUiSettingsClient; - configGetStub.withArgs('csv', 'maxSizeBytes').returns(18); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.maxSizeBytes') + .returns(18); callAsCurrentUserStub.onFirstCall().returns({ hits: { @@ -843,7 +1032,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -871,7 +1065,10 @@ describe('CSV Execute Job', function() { describe('scroll settings', function() { it('passes scroll duration to initial search call', async function() { const scrollDuration = 'test'; - configGetStub.withArgs('csv', 'scroll').returns({ duration: scrollDuration }); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.scroll') + .returns({ duration: scrollDuration }); callAsCurrentUserStub.onFirstCall().returns({ hits: { @@ -880,7 +1077,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -897,7 +1099,10 @@ describe('CSV Execute Job', function() { it('passes scroll size to initial search call', async function() { const scrollSize = 100; - configGetStub.withArgs('csv', 'scroll').returns({ size: scrollSize }); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.scroll') + .returns({ size: scrollSize }); callAsCurrentUserStub.onFirstCall().resolves({ hits: { @@ -906,7 +1111,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -923,7 +1133,10 @@ describe('CSV Execute Job', function() { it('passes scroll duration to subsequent scroll call', async function() { const scrollDuration = 'test'; - configGetStub.withArgs('csv', 'scroll').returns({ duration: scrollDuration }); + mockServer + .config() + .get.withArgs('xpack.reporting.csv.scroll') + .returns({ duration: scrollDuration }); callAsCurrentUserStub.onFirstCall().resolves({ hits: { @@ -932,7 +1145,12 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); + const executeJob = await executeJobFactory( + mockReportingPlugin, + mockServer, + mockElasticsearch, + mockLogger + ); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts index a8249e5810d3c..1579985891053 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts @@ -6,26 +6,32 @@ import { i18n } from '@kbn/i18n'; import Hapi from 'hapi'; -import { IUiSettingsClient, KibanaRequest } from '../../../../../../../src/core/server'; +import { + ElasticsearchServiceSetup, + IUiSettingsClient, + KibanaRequest, +} from '../../../../../../../src/core/server'; import { CSV_JOB_TYPE } from '../../../common/constants'; import { ReportingCore } from '../../../server'; import { cryptoFactory } from '../../../server/lib'; import { getFieldFormats } from '../../../server/services'; -import { ESQueueWorkerExecuteFn, ExecuteJobFactory, Logger } from '../../../types'; +import { ESQueueWorkerExecuteFn, ExecuteJobFactory, Logger, ServerFacade } from '../../../types'; import { JobDocPayloadDiscoverCsv } from '../types'; import { fieldFormatMapFactory } from './lib/field_format_map'; import { createGenerateCsv } from './lib/generate_csv'; export const executeJobFactory: ExecuteJobFactory> = async function executeJobFactoryFn(reporting: ReportingCore, parentLogger: Logger) { - const [config, elasticsearch] = await Promise.all([ - reporting.getConfig(), - reporting.getElasticsearchService(), - ]); - const crypto = cryptoFactory(config.get('encryptionKey')); +>> = async function executeJobFactoryFn( + reporting: ReportingCore, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, + parentLogger: Logger +) { + const crypto = cryptoFactory(server); + const config = server.config(); const logger = parentLogger.clone([CSV_JOB_TYPE, 'execute-job']); - const serverBasePath = config.kbnConfig.get('server', 'basePath'); + const serverBasePath = config.get('server.basePath'); return async function executeJob( jobId: string, @@ -125,9 +131,9 @@ export const executeJobFactory: ExecuteJobFactory) { const response = await request; diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts b/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts index 529c195486bc6..842330fa7c93f 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts @@ -5,8 +5,7 @@ */ import { CancellationToken } from '../../common/cancellation_token'; -import { ScrollConfig } from '../../server/types'; -import { JobDocPayload, JobParamPostPayload } from '../../types'; +import { JobDocPayload, JobParamPostPayload, ConditionalHeaders, RequestFacade } from '../../types'; interface DocValueField { field: string; @@ -107,7 +106,7 @@ export interface GenerateCsvParams { quoteValues: boolean; timezone: string | null; maxSizeBytes: number; - scroll: ScrollConfig; + scroll: { duration: string; size: number }; checkForFormulas?: boolean; }; } diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts index 15a1c3e0a9fad..17072d311b35f 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts @@ -5,11 +5,18 @@ */ import { notFound, notImplemented } from 'boom'; +import { ElasticsearchServiceSetup } from 'kibana/server'; import { get } from 'lodash'; import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../../common/constants'; import { ReportingCore } from '../../../../server'; import { cryptoFactory } from '../../../../server/lib'; -import { CreateJobFactory, ImmediateCreateJobFn, Logger, RequestFacade } from '../../../../types'; +import { + CreateJobFactory, + ImmediateCreateJobFn, + Logger, + RequestFacade, + ServerFacade, +} from '../../../../types'; import { JobDocPayloadPanelCsv, JobParamsPanelCsv, @@ -30,9 +37,13 @@ interface VisData { export const createJobFactory: CreateJobFactory> = async function createJobFactoryFn(reporting: ReportingCore, parentLogger: Logger) { - const config = await reporting.getConfig(); - const crypto = cryptoFactory(config.get('encryptionKey')); +>> = function createJobFactoryFn( + reporting: ReportingCore, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, + parentLogger: Logger +) { + const crypto = cryptoFactory(server); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'create-job']); return async function createJob( diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts index debcdb47919f1..6bb3e73fcfe84 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts @@ -5,6 +5,7 @@ */ import { i18n } from '@kbn/i18n'; +import { ElasticsearchServiceSetup } from 'kibana/server'; import { CONTENT_TYPE_CSV, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; import { ReportingCore } from '../../../server'; import { cryptoFactory } from '../../../server/lib'; @@ -14,6 +15,7 @@ import { JobDocOutput, Logger, RequestFacade, + ServerFacade, } from '../../../types'; import { CsvResultFromSearch } from '../../csv/types'; import { FakeRequest, JobDocPayloadPanelCsv, JobParamsPanelCsv, SearchPanel } from '../types'; @@ -21,11 +23,15 @@ import { createGenerateCsv } from './lib'; export const executeJobFactory: ExecuteJobFactory> = async function executeJobFactoryFn(reporting: ReportingCore, parentLogger: Logger) { - const config = await reporting.getConfig(); - const crypto = cryptoFactory(config.get('encryptionKey')); +>> = async function executeJobFactoryFn( + reporting: ReportingCore, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, + parentLogger: Logger +) { + const crypto = cryptoFactory(server); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'execute-job']); - const generateCsv = await createGenerateCsv(reporting, parentLogger); + const generateCsv = createGenerateCsv(reporting, server, elasticsearch, parentLogger); return async function executeJob( jobId: string | null, @@ -51,11 +57,11 @@ export const executeJobFactory: ExecuteJobFactory; + let decryptedHeaders; const serializedEncryptedHeaders = job.headers; try { decryptedHeaders = await crypto.decrypt(serializedEncryptedHeaders); @@ -73,7 +79,10 @@ export const executeJobFactory: ExecuteJobFactory { export async function generateCsvSearch( req: RequestFacade, reporting: ReportingCore, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, logger: Logger, searchPanel: SearchPanel, jobParams: JobParamsDiscoverCsv @@ -153,15 +159,11 @@ export async function generateCsvSearch( }, }; - const [elasticsearch, config] = await Promise.all([ - reporting.getElasticsearchService(), - reporting.getConfig(), - ]); - const { callAsCurrentUser } = elasticsearch.dataClient.asScoped( KibanaRequest.from(req.getRawRequest()) ); const callCluster = (...params: [string, object]) => callAsCurrentUser(...params); + const config = server.config(); const uiSettings = await getUiSettings(uiConfig); const generateCsvParams: GenerateCsvParams = { @@ -174,8 +176,8 @@ export async function generateCsvSearch( cancellationToken: new CancellationToken(), settings: { ...uiSettings, - maxSizeBytes: config.get('csv', 'maxSizeBytes'), - scroll: config.get('csv', 'scroll'), + maxSizeBytes: config.get('xpack.reporting.csv.maxSizeBytes'), + scroll: config.get('xpack.reporting.csv.scroll'), timezone, }, }; diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts index ab14d2dd8a660..6a7d5f336e238 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { JobDocPayload, JobParamPostPayload } from '../../types'; +import { JobParamPostPayload, JobDocPayload, ServerFacade } from '../../types'; export interface FakeRequest { - headers: Record; + headers: any; + server: ServerFacade; } export interface JobParamsPostPayloadPanelCsv extends JobParamPostPayload { diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts index 9aac612677094..a6911e1f14704 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts @@ -12,14 +12,14 @@ import { CreateJobFactory, ESQueueCreateJobFn, RequestFacade, + ServerFacade, } from '../../../../types'; import { JobParamsPNG } from '../../types'; export const createJobFactory: CreateJobFactory> = async function createJobFactoryFn(reporting: ReportingCore) { - const config = await reporting.getConfig(); - const crypto = cryptoFactory(config.get('encryptionKey')); +>> = function createJobFactoryFn(reporting: ReportingCore, server: ServerFacade) { + const crypto = cryptoFactory(server); return async function createJob( { objectType, title, relativeUrl, browserTimezone, layout }: JobParamsPNG, diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js index 267321d33809d..e2e6ba1b89096 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js @@ -5,6 +5,7 @@ */ import * as Rx from 'rxjs'; +import { memoize } from 'lodash'; import { createMockReportingCore } from '../../../../test_helpers'; import { cryptoFactory } from '../../../../server/lib/crypto'; import { executeJobFactory } from './index'; @@ -13,70 +14,63 @@ import { LevelLogger } from '../../../../server/lib'; jest.mock('../lib/generate_png', () => ({ generatePngObservableFactory: jest.fn() })); -let mockReporting; -let mockReportingConfig; - const cancellationToken = { on: jest.fn(), }; -const mockLoggerFactory = { - get: jest.fn().mockImplementation(() => ({ - error: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - })), -}; -const getMockLogger = () => new LevelLogger(mockLoggerFactory); - -const mockEncryptionKey = 'abcabcsecuresecret'; -const encryptHeaders = async headers => { - const crypto = cryptoFactory(mockEncryptionKey); - return await crypto.encrypt(headers); -}; +let config; +let mockServer; +let mockReporting; beforeEach(async () => { mockReporting = await createMockReportingCore(); - const kbnConfig = { + config = { + 'xpack.reporting.encryptionKey': 'testencryptionkey', 'server.basePath': '/sbp', + 'server.host': 'localhost', + 'server.port': 5601, }; - const reportingConfig = { - encryptionKey: mockEncryptionKey, - 'kibanaServer.hostname': 'localhost', - 'kibanaServer.port': 5601, - 'kibanaServer.protocol': 'http', - }; - - const mockGetConfig = jest.fn(); - mockReportingConfig = { - get: (...keys) => reportingConfig[keys.join('.')], - kbnConfig: { get: (...keys) => kbnConfig[keys.join('.')] }, - }; - mockGetConfig.mockImplementation(() => Promise.resolve(mockReportingConfig)); - mockReporting.getConfig = mockGetConfig; - - const mockElasticsearch = { - dataClient: { - asScoped: () => ({ callAsCurrentUser: jest.fn() }), + mockServer = { + config: memoize(() => ({ get: jest.fn() })), + info: { + protocol: 'http', }, }; - const mockGetElasticsearch = jest.fn(); - mockGetElasticsearch.mockImplementation(() => Promise.resolve(mockElasticsearch)); - mockReporting.getElasticsearchService = mockGetElasticsearch; + mockServer.config().get.mockImplementation(key => { + return config[key]; + }); generatePngObservableFactory.mockReturnValue(jest.fn()); }); afterEach(() => generatePngObservableFactory.mockReset()); +const mockElasticsearch = { + dataClient: { + asScoped: () => ({ callAsCurrentUser: jest.fn() }), + }, +}; + +const getMockLogger = () => new LevelLogger(); + +const encryptHeaders = async headers => { + const crypto = cryptoFactory(mockServer); + return await crypto.encrypt(headers); +}; + test(`passes browserTimezone to generatePng`, async () => { const encryptedHeaders = await encryptHeaders({}); const generatePngObservable = generatePngObservableFactory(); generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); - const executeJob = await executeJobFactory(mockReporting, getMockLogger()); + const executeJob = await executeJobFactory( + mockReporting, + mockServer, + mockElasticsearch, + getMockLogger() + ); const browserTimezone = 'UTC'; await executeJob( 'pngJobId', @@ -94,7 +88,15 @@ test(`passes browserTimezone to generatePng`, async () => { }); test(`returns content_type of application/png`, async () => { - const executeJob = await executeJobFactory(mockReporting, getMockLogger()); + const executeJob = await executeJobFactory( + mockReporting, + mockServer, + mockElasticsearch, + getMockLogger(), + { + browserDriverFactory: {}, + } + ); const encryptedHeaders = await encryptHeaders({}); const generatePngObservable = generatePngObservableFactory(); @@ -114,7 +116,15 @@ test(`returns content of generatePng getBuffer base64 encoded`, async () => { const generatePngObservable = generatePngObservableFactory(); generatePngObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); - const executeJob = await executeJobFactory(mockReporting, getMockLogger()); + const executeJob = await executeJobFactory( + mockReporting, + mockServer, + mockElasticsearch, + getMockLogger(), + { + browserDriverFactory: {}, + } + ); const encryptedHeaders = await encryptHeaders({}); const { content } = await executeJob( 'pngJobId', diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts index c53c20efec247..8670f0027af89 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts @@ -4,11 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ElasticsearchServiceSetup } from 'kibana/server'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PNG_JOB_TYPE } from '../../../../common/constants'; import { ReportingCore } from '../../../../server'; -import { ESQueueWorkerExecuteFn, ExecuteJobFactory, JobDocOutput, Logger } from '../../../../types'; +import { + ESQueueWorkerExecuteFn, + ExecuteJobFactory, + JobDocOutput, + Logger, + ServerFacade, +} from '../../../../types'; import { decryptJobHeaders, getConditionalHeaders, @@ -22,24 +29,22 @@ type QueuedPngExecutorFactory = ExecuteJobFactory = Rx.of(1).pipe( - mergeMap(() => decryptJobHeaders({ encryptionKey, job, logger })), + mergeMap(() => decryptJobHeaders({ server, job, logger })), map(decryptedHeaders => omitBlacklistedHeaders({ job, decryptedHeaders })), - map(filteredHeaders => getConditionalHeaders({ config, job, filteredHeaders })), + map(filteredHeaders => getConditionalHeaders({ server, job, filteredHeaders })), mergeMap(conditionalHeaders => { - const urls = getFullUrls({ config, job }); + const urls = getFullUrls({ server, job }); const hashUrl = urls[0]; return generatePngObservable( jobLogger, diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts index a15541d99f6fb..88e91982adc63 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts @@ -7,18 +7,17 @@ import * as Rx from 'rxjs'; import { map } from 'rxjs/operators'; import { LevelLogger } from '../../../../server/lib'; -import { CaptureConfig } from '../../../../server/types'; -import { ConditionalHeaders, HeadlessChromiumDriverFactory } from '../../../../types'; +import { ConditionalHeaders, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; import { LayoutParams } from '../../../common/layouts/layout'; import { PreserveLayout } from '../../../common/layouts/preserve_layout'; import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; import { ScreenshotResults } from '../../../common/lib/screenshots/types'; export function generatePngObservableFactory( - captureConfig: CaptureConfig, + server: ServerFacade, browserDriverFactory: HeadlessChromiumDriverFactory ) { - const screenshotsObservable = screenshotsObservableFactory(captureConfig, browserDriverFactory); + const screenshotsObservable = screenshotsObservableFactory(server, browserDriverFactory); return function generatePngObservable( logger: LevelLogger, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts index 8e1d5404a5984..656c99991e1f6 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts @@ -12,14 +12,14 @@ import { CreateJobFactory, ESQueueCreateJobFn, RequestFacade, + ServerFacade, } from '../../../../types'; import { JobParamsPDF } from '../../types'; export const createJobFactory: CreateJobFactory> = async function createJobFactoryFn(reporting: ReportingCore) { - const config = await reporting.getConfig(); - const crypto = cryptoFactory(config.get('encryptionKey')); +>> = function createJobFactoryFn(reporting: ReportingCore, server: ServerFacade) { + const crypto = cryptoFactory(server); return async function createJobFn( { title, relativeUrls, browserTimezone, layout, objectType }: JobParamsPDF, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js index 29769108bf4ac..484842ba18f2a 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js @@ -5,6 +5,7 @@ */ import * as Rx from 'rxjs'; +import { memoize } from 'lodash'; import { createMockReportingCore } from '../../../../test_helpers'; import { cryptoFactory } from '../../../../server/lib/crypto'; import { executeJobFactory } from './index'; @@ -13,65 +14,57 @@ import { LevelLogger } from '../../../../server/lib'; jest.mock('../lib/generate_pdf', () => ({ generatePdfObservableFactory: jest.fn() })); -let mockReporting; -let mockReportingConfig; - const cancellationToken = { on: jest.fn(), }; -const mockLoggerFactory = { - get: jest.fn().mockImplementation(() => ({ - error: jest.fn(), - debug: jest.fn(), - warn: jest.fn(), - })), -}; -const getMockLogger = () => new LevelLogger(mockLoggerFactory); - -const mockEncryptionKey = 'testencryptionkey'; -const encryptHeaders = async headers => { - const crypto = cryptoFactory(mockEncryptionKey); - return await crypto.encrypt(headers); -}; +let config; +let mockServer; +let mockReporting; beforeEach(async () => { mockReporting = await createMockReportingCore(); - const kbnConfig = { + config = { + 'xpack.reporting.encryptionKey': 'testencryptionkey', 'server.basePath': '/sbp', + 'server.host': 'localhost', + 'server.port': 5601, }; - const reportingConfig = { - encryptionKey: mockEncryptionKey, - 'kibanaServer.hostname': 'localhost', - 'kibanaServer.port': 5601, - 'kibanaServer.protocol': 'http', - }; - - const mockGetConfig = jest.fn(); - mockReportingConfig = { - get: (...keys) => reportingConfig[keys.join('.')], - kbnConfig: { get: (...keys) => kbnConfig[keys.join('.')] }, - }; - mockGetConfig.mockImplementation(() => Promise.resolve(mockReportingConfig)); - mockReporting.getConfig = mockGetConfig; - - const mockElasticsearch = { - dataClient: { - asScoped: () => ({ callAsCurrentUser: jest.fn() }), + mockServer = { + config: memoize(() => ({ get: jest.fn() })), + info: { + protocol: 'http', }, }; - const mockGetElasticsearch = jest.fn(); - mockGetElasticsearch.mockImplementation(() => Promise.resolve(mockElasticsearch)); - mockReporting.getElasticsearchService = mockGetElasticsearch; + mockServer.config().get.mockImplementation(key => { + return config[key]; + }); generatePdfObservableFactory.mockReturnValue(jest.fn()); }); afterEach(() => generatePdfObservableFactory.mockReset()); +const getMockLogger = () => new LevelLogger(); +const mockElasticsearch = { + dataClient: { + asScoped: () => ({ callAsCurrentUser: jest.fn() }), + }, +}; + +const encryptHeaders = async headers => { + const crypto = cryptoFactory(mockServer); + return await crypto.encrypt(headers); +}; + test(`returns content_type of application/pdf`, async () => { - const executeJob = await executeJobFactory(mockReporting, getMockLogger()); + const executeJob = await executeJobFactory( + mockReporting, + mockServer, + mockElasticsearch, + getMockLogger() + ); const encryptedHeaders = await encryptHeaders({}); const generatePdfObservable = generatePdfObservableFactory(); @@ -91,7 +84,12 @@ test(`returns content of generatePdf getBuffer base64 encoded`, async () => { const generatePdfObservable = generatePdfObservableFactory(); generatePdfObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); - const executeJob = await executeJobFactory(mockReporting, getMockLogger()); + const executeJob = await executeJobFactory( + mockReporting, + mockServer, + mockElasticsearch, + getMockLogger() + ); const encryptedHeaders = await encryptHeaders({}); const { content } = await executeJob( 'pdfJobId', diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts index e614db46c5730..535c2dcd439a7 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts @@ -4,11 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ElasticsearchServiceSetup } from 'kibana/server'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PDF_JOB_TYPE } from '../../../../common/constants'; import { ReportingCore } from '../../../../server'; -import { ESQueueWorkerExecuteFn, ExecuteJobFactory, JobDocOutput, Logger } from '../../../../types'; +import { + ESQueueWorkerExecuteFn, + ExecuteJobFactory, + JobDocOutput, + Logger, + ServerFacade, +} from '../../../../types'; import { decryptJobHeaders, getConditionalHeaders, @@ -23,25 +30,23 @@ type QueuedPdfExecutorFactory = ExecuteJobFactory = Rx.of(1).pipe( - mergeMap(() => decryptJobHeaders({ encryptionKey, job, logger })), + mergeMap(() => decryptJobHeaders({ server, job, logger })), map(decryptedHeaders => omitBlacklistedHeaders({ job, decryptedHeaders })), - map(filteredHeaders => getConditionalHeaders({ config, job, filteredHeaders })), - mergeMap(conditionalHeaders => getCustomLogo({ reporting, config, job, conditionalHeaders })), + map(filteredHeaders => getConditionalHeaders({ server, job, filteredHeaders })), + mergeMap(conditionalHeaders => getCustomLogo({ reporting, server, job, conditionalHeaders })), mergeMap(({ logo, conditionalHeaders }) => { - const urls = getFullUrls({ config, job }); + const urls = getFullUrls({ server, job }); const { browserTimezone, layout, title } = job; return generatePdfObservable( diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts index 7021fae983aa2..d78effaa1fc2f 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts @@ -8,8 +8,7 @@ import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; import { mergeMap } from 'rxjs/operators'; import { LevelLogger } from '../../../../server/lib'; -import { ReportingConfigType } from '../../../../server/core'; -import { ConditionalHeaders, HeadlessChromiumDriverFactory } from '../../../../types'; +import { ConditionalHeaders, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; import { createLayout } from '../../../common/layouts'; import { LayoutInstance, LayoutParams } from '../../../common/layouts/layout'; import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; @@ -28,10 +27,10 @@ const getTimeRange = (urlScreenshots: ScreenshotResults[]) => { }; export function generatePdfObservableFactory( - captureConfig: ReportingConfigType['capture'], + server: ServerFacade, browserDriverFactory: HeadlessChromiumDriverFactory ) { - const screenshotsObservable = screenshotsObservableFactory(captureConfig, browserDriverFactory); + const screenshotsObservable = screenshotsObservableFactory(server, browserDriverFactory); return function generatePdfObservable( logger: LevelLogger, @@ -42,7 +41,7 @@ export function generatePdfObservableFactory( layoutParams: LayoutParams, logo?: string ): Rx.Observable<{ buffer: Buffer; warnings: string[] }> { - const layout = createLayout(captureConfig, layoutParams) as LayoutInstance; + const layout = createLayout(server, layoutParams) as LayoutInstance; const screenshots$ = screenshotsObservable({ logger, urls, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts index e8dd3c5207d92..0a9dcfe986ca6 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { JobDocPayload } from '../../types'; import { LayoutInstance, LayoutParams } from '../common/layouts/layout'; +import { JobDocPayload, ServerFacade, RequestFacade } from '../../types'; // Job params: structure of incoming user request data, after being parsed from RISON export interface JobParamsPDF { diff --git a/x-pack/legacy/plugins/reporting/index.test.js b/x-pack/legacy/plugins/reporting/index.test.js new file mode 100644 index 0000000000000..0d9a717bd7d81 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/index.test.js @@ -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 { reporting } from './index'; +import { getConfigSchema } from '../../../test_utils'; + +// The snapshot records the number of cpus available +// to make the snapshot deterministic `os.cpus` needs to be mocked +// but the other members on `os` must remain untouched +jest.mock('os', () => { + const os = jest.requireActual('os'); + os.cpus = () => [{}, {}, {}, {}]; + return os; +}); + +// eslint-disable-next-line jest/valid-describe +const describeWithContext = describe.each([ + [{ dev: false, dist: false }], + [{ dev: true, dist: false }], + [{ dev: false, dist: true }], + [{ dev: true, dist: true }], +]); + +describeWithContext('config schema with context %j', context => { + it('produces correct config', async () => { + const schema = await getConfigSchema(reporting); + const value = await schema.validate({}, { context }); + value.capture.browser.chromium.disableSandbox = ''; + await expect(value).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/reporting/index.ts b/x-pack/legacy/plugins/reporting/index.ts index fb95e2c2edc24..89e98302cddc9 100644 --- a/x-pack/legacy/plugins/reporting/index.ts +++ b/x-pack/legacy/plugins/reporting/index.ts @@ -8,16 +8,21 @@ import { i18n } from '@kbn/i18n'; import { Legacy } from 'kibana'; import { resolve } from 'path'; import { PLUGIN_ID, UI_SETTINGS_CUSTOM_PDF_LOGO } from './common/constants'; +import { config as reportingConfig } from './config'; import { legacyInit } from './server/legacy'; import { ReportingPluginSpecOptions } from './types'; -const kbToBase64Length = (kb: number) => Math.floor((kb * 1024 * 8) / 6); +const kbToBase64Length = (kb: number) => { + return Math.floor((kb * 1024 * 8) / 6); +}; export const reporting = (kibana: any) => { return new kibana.Plugin({ id: PLUGIN_ID, + configPrefix: 'xpack.reporting', publicDir: resolve(__dirname, 'public'), require: ['kibana', 'elasticsearch', 'xpack_main'], + config: reportingConfig, uiExports: { uiSettingDefaults: { @@ -44,5 +49,14 @@ export const reporting = (kibana: any) => { async init(server: Legacy.Server) { return legacyInit(server, this); }, + + deprecations({ unused }: any) { + return [ + unused('capture.concurrency'), + unused('capture.timeout'), + unused('capture.settleTime'), + unused('kibanaApp'), + ]; + }, } as ReportingPluginSpecOptions); }; diff --git a/x-pack/legacy/plugins/reporting/log_configuration.ts b/x-pack/legacy/plugins/reporting/log_configuration.ts index 7aaed2038bd52..b07475df6304f 100644 --- a/x-pack/legacy/plugins/reporting/log_configuration.ts +++ b/x-pack/legacy/plugins/reporting/log_configuration.ts @@ -6,23 +6,22 @@ import getosSync, { LinuxOs } from 'getos'; import { promisify } from 'util'; -import { BROWSER_TYPE } from './common/constants'; -import { CaptureConfig } from './server/types'; -import { Logger } from './types'; +import { ServerFacade, Logger } from './types'; const getos = promisify(getosSync); -export async function logConfiguration(captureConfig: CaptureConfig, logger: Logger) { - const { - browser: { - type: browserType, - chromium: { disableSandbox }, - }, - } = captureConfig; +export async function logConfiguration(server: ServerFacade, logger: Logger) { + const config = server.config(); + const browserType = config.get('xpack.reporting.capture.browser.type'); logger.debug(`Browser type: ${browserType}`); - if (browserType === BROWSER_TYPE) { - logger.debug(`Chromium sandbox disabled: ${disableSandbox}`); + + if (browserType === 'chromium') { + logger.debug( + `Chromium sandbox disabled: ${config.get( + 'xpack.reporting.capture.browser.chromium.disableSandbox' + )}` + ); } const os = await getos(); diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts index a2f7a1f3ad0da..dc79a6b9db2c1 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts @@ -4,14 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CaptureConfig } from '../../../../server/types'; - -type ViewportConfig = CaptureConfig['viewport']; -type BrowserConfig = CaptureConfig['browser']['chromium']; +import { BrowserConfig } from '../../../../types'; interface LaunchArgs { userDataDir: BrowserConfig['userDataDir']; - viewport: ViewportConfig; + viewport: BrowserConfig['viewport']; disableSandbox: BrowserConfig['disableSandbox']; proxy: BrowserConfig['proxy']; } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index cb228150efbcd..f90f2c7aee395 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -19,8 +19,7 @@ import { import * as Rx from 'rxjs'; import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber'; import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators'; -import { BROWSER_TYPE } from '../../../../common/constants'; -import { CaptureConfig } from '../../../../server/types'; +import { BrowserConfig, CaptureConfig } from '../../../../types'; import { LevelLogger as Logger } from '../../../lib/level_logger'; import { safeChildProcess } from '../../safe_child_process'; import { HeadlessChromiumDriver } from '../driver'; @@ -29,8 +28,7 @@ import { puppeteerLaunch } from '../puppeteer'; import { args } from './args'; type binaryPath = string; -type BrowserConfig = CaptureConfig['browser']['chromium']; -type ViewportConfig = CaptureConfig['viewport']; +type ViewportConfig = BrowserConfig['viewport']; export class HeadlessChromiumDriverFactory { private binaryPath: binaryPath; @@ -39,10 +37,15 @@ export class HeadlessChromiumDriverFactory { private userDataDir: string; private getChromiumArgs: (viewport: ViewportConfig) => string[]; - constructor(binaryPath: binaryPath, logger: Logger, captureConfig: CaptureConfig) { + constructor( + binaryPath: binaryPath, + logger: Logger, + browserConfig: BrowserConfig, + captureConfig: CaptureConfig + ) { this.binaryPath = binaryPath; + this.browserConfig = browserConfig; this.captureConfig = captureConfig; - this.browserConfig = captureConfig.browser.chromium; this.userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chromium-')); this.getChromiumArgs = (viewport: ViewportConfig) => @@ -54,7 +57,7 @@ export class HeadlessChromiumDriverFactory { }); } - type = BROWSER_TYPE; + type = 'chromium'; test(logger: Logger) { const chromiumArgs = args({ @@ -150,7 +153,7 @@ export class HeadlessChromiumDriverFactory { // HeadlessChromiumDriver: object to "drive" a browser page const driver = new HeadlessChromiumDriver(page, { - inspect: !!this.browserConfig.inspect, + inspect: this.browserConfig.inspect, networkPolicy: this.captureConfig.networkPolicy, }); diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts index 5f89662c94da2..d32338ae3e311 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CaptureConfig } from '../../../server/types'; +import { BrowserConfig, CaptureConfig } from '../../../types'; import { LevelLogger } from '../../lib'; import { HeadlessChromiumDriverFactory } from './driver_factory'; @@ -13,7 +13,8 @@ export { paths } from './paths'; export async function createDriverFactory( binaryPath: string, logger: LevelLogger, + browserConfig: BrowserConfig, captureConfig: CaptureConfig ): Promise { - return new HeadlessChromiumDriverFactory(binaryPath, logger, captureConfig); + return new HeadlessChromiumDriverFactory(binaryPath, logger, browserConfig, captureConfig); } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts b/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts index af3b86919dc50..49c6222c9f276 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts @@ -4,22 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Logger } from '../../types'; -import { ReportingConfig } from '../types'; -import { HeadlessChromiumDriverFactory } from './chromium/driver_factory'; import { ensureBrowserDownloaded } from './download'; -import { chromium } from './index'; import { installBrowser } from './install'; +import { ServerFacade, CaptureConfig, Logger } from '../../types'; +import { BROWSER_TYPE } from '../../common/constants'; +import { chromium } from './index'; +import { HeadlessChromiumDriverFactory } from './chromium/driver_factory'; export async function createBrowserDriverFactory( - config: ReportingConfig, + server: ServerFacade, logger: Logger ): Promise { - const captureConfig = config.get('capture'); - const browserConfig = captureConfig.browser.chromium; - const browserAutoDownload = captureConfig.browser.autoDownload; + const config = server.config(); + + const dataDir: string = config.get('path.data'); + const captureConfig: CaptureConfig = config.get('xpack.reporting.capture'); const browserType = captureConfig.browser.type; - const dataDir = config.kbnConfig.get('path', 'data'); + const browserAutoDownload = captureConfig.browser.autoDownload; + const browserConfig = captureConfig.browser[BROWSER_TYPE]; if (browserConfig.disableSandbox) { logger.warning(`Enabling the Chromium sandbox provides an additional layer of protection.`); @@ -30,7 +32,7 @@ export async function createBrowserDriverFactory( try { const { binaryPath } = await installBrowser(logger, chromium, dataDir); - return chromium.createDriverFactory(binaryPath, logger, captureConfig); + return chromium.createDriverFactory(binaryPath, logger, browserConfig, captureConfig); } catch (error) { if (error.cause && ['EACCES', 'EEXIST'].includes(error.cause.code)) { logger.error( diff --git a/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts b/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts index 3697c4b86ce3c..73186966e3d2f 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts @@ -4,15 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { existsSync } from 'fs'; import { resolve as resolvePath } from 'path'; -import { BROWSER_TYPE } from '../../../common/constants'; +import { existsSync } from 'fs'; + import { chromium } from '../index'; -import { BrowserDownload } from '../types'; +import { BrowserDownload, BrowserType } from '../types'; + import { md5 } from './checksum'; -import { clean } from './clean'; -import { download } from './download'; import { asyncMap } from './util'; +import { download } from './download'; +import { clean } from './clean'; /** * Check for the downloaded archive of each requested browser type and @@ -20,7 +21,7 @@ import { asyncMap } from './util'; * @param {String} browserType * @return {Promise} */ -export async function ensureBrowserDownloaded(browserType = BROWSER_TYPE) { +export async function ensureBrowserDownloaded(browserType: BrowserType) { await ensureDownloaded([chromium]); } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts b/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts index 9714c5965a5db..b36345c08bfee 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts @@ -6,7 +6,12 @@ import * as _ from 'lodash'; import { parse } from 'url'; -import { NetworkPolicyRule } from '../../types'; + +interface FirewallRule { + allow: boolean; + host?: string; + protocol?: string; +} const isHostMatch = (actualHost: string, ruleHost: string) => { const hostParts = actualHost.split('.').reverse(); @@ -15,7 +20,7 @@ const isHostMatch = (actualHost: string, ruleHost: string) => { return _.every(ruleParts, (part, idx) => part === hostParts[idx]); }; -export const allowRequest = (url: string, rules: NetworkPolicyRule[]) => { +export const allowRequest = (url: string, rules: FirewallRule[]) => { const parsed = parse(url); if (!rules.length) { diff --git a/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts b/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts index f096073ec2f5f..0c480fc82752b 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +export type BrowserType = 'chromium'; + export interface BrowserDownload { paths: { archivesPath: string; diff --git a/x-pack/legacy/plugins/reporting/server/config/config.js b/x-pack/legacy/plugins/reporting/server/config/config.js new file mode 100644 index 0000000000000..08e4db464b003 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/config/config.js @@ -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 { cpus } from 'os'; + +const defaultCPUCount = 2; + +function cpuCount() { + try { + return cpus().length; + } catch (e) { + return defaultCPUCount; + } +} + +export const config = { + concurrency: cpuCount(), +}; diff --git a/x-pack/legacy/plugins/reporting/server/core.ts b/x-pack/legacy/plugins/reporting/server/core.ts index c233a63833950..4506d41e4f5c3 100644 --- a/x-pack/legacy/plugins/reporting/server/core.ts +++ b/x-pack/legacy/plugins/reporting/server/core.ts @@ -7,14 +7,12 @@ import * as Rx from 'rxjs'; import { first, mapTo } from 'rxjs/operators'; import { - ElasticsearchServiceSetup, IUiSettingsClient, KibanaRequest, SavedObjectsClient, SavedObjectsServiceStart, UiSettingsServiceStart, } from 'src/core/server'; -import { ConfigType as ReportingConfigType } from '../../../../plugins/reporting/server'; // @ts-ignore no module definition import { mirrorPluginStatus } from '../../../server/lib/mirror_plugin_status'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; @@ -27,63 +25,14 @@ import { ReportingSetupDeps } from './types'; interface ReportingInternalSetup { browserDriverFactory: HeadlessChromiumDriverFactory; - config: ReportingConfig; - elasticsearch: ElasticsearchServiceSetup; } interface ReportingInternalStart { - enqueueJob: EnqueueJobFn; - esqueue: ESQueueInstance; savedObjects: SavedObjectsServiceStart; uiSettings: UiSettingsServiceStart; + esqueue: ESQueueInstance; + enqueueJob: EnqueueJobFn; } -// make config.get() aware of the value type it returns -interface Config { - get(key1: Key1): BaseType[Key1]; - get( - key1: Key1, - key2: Key2 - ): BaseType[Key1][Key2]; - get< - Key1 extends keyof BaseType, - Key2 extends keyof BaseType[Key1], - Key3 extends keyof BaseType[Key1][Key2] - >( - key1: Key1, - key2: Key2, - key3: Key3 - ): BaseType[Key1][Key2][Key3]; - get< - Key1 extends keyof BaseType, - Key2 extends keyof BaseType[Key1], - Key3 extends keyof BaseType[Key1][Key2], - Key4 extends keyof BaseType[Key1][Key2][Key3] - >( - key1: Key1, - key2: Key2, - key3: Key3, - key4: Key4 - ): BaseType[Key1][Key2][Key3][Key4]; -} - -interface KbnServerConfigType { - path: { data: string }; - server: { - basePath: string; - host: string; - name: string; - port: number; - protocol: string; - uuid: string; - }; -} - -export interface ReportingConfig extends Config { - kbnConfig: Config; -} - -export { ReportingConfigType }; - export class ReportingCore { private pluginSetupDeps?: ReportingInternalSetup; private pluginStartDeps?: ReportingInternalStart; @@ -96,7 +45,6 @@ export class ReportingCore { legacySetup( xpackMainPlugin: XPackMainPlugin, reporting: ReportingPluginSpecOptions, - config: ReportingConfig, __LEGACY: ServerFacade, plugins: ReportingSetupDeps ) { @@ -108,7 +56,7 @@ export class ReportingCore { xpackMainPlugin.info.feature(PLUGIN_ID).registerLicenseCheckResultsGenerator(checkLicense); }); // Reporting routes - registerRoutes(this, config, __LEGACY, plugins, this.logger); + registerRoutes(this, __LEGACY, plugins, this.logger); } public pluginSetup(reportingSetupDeps: ReportingInternalSetup) { @@ -142,31 +90,23 @@ export class ReportingCore { return (await this.getPluginSetupDeps()).browserDriverFactory; } - public async getConfig(): Promise { - return (await this.getPluginSetupDeps()).config; - } - /* - * Outside dependencies + * Kibana core module dependencies */ - private async getPluginSetupDeps(): Promise { + private async getPluginSetupDeps() { if (this.pluginSetupDeps) { return this.pluginSetupDeps; } return await this.pluginSetup$.pipe(first()).toPromise(); } - private async getPluginStartDeps(): Promise { + private async getPluginStartDeps() { if (this.pluginStartDeps) { return this.pluginStartDeps; } return await this.pluginStart$.pipe(first()).toPromise(); } - public async getElasticsearchService(): Promise { - return (await this.getPluginSetupDeps()).elasticsearch; - } - public async getSavedObjectsClient(fakeRequest: KibanaRequest): Promise { const { savedObjects } = await this.getPluginStartDeps(); return savedObjects.getScopedClient(fakeRequest) as SavedObjectsClient; diff --git a/x-pack/legacy/plugins/reporting/server/index.ts b/x-pack/legacy/plugins/reporting/server/index.ts index efcfd6b7f783d..24e2a954415d9 100644 --- a/x-pack/legacy/plugins/reporting/server/index.ts +++ b/x-pack/legacy/plugins/reporting/server/index.ts @@ -11,5 +11,5 @@ export const plugin = (context: PluginInitializerContext) => { return new Plugin(context); }; +export { ReportingCore } from './core'; export { ReportingPlugin } from './plugin'; -export { ReportingConfig, ReportingCore } from './core'; diff --git a/x-pack/legacy/plugins/reporting/server/legacy.ts b/x-pack/legacy/plugins/reporting/server/legacy.ts index 29e5af529767e..336ff5f4d2ee7 100644 --- a/x-pack/legacy/plugins/reporting/server/legacy.ts +++ b/x-pack/legacy/plugins/reporting/server/legacy.ts @@ -4,75 +4,35 @@ * you may not use this file except in compliance with the Elastic License. */ import { Legacy } from 'kibana'; -import { get } from 'lodash'; -import { take } from 'rxjs/operators'; -import { CoreSetup, PluginInitializerContext } from 'src/core/server'; -import { ConfigType, PluginsSetup } from '../../../../plugins/reporting/server'; +import { PluginInitializerContext } from 'src/core/server'; import { SecurityPluginSetup } from '../../../../plugins/security/server'; import { ReportingPluginSpecOptions } from '../types'; import { plugin } from './index'; -import { LegacySetup, ReportingConfig, ReportingStartDeps } from './types'; +import { LegacySetup, ReportingStartDeps } from './types'; const buildLegacyDependencies = ( - coreSetup: CoreSetup, server: Legacy.Server, reportingPlugin: ReportingPluginSpecOptions -): LegacySetup => { - return { - route: server.route.bind(server), - plugins: { - xpack_main: server.plugins.xpack_main, - reporting: reportingPlugin, - }, - }; -}; - -const buildConfig = ( - coreSetup: CoreSetup, - server: Legacy.Server, - reportingConfig: ConfigType -): ReportingConfig => { - const config = server.config(); - const { http } = coreSetup; - const serverInfo = http.getServerInfo(); - - const kbnConfig = { - path: { - data: config.get('path.data'), // FIXME: get from the real PluginInitializerContext - }, - server: { - basePath: coreSetup.http.basePath.serverBasePath, - host: serverInfo.host, - name: serverInfo.name, - port: serverInfo.port, - uuid: coreSetup.uuid.getInstanceUuid(), - protocol: serverInfo.protocol, - }, - }; - - // spreading arguments as an array allows the return type to be known by the compiler - return { - get: (...keys: string[]) => get(reportingConfig, keys.join('.'), null), - kbnConfig: { - get: (...keys: string[]) => get(kbnConfig, keys.join('.'), null), - }, - }; -}; +): LegacySetup => ({ + config: server.config, + info: server.info, + route: server.route.bind(server), + plugins: { + elasticsearch: server.plugins.elasticsearch, + xpack_main: server.plugins.xpack_main, + reporting: reportingPlugin, + }, +}); export const legacyInit = async ( server: Legacy.Server, - reportingLegacyPlugin: ReportingPluginSpecOptions + reportingPlugin: ReportingPluginSpecOptions ) => { - const { core: coreSetup } = server.newPlatform.setup; - const { config$ } = (server.newPlatform.setup.plugins.reporting as PluginsSetup).__legacy; - const reportingConfig = await config$.pipe(take(1)).toPromise(); - const reporting = { config: buildConfig(coreSetup, server, reportingConfig) }; - - const __LEGACY = buildLegacyDependencies(coreSetup, server, reportingLegacyPlugin); + const coreSetup = server.newPlatform.setup.core; + const pluginInstance = plugin(server.newPlatform.coreContext as PluginInitializerContext); - const pluginInstance = plugin(server.newPlatform.coreContext as PluginInitializerContext); // NOTE: mocked-out PluginInitializerContext + const __LEGACY = buildLegacyDependencies(server, reportingPlugin); await pluginInstance.setup(coreSetup, { - reporting, elasticsearch: coreSetup.elasticsearch, security: server.newPlatform.setup.plugins.security as SecurityPluginSetup, usageCollection: server.newPlatform.setup.plugins.usageCollection, @@ -82,6 +42,7 @@ export const legacyInit = async ( // Schedule to call the "start" hook only after start dependencies are ready coreSetup.getStartServices().then(([core, plugins]) => pluginInstance.start(core, { + elasticsearch: coreSetup.elasticsearch, data: (plugins as ReportingStartDeps).data, __LEGACY, }) diff --git a/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts b/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts index a05205526dd3e..d593e4625cdf4 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts @@ -4,24 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ESQueueInstance, Logger } from '../../types'; +import { ElasticsearchServiceSetup } from 'kibana/server'; +import { ESQueueInstance, ServerFacade, QueueConfig, Logger } from '../../types'; import { ReportingCore } from '../core'; -import { createTaggedLogger } from './create_tagged_logger'; // TODO remove createTaggedLogger once esqueue is removed -import { createWorkerFactory } from './create_worker'; // @ts-ignore import { Esqueue } from './esqueue'; +import { createWorkerFactory } from './create_worker'; +import { createTaggedLogger } from './create_tagged_logger'; // TODO remove createTaggedLogger once esqueue is removed export async function createQueueFactory( reporting: ReportingCore, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, logger: Logger ): Promise { - const [config, elasticsearch] = await Promise.all([ - reporting.getConfig(), - reporting.getElasticsearchService(), - ]); - - const queueConfig = config.get('queue'); - const index = config.get('index'); + const queueConfig: QueueConfig = server.config().get('xpack.reporting.queue'); + const index = server.config().get('xpack.reporting.index'); const queueOptions = { interval: queueConfig.indexInterval, @@ -35,7 +33,7 @@ export async function createQueueFactory( if (queueConfig.pollEnabled) { // create workers to poll the index for idle jobs waiting to be claimed and executed - const createWorker = await createWorkerFactory(reporting, config, logger); + const createWorker = createWorkerFactory(reporting, server, elasticsearch, logger); await createWorker(queue); } else { logger.info( diff --git a/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts b/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts index 01a937a49873a..d4d913243e18d 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ElasticsearchServiceSetup } from 'kibana/server'; import * as sinon from 'sinon'; -import { ReportingConfig, ReportingCore } from '../../server/types'; +import { ReportingCore } from '../../server'; import { createMockReportingCore } from '../../test_helpers'; +import { ServerFacade } from '../../types'; import { createWorkerFactory } from './create_worker'; // @ts-ignore import { Esqueue } from './esqueue'; @@ -15,15 +17,21 @@ import { ClientMock } from './esqueue/__tests__/fixtures/legacy_elasticsearch'; import { ExportTypesRegistry } from './export_types_registry'; const configGetStub = sinon.stub(); -configGetStub.withArgs('queue').returns({ +configGetStub.withArgs('xpack.reporting.queue').returns({ pollInterval: 3300, pollIntervalErrorMultiplier: 10, }); -configGetStub.withArgs('server', 'name').returns('test-server-123'); -configGetStub.withArgs('server', 'uuid').returns('g9ymiujthvy6v8yrh7567g6fwzgzftzfr'); +configGetStub.withArgs('server.name').returns('test-server-123'); +configGetStub.withArgs('server.uuid').returns('g9ymiujthvy6v8yrh7567g6fwzgzftzfr'); const executeJobFactoryStub = sinon.stub(); -const getMockLogger = sinon.stub(); + +const getMockServer = (): ServerFacade => { + return ({ + config: () => ({ get: configGetStub }), + } as unknown) as ServerFacade; +}; +const getMockLogger = jest.fn(); const getMockExportTypesRegistry = ( exportTypes: any[] = [{ executeJobFactory: executeJobFactoryStub }] @@ -33,23 +41,25 @@ const getMockExportTypesRegistry = ( } as ExportTypesRegistry); describe('Create Worker', () => { - let mockReporting: ReportingCore; - let mockConfig: ReportingConfig; let queue: Esqueue; let client: ClientMock; + let mockReporting: ReportingCore; beforeEach(async () => { mockReporting = await createMockReportingCore(); - mockReporting.getExportTypesRegistry = () => getMockExportTypesRegistry(); - mockConfig = { get: configGetStub, kbnConfig: { get: configGetStub } }; - mockReporting.getConfig = () => Promise.resolve(mockConfig); client = new ClientMock(); queue = new Esqueue('reporting-queue', { client }); executeJobFactoryStub.reset(); }); test('Creates a single Esqueue worker for Reporting', async () => { - const createWorker = await createWorkerFactory(mockReporting, mockConfig, getMockLogger()); + mockReporting.getExportTypesRegistry = () => getMockExportTypesRegistry(); + const createWorker = createWorkerFactory( + mockReporting, + getMockServer(), + {} as ElasticsearchServiceSetup, + getMockLogger() + ); const registerWorkerSpy = sinon.spy(queue, 'registerWorker'); await createWorker(queue); @@ -81,7 +91,12 @@ Object { { executeJobFactory: executeJobFactoryStub }, ]); mockReporting.getExportTypesRegistry = () => exportTypesRegistry; - const createWorker = await createWorkerFactory(mockReporting, mockConfig, getMockLogger()); + const createWorker = createWorkerFactory( + mockReporting, + getMockServer(), + {} as ElasticsearchServiceSetup, + getMockLogger() + ); const registerWorkerSpy = sinon.spy(queue, 'registerWorker'); await createWorker(queue); diff --git a/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts b/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts index e9d0acf29c721..3567712367608 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ElasticsearchServiceSetup } from 'kibana/server'; import { CancellationToken } from '../../common/cancellation_token'; import { PLUGIN_ID } from '../../common/constants'; -import { ReportingConfig } from '../../server/types'; import { ESQueueInstance, ESQueueWorkerExecuteFn, @@ -15,22 +15,25 @@ import { JobDocPayload, JobSource, Logger, + QueueConfig, RequestFacade, + ServerFacade, } from '../../types'; import { ReportingCore } from '../core'; // @ts-ignore untyped dependency import { events as esqueueEvents } from './esqueue'; -export async function createWorkerFactory( +export function createWorkerFactory( reporting: ReportingCore, - config: ReportingConfig, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, logger: Logger ) { type JobDocPayloadType = JobDocPayload; - - const queueConfig = config.get('queue'); - const kibanaName = config.kbnConfig.get('server', 'name'); - const kibanaId = config.kbnConfig.get('server', 'uuid'); + const config = server.config(); + const queueConfig: QueueConfig = config.get('xpack.reporting.queue'); + const kibanaName: string = config.get('server.name'); + const kibanaId: string = config.get('server.uuid'); // Once more document types are added, this will need to be passed in return async function createWorker(queue: ESQueueInstance) { @@ -44,7 +47,12 @@ export async function createWorkerFactory( ExportTypeDefinition >) { // TODO: the executeJobFn should be unwrapped in the register method of the export types registry - const jobExecutor = await exportType.executeJobFactory(reporting, logger); + const jobExecutor = await exportType.executeJobFactory( + reporting, + server, + elasticsearch, + logger + ); jobExecutors.set(exportType.jobType, jobExecutor); } diff --git a/x-pack/legacy/plugins/reporting/server/lib/crypto.ts b/x-pack/legacy/plugins/reporting/server/lib/crypto.ts index 97876529ecfa7..dbc01fc947f8b 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/crypto.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/crypto.ts @@ -5,7 +5,12 @@ */ import nodeCrypto from '@elastic/node-crypto'; +import { oncePerServer } from './once_per_server'; +import { ServerFacade } from '../../types'; -export function cryptoFactory(encryptionKey: string | undefined) { +function cryptoFn(server: ServerFacade) { + const encryptionKey = server.config().get('xpack.reporting.encryptionKey'); return nodeCrypto({ encryptionKey }); } + +export const cryptoFactory = oncePerServer(cryptoFn); diff --git a/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts b/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts index bc4754b02ed57..c215bdc398904 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts @@ -5,18 +5,22 @@ */ import { get } from 'lodash'; +import { ElasticsearchServiceSetup } from 'kibana/server'; +// @ts-ignore +import { events as esqueueEvents } from './esqueue'; import { - ConditionalHeaders, EnqueueJobFn, ESQueueCreateJobFn, ImmediateCreateJobFn, Job, - Logger, + ServerFacade, RequestFacade, + Logger, + CaptureConfig, + QueueConfig, + ConditionalHeaders, } from '../../types'; import { ReportingCore } from '../core'; -// @ts-ignore -import { events as esqueueEvents } from './esqueue'; interface ConfirmedJob { id: string; @@ -25,16 +29,18 @@ interface ConfirmedJob { _primary_term: number; } -export async function enqueueJobFactory( +export function enqueueJobFactory( reporting: ReportingCore, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, parentLogger: Logger -): Promise { - const config = await reporting.getConfig(); +): EnqueueJobFn { const logger = parentLogger.clone(['queue-job']); - const captureConfig = config.get('capture'); + const config = server.config(); + const captureConfig: CaptureConfig = config.get('xpack.reporting.capture'); const browserType = captureConfig.browser.type; const maxAttempts = captureConfig.maxAttempts; - const queueConfig = config.get('queue'); + const queueConfig: QueueConfig = config.get('xpack.reporting.queue'); return async function enqueueJob( exportTypeId: string, @@ -53,7 +59,12 @@ export async function enqueueJobFactory( } // TODO: the createJobFn should be unwrapped in the register method of the export types registry - const createJob = (await exportType.createJobFactory(reporting, logger)) as CreateJobFn; + const createJob = exportType.createJobFactory( + reporting, + server, + elasticsearch, + logger + ) as CreateJobFn; const payload = await createJob(jobParams, headers, request); const options = { diff --git a/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js b/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js index 8e4047e2f22e5..6cdbe8f968f75 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js +++ b/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js @@ -8,7 +8,6 @@ import moment from 'moment'; export const intervals = ['year', 'month', 'week', 'day', 'hour', 'minute']; -// TODO: remove this helper by using `schema.duration` objects in the reporting config schema export function indexTimestamp(intervalStr, separator = '-') { if (separator.match(/[a-z]/i)) throw new Error('Interval separator can not be a letter'); diff --git a/x-pack/legacy/plugins/reporting/server/lib/get_user.ts b/x-pack/legacy/plugins/reporting/server/lib/get_user.ts index 5e73fe77ecb79..49d5c568c3981 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/get_user.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/get_user.ts @@ -6,10 +6,10 @@ import { Legacy } from 'kibana'; import { KibanaRequest } from '../../../../../../src/core/server'; -import { Logger } from '../../types'; +import { ServerFacade } from '../../types'; import { ReportingSetupDeps } from '../types'; -export function getUserFactory(security: ReportingSetupDeps['security'], logger: Logger) { +export function getUserFactory(server: ServerFacade, security: ReportingSetupDeps['security']) { /* * Legacy.Request because this is called from routing middleware */ diff --git a/x-pack/legacy/plugins/reporting/server/lib/index.ts b/x-pack/legacy/plugins/reporting/server/lib/index.ts index f5ccbe493a91f..0a2db749cb954 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/index.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/index.ts @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -export { checkLicenseFactory } from './check_license'; -export { createQueueFactory } from './create_queue'; -export { cryptoFactory } from './crypto'; -export { enqueueJobFactory } from './enqueue_job'; export { getExportTypesRegistry } from './export_types_registry'; +export { checkLicenseFactory } from './check_license'; export { LevelLogger } from './level_logger'; +export { cryptoFactory } from './crypto'; +export { oncePerServer } from './once_per_server'; export { runValidations } from './validate'; +export { createQueueFactory } from './create_queue'; +export { enqueueJobFactory } from './enqueue_job'; diff --git a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts index 0affc111c1368..c01e6377b039e 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts @@ -9,8 +9,7 @@ import Boom from 'boom'; import { errors as elasticsearchErrors } from 'elasticsearch'; import { ElasticsearchServiceSetup } from 'kibana/server'; import { get } from 'lodash'; -import { JobSource } from '../../types'; -import { ReportingConfig } from '../types'; +import { JobSource, ServerFacade } from '../../types'; const esErrors = elasticsearchErrors as Record; const defaultSize = 10; @@ -40,11 +39,8 @@ interface CountAggResult { count: number; } -export function jobsQueryFactory( - config: ReportingConfig, - elasticsearch: ElasticsearchServiceSetup -) { - const index = config.get('index'); +export function jobsQueryFactory(server: ServerFacade, elasticsearch: ElasticsearchServiceSetup) { + const index = server.config().get('xpack.reporting.index'); const { callAsInternalUser } = elasticsearch.adminClient; function getUsername(user: any) { diff --git a/x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts b/x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts new file mode 100644 index 0000000000000..ae3636079a9bb --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { memoize, MemoizedFunction } from 'lodash'; +import { ServerFacade } from '../../types'; + +type ServerFn = (server: ServerFacade) => any; +type Memo = ((server: ServerFacade) => any) & MemoizedFunction; + +/** + * allow this function to be called multiple times, but + * ensure that it only received one argument, the server, + * and cache the return value so that subsequent calls get + * the exact same value. + * + * This is intended to be used by service factories like getObjectQueueFactory + * + * @param {Function} fn - the factory function + * @return {any} + */ +export function oncePerServer(fn: ServerFn) { + const memoized: Memo = memoize(function(server: ServerFacade) { + if (arguments.length !== 1) { + throw new TypeError('This function expects to be called with a single argument'); + } + + // @ts-ignore + return fn.call(this, server); + }); + + // @ts-ignore + // Type 'WeakMap' is not assignable to type 'MapCache + + // use a weak map a the cache so that: + // 1. return values mapped to the actual server instance + // 2. return value lifecycle matches that of the server + memoized.cache = new WeakMap(); + + return memoized; +} diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js new file mode 100644 index 0000000000000..10980f702d849 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js @@ -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 expect from '@kbn/expect'; +import sinon from 'sinon'; +import { validateEncryptionKey } from '../validate_encryption_key'; + +describe('Reporting: Validate config', () => { + const logger = { + warning: sinon.spy(), + }; + + beforeEach(() => { + logger.warning.resetHistory(); + }); + + [undefined, null].forEach(value => { + it(`should log a warning and set xpack.reporting.encryptionKey if encryptionKey is ${value}`, () => { + const config = { + get: sinon.stub().returns(value), + set: sinon.stub(), + }; + + expect(() => validateEncryptionKey({ config: () => config }, logger)).not.to.throwError(); + + sinon.assert.calledWith(config.set, 'xpack.reporting.encryptionKey'); + sinon.assert.calledWithMatch(logger.warning, /Generating a random key/); + sinon.assert.calledWithMatch(logger.warning, /please set xpack.reporting.encryptionKey/); + }); + }); +}); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts new file mode 100644 index 0000000000000..04f998fd3e5a5 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.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 expect from '@kbn/expect'; +import sinon from 'sinon'; +import { ServerFacade } from '../../../../types'; +import { validateServerHost } from '../validate_server_host'; + +const configKey = 'xpack.reporting.kibanaServer.hostname'; + +describe('Reporting: Validate server host setting', () => { + it(`should log a warning and set ${configKey} if server.host is "0"`, () => { + const getStub = sinon.stub(); + getStub.withArgs('server.host').returns('0'); + getStub.withArgs(configKey).returns(undefined); + const config = { + get: getStub, + set: sinon.stub(), + }; + + expect(() => + validateServerHost(({ config: () => config } as unknown) as ServerFacade) + ).to.throwError(); + + sinon.assert.calledWith(config.set, configKey); + }); +}); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts index 85d9f727d7fa7..0fdbd858b8e3c 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts @@ -6,22 +6,25 @@ import { i18n } from '@kbn/i18n'; import { ElasticsearchServiceSetup } from 'kibana/server'; -import { Logger } from '../../../types'; +import { Logger, ServerFacade } from '../../../types'; import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_factory'; -import { ReportingConfig } from '../../types'; import { validateBrowser } from './validate_browser'; +import { validateEncryptionKey } from './validate_encryption_key'; import { validateMaxContentLength } from './validate_max_content_length'; +import { validateServerHost } from './validate_server_host'; export async function runValidations( - config: ReportingConfig, + server: ServerFacade, elasticsearch: ElasticsearchServiceSetup, browserFactory: HeadlessChromiumDriverFactory, logger: Logger ) { try { await Promise.all([ - validateBrowser(browserFactory, logger), - validateMaxContentLength(config, elasticsearch, logger), + validateBrowser(server, browserFactory, logger), + validateEncryptionKey(server, logger), + validateMaxContentLength(server, elasticsearch, logger), + validateServerHost(server), ]); logger.debug( i18n.translate('xpack.reporting.selfCheck.ok', { diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts index d6512d5eb718b..89c49123e85bf 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts @@ -3,10 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { Browser } from 'puppeteer'; import { BROWSER_TYPE } from '../../../common/constants'; -import { Logger } from '../../../types'; +import { ServerFacade, Logger } from '../../../types'; import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_factory'; /* @@ -14,6 +13,7 @@ import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_fa * to the locally running Kibana instance. */ export const validateBrowser = async ( + server: ServerFacade, browserFactory: HeadlessChromiumDriverFactory, logger: Logger ) => { diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts new file mode 100644 index 0000000000000..e0af94cbdc29c --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.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. + */ + +import { i18n } from '@kbn/i18n'; +import crypto from 'crypto'; +import { ServerFacade, Logger } from '../../../types'; + +export function validateEncryptionKey(serverFacade: ServerFacade, logger: Logger) { + const config = serverFacade.config(); + + const encryptionKey = config.get('xpack.reporting.encryptionKey'); + if (encryptionKey == null) { + // TODO this should simply throw an error and let the handler conver it to a warning mesasge. See validateServerHost. + logger.warning( + i18n.translate('xpack.reporting.selfCheckEncryptionKey.warning', { + defaultMessage: + `Generating a random key for {setting}. To prevent pending reports ` + + `from failing on restart, please set {setting} in kibana.yml`, + values: { + setting: 'xpack.reporting.encryptionKey', + }, + }) + ); + + // @ts-ignore: No set() method on KibanaConfig, just get() and has() + config.set('xpack.reporting.encryptionKey', crypto.randomBytes(16).toString('hex')); // update config in memory to contain a usable encryption key + } +} diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js index 2551fd48b91f3..942dcaf842696 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js @@ -32,7 +32,11 @@ describe('Reporting: Validate Max Content Length', () => { }); it('should log warning messages when reporting has a higher max-size than elasticsearch', async () => { - const config = { get: sinon.stub().returns(FIVE_HUNDRED_MEGABYTES) }; + const server = { + config: () => ({ + get: sinon.stub().returns(FIVE_HUNDRED_MEGABYTES), + }), + }; const elasticsearch = { dataClient: { callAsInternalUser: () => ({ @@ -45,7 +49,7 @@ describe('Reporting: Validate Max Content Length', () => { }, }; - await validateMaxContentLength(config, elasticsearch, logger); + await validateMaxContentLength(server, elasticsearch, logger); sinon.assert.calledWithMatch( logger.warning, @@ -66,10 +70,14 @@ describe('Reporting: Validate Max Content Length', () => { }); it('should do nothing when reporting has the same max-size as elasticsearch', async () => { - const config = { get: sinon.stub().returns(ONE_HUNDRED_MEGABYTES) }; + const server = { + config: () => ({ + get: sinon.stub().returns(ONE_HUNDRED_MEGABYTES), + }), + }; expect( - async () => await validateMaxContentLength(config, elasticsearch, logger.warning) + async () => await validateMaxContentLength(server, elasticsearch, logger.warning) ).not.toThrow(); sinon.assert.notCalled(logger.warning); }); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts index a20905ba093d4..ce4a5b93e7431 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts @@ -7,17 +7,17 @@ import numeral from '@elastic/numeral'; import { ElasticsearchServiceSetup } from 'kibana/server'; import { defaults, get } from 'lodash'; -import { Logger } from '../../../types'; -import { ReportingConfig } from '../../types'; +import { Logger, ServerFacade } from '../../../types'; -const KIBANA_MAX_SIZE_BYTES_PATH = 'csv.maxSizeBytes'; +const KIBANA_MAX_SIZE_BYTES_PATH = 'xpack.reporting.csv.maxSizeBytes'; const ES_MAX_SIZE_BYTES_PATH = 'http.max_content_length'; export async function validateMaxContentLength( - config: ReportingConfig, + server: ServerFacade, elasticsearch: ElasticsearchServiceSetup, logger: Logger ) { + const config = server.config(); const { callAsInternalUser } = elasticsearch.dataClient; const elasticClusterSettingsResponse = await callAsInternalUser('cluster.getSettings', { @@ -28,13 +28,13 @@ export async function validateMaxContentLength( const elasticSearchMaxContent = get(elasticClusterSettings, 'http.max_content_length', '100mb'); const elasticSearchMaxContentBytes = numeral().unformat(elasticSearchMaxContent.toUpperCase()); - const kibanaMaxContentBytes = config.get('csv', 'maxSizeBytes'); + const kibanaMaxContentBytes: number = config.get(KIBANA_MAX_SIZE_BYTES_PATH); if (kibanaMaxContentBytes > elasticSearchMaxContentBytes) { // TODO this should simply throw an error and let the handler conver it to a warning mesasge. See validateServerHost. logger.warning( - `xpack.reporting.${KIBANA_MAX_SIZE_BYTES_PATH} (${kibanaMaxContentBytes}) is higher than ElasticSearch's ${ES_MAX_SIZE_BYTES_PATH} (${elasticSearchMaxContentBytes}). ` + - `Please set ${ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your xpack.reporting.${KIBANA_MAX_SIZE_BYTES_PATH} in Kibana to avoid this warning.` + `${KIBANA_MAX_SIZE_BYTES_PATH} (${kibanaMaxContentBytes}) is higher than ElasticSearch's ${ES_MAX_SIZE_BYTES_PATH} (${elasticSearchMaxContentBytes}). ` + + `Please set ${ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your ${KIBANA_MAX_SIZE_BYTES_PATH} in Kibana to avoid this warning.` ); } } diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts new file mode 100644 index 0000000000000..f4f4d61246b6a --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.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 { ServerFacade } from '../../../types'; + +const configKey = 'xpack.reporting.kibanaServer.hostname'; + +export function validateServerHost(serverFacade: ServerFacade) { + const config = serverFacade.config(); + + const serverHost = config.get('server.host'); + const reportingKibanaHostName = config.get(configKey); + + if (!reportingKibanaHostName && serverHost === '0') { + // @ts-ignore: No set() method on KibanaConfig, just get() and has() + config.set(configKey, '0.0.0.0'); // update config in memory to allow Reporting to work + + throw new Error( + `Found 'server.host: "0"' in settings. This is incompatible with Reporting. ` + + `To enable Reporting to work, '${configKey}: 0.0.0.0' is being automatically to the configuration. ` + + `You can change to 'server.host: 0.0.0.0' or add '${configKey}: 0.0.0.0' in kibana.yml to prevent this message.` + ); + } +} diff --git a/x-pack/legacy/plugins/reporting/server/plugin.ts b/x-pack/legacy/plugins/reporting/server/plugin.ts index 1d7cc075b690d..4f24cc16b2277 100644 --- a/x-pack/legacy/plugins/reporting/server/plugin.ts +++ b/x-pack/legacy/plugins/reporting/server/plugin.ts @@ -12,6 +12,8 @@ import { createQueueFactory, enqueueJobFactory, LevelLogger, runValidations } fr import { setFieldFormats } from './services'; import { ReportingSetup, ReportingSetupDeps, ReportingStart, ReportingStartDeps } from './types'; import { registerReportingUsageCollector } from './usage'; +// @ts-ignore no module definition +import { mirrorPluginStatus } from '../../../server/lib/mirror_plugin_status'; export class ReportingPlugin implements Plugin { @@ -24,29 +26,29 @@ export class ReportingPlugin } public async setup(core: CoreSetup, plugins: ReportingSetupDeps) { - const { reporting: reportingNewPlatform, elasticsearch, __LEGACY } = plugins; - const { config } = reportingNewPlatform; + const { elasticsearch, usageCollection, __LEGACY } = plugins; - const browserDriverFactory = await createBrowserDriverFactory(config, this.logger); // required for validations :( - runValidations(config, elasticsearch, browserDriverFactory, this.logger); // this must run early, as it sets up config defaults + const browserDriverFactory = await createBrowserDriverFactory(__LEGACY, this.logger); // required for validations :( + runValidations(__LEGACY, elasticsearch, browserDriverFactory, this.logger); // this must run early, as it sets up config defaults const { xpack_main: xpackMainLegacy, reporting: reportingLegacy } = __LEGACY.plugins; - this.reportingCore.legacySetup(xpackMainLegacy, reportingLegacy, config, __LEGACY, plugins); + this.reportingCore.legacySetup(xpackMainLegacy, reportingLegacy, __LEGACY, plugins); // Register a function with server to manage the collection of usage stats - registerReportingUsageCollector(this.reportingCore, config, plugins); + registerReportingUsageCollector(this.reportingCore, __LEGACY, usageCollection); // regsister setup internals - this.reportingCore.pluginSetup({ browserDriverFactory, config, elasticsearch }); + this.reportingCore.pluginSetup({ browserDriverFactory }); return {}; } public async start(core: CoreStart, plugins: ReportingStartDeps) { const { reportingCore, logger } = this; + const { elasticsearch, __LEGACY } = plugins; - const esqueue = await createQueueFactory(reportingCore, logger); - const enqueueJob = await enqueueJobFactory(reportingCore, logger); + const esqueue = await createQueueFactory(reportingCore, __LEGACY, elasticsearch, logger); + const enqueueJob = enqueueJobFactory(reportingCore, __LEGACY, elasticsearch, logger); this.reportingCore.pluginStart({ savedObjects: core.savedObjects, @@ -56,9 +58,7 @@ export class ReportingPlugin }); setFieldFormats(plugins.data.fieldFormats); - - const config = await reportingCore.getConfig(); - logConfiguration(config.get('capture'), this.logger); + logConfiguration(__LEGACY, this.logger); return {}; } diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts index dc58e97ff3e41..56622617586f7 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts @@ -10,7 +10,7 @@ import { Legacy } from 'kibana'; import rison from 'rison-node'; import { API_BASE_URL } from '../../common/constants'; import { Logger, ReportingResponseToolkit, ServerFacade } from '../../types'; -import { ReportingConfig, ReportingSetupDeps } from '../types'; +import { ReportingSetupDeps } from '../types'; import { makeRequestFacade } from './lib/make_request_facade'; import { GetRouteConfigFactoryFn, @@ -22,7 +22,6 @@ import { HandlerErrorFunction, HandlerFunction } from './types'; const BASE_GENERATE = `${API_BASE_URL}/generate`; export function registerGenerateFromJobParams( - config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, handler: HandlerFunction, @@ -31,7 +30,7 @@ export function registerGenerateFromJobParams( ) { const getRouteConfig = () => { const getOriginalRouteConfig: GetRouteConfigFactoryFn = getRouteConfigFactoryReportingPre( - config, + server, plugins, logger ); diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts index 23ab7ee0d9e6b..415b6b7d64366 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts @@ -9,7 +9,7 @@ import { get } from 'lodash'; import { API_BASE_GENERATE_V1, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../common/constants'; import { getJobParamsFromRequest } from '../../export_types/csv_from_savedobject/server/lib/get_job_params_from_request'; import { Logger, ReportingResponseToolkit, ServerFacade } from '../../types'; -import { ReportingConfig, ReportingSetupDeps } from '../types'; +import { ReportingSetupDeps } from '../types'; import { makeRequestFacade } from './lib/make_request_facade'; import { getRouteOptionsCsv } from './lib/route_config_factories'; import { HandlerErrorFunction, HandlerFunction, QueuedJobPayload } from './types'; @@ -24,14 +24,13 @@ import { HandlerErrorFunction, HandlerFunction, QueuedJobPayload } from './types * - local (transient) changes the user made to the saved object */ export function registerGenerateCsvFromSavedObject( - config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, handleRoute: HandlerFunction, handleRouteError: HandlerErrorFunction, logger: Logger ) { - const routeOptions = getRouteOptionsCsv(config, plugins, logger); + const routeOptions = getRouteOptionsCsv(server, plugins, logger); server.route({ path: `${API_BASE_GENERATE_V1}/csv/saved-object/{savedObjectType}:{savedObjectId}`, diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts index 5bd07aa6049ed..5d17fa2e82b8c 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts @@ -16,7 +16,7 @@ import { ResponseFacade, ServerFacade, } from '../../types'; -import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; +import { ReportingSetupDeps, ReportingCore } from '../types'; import { makeRequestFacade } from './lib/make_request_facade'; import { getRouteOptionsCsv } from './lib/route_config_factories'; @@ -31,12 +31,12 @@ import { getRouteOptionsCsv } from './lib/route_config_factories'; */ export function registerGenerateCsvFromSavedObjectImmediate( reporting: ReportingCore, - config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, parentLogger: Logger ) { - const routeOptions = getRouteOptionsCsv(config, plugins, parentLogger); + const routeOptions = getRouteOptionsCsv(server, plugins, parentLogger); + const { elasticsearch } = plugins; /* * CSV export with the `immediate` option does not queue a job with Reporting's ESQueue to run the job async. Instead, this does: @@ -52,10 +52,14 @@ export function registerGenerateCsvFromSavedObjectImmediate( const logger = parentLogger.clone(['savedobject-csv']); const jobParams = getJobParamsFromRequest(request, { isImmediate: true }); - const [createJobFn, executeJobFn] = await Promise.all([ - createJobFactory(reporting, logger), - executeJobFactory(reporting, logger), - ]); + /* TODO these functions should be made available in the export types registry: + * + * const { createJobFn, executeJobFn } = exportTypesRegistry.getById(CSV_FROM_SAVEDOBJECT_JOB_TYPE) + * + * Calling an execute job factory requires passing a browserDriverFactory option, so we should not call the factory from here + */ + const createJobFn = createJobFactory(reporting, server, elasticsearch, logger); + const executeJobFn = await executeJobFactory(reporting, server, elasticsearch, logger); const jobDocPayload: JobDocPayloadPanelCsv = await createJobFn( jobParams, request.headers, diff --git a/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts b/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts index 44a98dac2d4a9..54d9671692c5d 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts @@ -7,7 +7,7 @@ import Hapi from 'hapi'; import { createMockReportingCore } from '../../test_helpers'; import { Logger, ServerFacade } from '../../types'; -import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; +import { ReportingCore, ReportingSetupDeps } from '../../server/types'; jest.mock('./lib/authorized_user_pre_routing', () => ({ authorizedUserPreRoutingFactory: () => () => ({}), @@ -22,8 +22,6 @@ import { registerJobGenerationRoutes } from './generation'; let mockServer: Hapi.Server; let mockReportingPlugin: ReportingCore; -let mockReportingConfig: ReportingConfig; - const mockLogger = ({ error: jest.fn(), debug: jest.fn(), @@ -35,12 +33,10 @@ beforeEach(async () => { port: 8080, routes: { log: { collect: true } }, }); - + mockServer.config = () => ({ get: jest.fn(), has: jest.fn() }); mockReportingPlugin = await createMockReportingCore(); mockReportingPlugin.getEnqueueJob = async () => jest.fn().mockImplementation(() => ({ toJSON: () => '{ "job": "data" }' })); - - mockReportingConfig = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; }); const mockPlugins = { @@ -58,7 +54,6 @@ const getErrorsFromRequest = (request: Hapi.Request) => { test(`returns 400 if there are no job params`, async () => { registerJobGenerationRoutes( mockReportingPlugin, - (mockReportingConfig as unknown) as ReportingConfig, (mockServer as unknown) as ServerFacade, (mockPlugins as unknown) as ReportingSetupDeps, mockLogger @@ -85,7 +80,6 @@ test(`returns 400 if there are no job params`, async () => { test(`returns 400 if job params is invalid`, async () => { registerJobGenerationRoutes( mockReportingPlugin, - (mockReportingConfig as unknown) as ReportingConfig, (mockServer as unknown) as ServerFacade, (mockPlugins as unknown) as ReportingSetupDeps, mockLogger @@ -120,7 +114,6 @@ test(`returns 500 if job handler throws an error`, async () => { registerJobGenerationRoutes( mockReportingPlugin, - (mockReportingConfig as unknown) as ReportingConfig, (mockServer as unknown) as ServerFacade, (mockPlugins as unknown) as ReportingSetupDeps, mockLogger diff --git a/x-pack/legacy/plugins/reporting/server/routes/generation.ts b/x-pack/legacy/plugins/reporting/server/routes/generation.ts index 0ac6a34dd75bb..096ba84b63d1a 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generation.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generation.ts @@ -9,7 +9,7 @@ import { errors as elasticsearchErrors } from 'elasticsearch'; import { Legacy } from 'kibana'; import { API_BASE_URL } from '../../common/constants'; import { Logger, ReportingResponseToolkit, ServerFacade } from '../../types'; -import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; +import { ReportingSetupDeps, ReportingCore } from '../types'; import { registerGenerateFromJobParams } from './generate_from_jobparams'; import { registerGenerateCsvFromSavedObject } from './generate_from_savedobject'; import { registerGenerateCsvFromSavedObjectImmediate } from './generate_from_savedobject_immediate'; @@ -19,13 +19,12 @@ const esErrors = elasticsearchErrors as Record; export function registerJobGenerationRoutes( reporting: ReportingCore, - config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { - const DOWNLOAD_BASE_URL = - `${config.kbnConfig.get('server', 'basePath')}` + `${API_BASE_URL}/jobs/download`; + const config = server.config(); + const DOWNLOAD_BASE_URL = config.get('server.basePath') + `${API_BASE_URL}/jobs/download`; /* * Generates enqueued job details to use in responses @@ -67,11 +66,11 @@ export function registerJobGenerationRoutes( return err; } - registerGenerateFromJobParams(config, server, plugins, handler, handleError, logger); + registerGenerateFromJobParams(server, plugins, handler, handleError, logger); // Register beta panel-action download-related API's - if (config.get('csv', 'enablePanelActionDownload')) { - registerGenerateCsvFromSavedObject(config, server, plugins, handler, handleError, logger); - registerGenerateCsvFromSavedObjectImmediate(reporting, config, server, plugins, logger); + if (config.get('xpack.reporting.csv.enablePanelActionDownload')) { + registerGenerateCsvFromSavedObject(server, plugins, handler, handleError, logger); + registerGenerateCsvFromSavedObjectImmediate(reporting, server, plugins, logger); } } diff --git a/x-pack/legacy/plugins/reporting/server/routes/index.ts b/x-pack/legacy/plugins/reporting/server/routes/index.ts index 21eeb901d9b96..610ab4907d369 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/index.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/index.ts @@ -5,17 +5,16 @@ */ import { Logger, ServerFacade } from '../../types'; -import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; +import { ReportingCore, ReportingSetupDeps } from '../types'; import { registerJobGenerationRoutes } from './generation'; import { registerJobInfoRoutes } from './jobs'; export function registerRoutes( reporting: ReportingCore, - config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { - registerJobGenerationRoutes(reporting, config, server, plugins, logger); - registerJobInfoRoutes(reporting, config, server, plugins, logger); + registerJobGenerationRoutes(reporting, server, plugins, logger); + registerJobInfoRoutes(reporting, server, plugins, logger); } diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js b/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js index b12aa44487523..071b401d2321b 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js +++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js @@ -5,6 +5,7 @@ */ import Hapi from 'hapi'; +import { memoize } from 'lodash'; import { createMockReportingCore } from '../../test_helpers'; import { ExportTypesRegistry } from '../lib/export_types_registry'; @@ -22,7 +23,6 @@ import { registerJobInfoRoutes } from './jobs'; let mockServer; let exportTypesRegistry; let mockReportingPlugin; -let mockReportingConfig; const mockLogger = { error: jest.fn(), debug: jest.fn(), @@ -30,6 +30,7 @@ const mockLogger = { beforeEach(async () => { mockServer = new Hapi.Server({ debug: false, port: 8080, routes: { log: { collect: true } } }); + mockServer.config = memoize(() => ({ get: jest.fn() })); exportTypesRegistry = new ExportTypesRegistry(); exportTypesRegistry.register({ id: 'unencoded', @@ -42,11 +43,8 @@ beforeEach(async () => { jobContentEncoding: 'base64', jobContentExtension: 'pdf', }); - mockReportingPlugin = await createMockReportingCore(); mockReportingPlugin.getExportTypesRegistry = () => exportTypesRegistry; - - mockReportingConfig = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; }); const mockPlugins = { @@ -72,13 +70,7 @@ test(`returns 404 if job not found`, async () => { callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(getHits())), }; - registerJobInfoRoutes( - mockReportingPlugin, - mockReportingConfig, - mockServer, - mockPlugins, - mockLogger - ); + registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); const request = { method: 'GET', @@ -97,13 +89,7 @@ test(`returns 401 if not valid job type`, async () => { .mockReturnValue(Promise.resolve(getHits({ jobtype: 'invalidJobType' }))), }; - registerJobInfoRoutes( - mockReportingPlugin, - mockReportingConfig, - mockServer, - mockPlugins, - mockLogger - ); + registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); const request = { method: 'GET', @@ -124,13 +110,7 @@ describe(`when job is incomplete`, () => { ), }; - registerJobInfoRoutes( - mockReportingPlugin, - mockReportingConfig, - mockServer, - mockPlugins, - mockLogger - ); + registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); const request = { method: 'GET', @@ -172,13 +152,7 @@ describe(`when job is failed`, () => { callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), }; - registerJobInfoRoutes( - mockReportingPlugin, - mockReportingConfig, - mockServer, - mockPlugins, - mockLogger - ); + registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); const request = { method: 'GET', @@ -223,13 +197,7 @@ describe(`when job is completed`, () => { callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), }; - registerJobInfoRoutes( - mockReportingPlugin, - mockReportingConfig, - mockServer, - mockPlugins, - mockLogger - ); + registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); const request = { method: 'GET', diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts index 4f29e561431fa..b9aa75e0ddd00 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts @@ -17,7 +17,7 @@ import { ServerFacade, } from '../../types'; import { jobsQueryFactory } from '../lib/jobs_query'; -import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; +import { ReportingSetupDeps, ReportingCore } from '../types'; import { deleteJobResponseHandlerFactory, downloadJobResponseHandlerFactory, @@ -37,14 +37,13 @@ function isResponse(response: Boom | ResponseObject): response is Response export function registerJobInfoRoutes( reporting: ReportingCore, - config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { const { elasticsearch } = plugins; - const jobsQuery = jobsQueryFactory(config, elasticsearch); - const getRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); + const jobsQuery = jobsQueryFactory(server, elasticsearch); + const getRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); // list jobs in the queue, paginated server.route({ @@ -142,8 +141,8 @@ export function registerJobInfoRoutes( // trigger a download of the output from a job const exportTypesRegistry = reporting.getExportTypesRegistry(); - const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(config, plugins, logger); - const downloadResponseHandler = downloadJobResponseHandlerFactory(config, elasticsearch, exportTypesRegistry); // prettier-ignore + const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(server, plugins, logger); + const downloadResponseHandler = downloadJobResponseHandlerFactory(server, elasticsearch, exportTypesRegistry); // prettier-ignore server.route({ path: `${MAIN_ENTRY}/download/{docId}`, method: 'GET', @@ -182,8 +181,8 @@ export function registerJobInfoRoutes( }); // allow a report to be deleted - const getRouteConfigDelete = getRouteConfigFactoryDeletePre(config, plugins, logger); - const deleteResponseHandler = deleteJobResponseHandlerFactory(config, elasticsearch); + const getRouteConfigDelete = getRouteConfigFactoryDeletePre(server, plugins, logger); + const deleteResponseHandler = deleteJobResponseHandlerFactory(server, elasticsearch); server.route({ path: `${MAIN_ENTRY}/delete/{docId}`, method: 'DELETE', diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js index b5d6ae59ce5dd..3460d22592e3d 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js @@ -7,48 +7,56 @@ import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; describe('authorized_user_pre_routing', function() { - const createMockConfig = (mockConfig = {}) => { - return { - get: (...keys) => mockConfig[keys.join('.')], - kbnConfig: { get: (...keys) => mockConfig[keys.join('.')] }, - }; - }; - const createMockPlugins = (function() { + // the getClientShield is using `once` which forces us to use a constant mock + // which makes testing anything that is dependent on `oncePerServer` confusing. + // so createMockServer reuses the same 'instance' of the server and overwrites + // the properties to contain different values + const createMockServer = (function() { const getUserStub = jest.fn(); + let mockConfig; + + const mockServer = { + expose() {}, + config() { + return { + get(key) { + return mockConfig[key]; + }, + }; + }, + log: function() {}, + plugins: { + xpack_main: {}, + security: { getUser: getUserStub }, + }, + }; return function({ securityEnabled = true, xpackInfoUndefined = false, xpackInfoAvailable = true, - getCurrentUser = undefined, user = undefined, + config = {}, }) { - getUserStub.mockReset(); - getUserStub.mockResolvedValue(user); - return { - security: securityEnabled - ? { - authc: { getCurrentUser }, - } - : null, - __LEGACY: { - plugins: { - xpack_main: { - info: !xpackInfoUndefined && { + mockConfig = config; + + mockServer.plugins.xpack_main = { + info: !xpackInfoUndefined && { + isAvailable: () => xpackInfoAvailable, + feature(featureName) { + if (featureName === 'security') { + return { + isEnabled: () => securityEnabled, isAvailable: () => xpackInfoAvailable, - feature(featureName) { - if (featureName === 'security') { - return { - isEnabled: () => securityEnabled, - isAvailable: () => xpackInfoAvailable, - }; - } - }, - }, - }, + }; + } }, }, }; + + getUserStub.mockReset(); + getUserStub.mockResolvedValue(user); + return mockServer; }; })(); @@ -67,6 +75,10 @@ describe('authorized_user_pre_routing', function() { raw: { req: mockRequestRaw }, }); + const getMockPlugins = pluginSet => { + return pluginSet || { security: null }; + }; + const getMockLogger = () => ({ warn: jest.fn(), error: msg => { @@ -75,9 +87,11 @@ describe('authorized_user_pre_routing', function() { }); it('should return with boom notFound when xpackInfo is undefined', async function() { + const mockServer = createMockServer({ xpackInfoUndefined: true }); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - createMockConfig(), - createMockPlugins({ xpackInfoUndefined: true }), + mockServer, + getMockPlugins(), getMockLogger() ); const response = await authorizedUserPreRouting(getMockRequest()); @@ -86,9 +100,11 @@ describe('authorized_user_pre_routing', function() { }); it(`should return with boom notFound when xpackInfo isn't available`, async function() { + const mockServer = createMockServer({ xpackInfoAvailable: false }); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - createMockConfig(), - createMockPlugins({ xpackInfoAvailable: false }), + mockServer, + getMockPlugins(), getMockLogger() ); const response = await authorizedUserPreRouting(getMockRequest()); @@ -97,9 +113,11 @@ describe('authorized_user_pre_routing', function() { }); it('should return with null user when security is disabled in Elasticsearch', async function() { + const mockServer = createMockServer({ securityEnabled: false }); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - createMockConfig(), - createMockPlugins({ securityEnabled: false }), + mockServer, + getMockPlugins(), getMockLogger() ); const response = await authorizedUserPreRouting(getMockRequest()); @@ -107,14 +125,16 @@ describe('authorized_user_pre_routing', function() { }); it('should return with boom unauthenticated when security is enabled but no authenticated user', async function() { - const mockPlugins = createMockPlugins({ + const mockServer = createMockServer({ user: null, config: { 'xpack.reporting.roles.allow': ['.reporting_user'] }, }); - mockPlugins.security = { authc: { getCurrentUser: () => null } }; + const mockPlugins = getMockPlugins({ + security: { authc: { getCurrentUser: () => null } }, + }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - createMockConfig(), + mockServer, mockPlugins, getMockLogger() ); @@ -124,14 +144,16 @@ describe('authorized_user_pre_routing', function() { }); it(`should return with boom forbidden when security is enabled but user doesn't have allowed role`, async function() { - const mockConfig = createMockConfig({ 'roles.allow': ['.reporting_user'] }); - const mockPlugins = createMockPlugins({ + const mockServer = createMockServer({ user: { roles: [] }, - getCurrentUser: () => ({ roles: ['something_else'] }), + config: { 'xpack.reporting.roles.allow': ['.reporting_user'] }, + }); + const mockPlugins = getMockPlugins({ + security: { authc: { getCurrentUser: () => ({ roles: ['something_else'] }) } }, }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockConfig, + mockServer, mockPlugins, getMockLogger() ); @@ -142,14 +164,18 @@ describe('authorized_user_pre_routing', function() { it('should return with user when security is enabled and user has explicitly allowed role', async function() { const user = { roles: ['.reporting_user', 'something_else'] }; - const mockConfig = createMockConfig({ 'roles.allow': ['.reporting_user'] }); - const mockPlugins = createMockPlugins({ + const mockServer = createMockServer({ user, - getCurrentUser: () => ({ roles: ['.reporting_user', 'something_else'] }), + config: { 'xpack.reporting.roles.allow': ['.reporting_user'] }, + }); + const mockPlugins = getMockPlugins({ + security: { + authc: { getCurrentUser: () => ({ roles: ['.reporting_user', 'something_else'] }) }, + }, }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockConfig, + mockServer, mockPlugins, getMockLogger() ); @@ -159,13 +185,16 @@ describe('authorized_user_pre_routing', function() { it('should return with user when security is enabled and user has superuser role', async function() { const user = { roles: ['superuser', 'something_else'] }; - const mockConfig = createMockConfig({ 'roles.allow': [] }); - const mockPlugins = createMockPlugins({ - getCurrentUser: () => ({ roles: ['superuser', 'something_else'] }), + const mockServer = createMockServer({ + user, + config: { 'xpack.reporting.roles.allow': [] }, + }); + const mockPlugins = getMockPlugins({ + security: { authc: { getCurrentUser: () => ({ roles: ['superuser', 'something_else'] }) } }, }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockConfig, + mockServer, mockPlugins, getMockLogger() ); diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts index 1ca28ca62a7f2..c5f8c78016f61 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts @@ -7,8 +7,7 @@ import Boom from 'boom'; import { Legacy } from 'kibana'; import { AuthenticatedUser } from '../../../../../../plugins/security/server'; -import { ReportingConfig } from '../../../server'; -import { Logger } from '../../../types'; +import { Logger, ServerFacade } from '../../../types'; import { getUserFactory } from '../../lib/get_user'; import { ReportingSetupDeps } from '../../types'; @@ -19,14 +18,16 @@ export type PreRoutingFunction = ( ) => Promise | AuthenticatedUser | null>; export const authorizedUserPreRoutingFactory = function authorizedUserPreRoutingFn( - config: ReportingConfig, + server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { - const getUser = getUserFactory(plugins.security, logger); - const { info: xpackInfo } = plugins.__LEGACY.plugins.xpack_main; + const getUser = getUserFactory(server, plugins.security); + const config = server.config(); return async function authorizedUserPreRouting(request: Legacy.Request) { + const xpackInfo = server.plugins.xpack_main.info; + if (!xpackInfo || !xpackInfo.isAvailable()) { logger.warn('Unable to authorize user before xpack info is available.', [ 'authorizedUserPreRouting', @@ -45,7 +46,10 @@ export const authorizedUserPreRoutingFactory = function authorizedUserPreRouting return Boom.unauthorized(`Sorry, you aren't authenticated`); } - const authorizedRoles = [superuserRole, ...(config.get('roles', 'allow') as string[])]; + const authorizedRoles = [ + superuserRole, + ...(config.get('xpack.reporting.roles.allow') as string[]), + ]; if (!user.roles.find(role => authorizedRoles.includes(role))) { return Boom.forbidden(`Sorry, you don't have access to Reporting`); } diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts index aef37754681ec..fb3944ea33552 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts @@ -8,7 +8,13 @@ import contentDisposition from 'content-disposition'; import * as _ from 'lodash'; import { CSV_JOB_TYPE } from '../../../common/constants'; -import { ExportTypeDefinition, ExportTypesRegistry, JobDocOutput, JobSource } from '../../../types'; +import { + ExportTypeDefinition, + ExportTypesRegistry, + JobDocOutput, + JobSource, + ServerFacade, +} from '../../../types'; interface ICustomHeaders { [x: string]: any; @@ -16,15 +22,9 @@ interface ICustomHeaders { type ExportTypeType = ExportTypeDefinition; -interface ErrorFromPayload { - message: string; - reason: string | null; -} - -// A camelCase version of JobDocOutput interface Payload { statusCode: number; - content: string | Buffer | ErrorFromPayload; + content: any; contentType: string; headers: Record; } @@ -48,17 +48,20 @@ const getReportingHeaders = (output: JobDocOutput, exportType: ExportTypeType) = return metaDataHeaders; }; -export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegistry) { - function encodeContent(content: string | null, exportType: ExportTypeType): Buffer | string { +export function getDocumentPayloadFactory( + server: ServerFacade, + exportTypesRegistry: ExportTypesRegistry +) { + function encodeContent(content: string | null, exportType: ExportTypeType) { switch (exportType.jobContentEncoding) { case 'base64': - return content ? Buffer.from(content, 'base64') : ''; // convert null to empty string + return content ? Buffer.from(content, 'base64') : content; // Buffer.from rejects null default: - return content ? content : ''; // convert null to empty string + return content; } } - function getCompleted(output: JobDocOutput, jobType: string, title: string): Payload { + function getCompleted(output: JobDocOutput, jobType: string, title: string) { const exportType = exportTypesRegistry.get((item: ExportTypeType) => item.jobType === jobType); const filename = getTitle(exportType, title); const headers = getReportingHeaders(output, exportType); @@ -74,7 +77,7 @@ export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegist }; } - function getFailure(output: JobDocOutput): Payload { + function getFailure(output: JobDocOutput) { return { statusCode: 500, content: { diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts index e7e7c866db96a..30627d5b23230 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts @@ -5,12 +5,11 @@ */ import Boom from 'boom'; -import { ResponseToolkit } from 'hapi'; import { ElasticsearchServiceSetup } from 'kibana/server'; +import { ResponseToolkit } from 'hapi'; import { WHITELISTED_JOB_CONTENT_TYPES } from '../../../common/constants'; -import { ExportTypesRegistry } from '../../../types'; +import { ExportTypesRegistry, ServerFacade } from '../../../types'; import { jobsQueryFactory } from '../../lib/jobs_query'; -import { ReportingConfig } from '../../types'; import { getDocumentPayloadFactory } from './get_document_payload'; interface JobResponseHandlerParams { @@ -22,12 +21,12 @@ interface JobResponseHandlerOpts { } export function downloadJobResponseHandlerFactory( - config: ReportingConfig, + server: ServerFacade, elasticsearch: ElasticsearchServiceSetup, exportTypesRegistry: ExportTypesRegistry ) { - const jobsQuery = jobsQueryFactory(config, elasticsearch); - const getDocumentPayload = getDocumentPayloadFactory(exportTypesRegistry); + const jobsQuery = jobsQueryFactory(server, elasticsearch); + const getDocumentPayload = getDocumentPayloadFactory(server, exportTypesRegistry); return function jobResponseHandler( validJobTypes: string[], @@ -71,10 +70,10 @@ export function downloadJobResponseHandlerFactory( } export function deleteJobResponseHandlerFactory( - config: ReportingConfig, + server: ServerFacade, elasticsearch: ElasticsearchServiceSetup ) { - const jobsQuery = jobsQueryFactory(config, elasticsearch); + const jobsQuery = jobsQueryFactory(server, elasticsearch); return async function deleteJobResponseHander( validJobTypes: string[], diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts index 8a79566aafae2..9e618ff1fe40a 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts @@ -6,17 +6,17 @@ import Boom from 'boom'; import { Legacy } from 'kibana'; -import { Logger } from '../../../types'; -import { ReportingConfig, ReportingSetupDeps } from '../../types'; +import { Logger, ServerFacade } from '../../../types'; +import { ReportingSetupDeps } from '../../types'; export type GetReportingFeatureIdFn = (request: Legacy.Request) => string; export const reportingFeaturePreRoutingFactory = function reportingFeaturePreRoutingFn( - config: ReportingConfig, + server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { - const xpackMainPlugin = plugins.__LEGACY.plugins.xpack_main; + const xpackMainPlugin = server.plugins.xpack_main; const pluginId = 'reporting'; // License checking and enable/disable logic diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts index 06f7efaa9dcbb..3d275d34e2f7d 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts @@ -6,8 +6,8 @@ import Joi from 'joi'; import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; -import { Logger } from '../../../types'; -import { ReportingConfig, ReportingSetupDeps } from '../../types'; +import { Logger, ServerFacade } from '../../../types'; +import { ReportingSetupDeps } from '../../types'; import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; import { GetReportingFeatureIdFn, @@ -29,12 +29,12 @@ export type GetRouteConfigFactoryFn = ( ) => RouteConfigFactory; export function getRouteConfigFactoryReportingPre( - config: ReportingConfig, + server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const authorizedUserPreRouting = authorizedUserPreRoutingFactory(config, plugins, logger); - const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(config, plugins, logger); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory(server, plugins, logger); + const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(server, plugins, logger); return (getFeatureId?: GetReportingFeatureIdFn): RouteConfigFactory => { const preRouting: any[] = [{ method: authorizedUserPreRouting, assign: 'user' }]; @@ -50,11 +50,11 @@ export function getRouteConfigFactoryReportingPre( } export function getRouteOptionsCsv( - config: ReportingConfig, + server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { - const getRouteConfig = getRouteConfigFactoryReportingPre(config, plugins, logger); + const getRouteConfig = getRouteConfigFactoryReportingPre(server, plugins, logger); return { ...getRouteConfig(() => CSV_FROM_SAVEDOBJECT_JOB_TYPE), validate: { @@ -75,12 +75,12 @@ export function getRouteOptionsCsv( } export function getRouteConfigFactoryManagementPre( - config: ReportingConfig, + server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const authorizedUserPreRouting = authorizedUserPreRoutingFactory(config, plugins, logger); - const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(config, plugins, logger); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory(server, plugins, logger); + const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(server, plugins, logger); const managementPreRouting = reportingFeaturePreRouting(() => 'management'); return (): RouteConfigFactory => { @@ -99,11 +99,11 @@ export function getRouteConfigFactoryManagementPre( // Additionally, the range-request doesn't alleviate any performance issues on the server as the entire // download is loaded into memory. export function getRouteConfigFactoryDownloadPre( - config: ReportingConfig, + server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const getManagementRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); + const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); return (): RouteConfigFactory => ({ ...getManagementRouteConfig(), tags: [API_TAG, 'download'], @@ -114,11 +114,11 @@ export function getRouteConfigFactoryDownloadPre( } export function getRouteConfigFactoryDeletePre( - config: ReportingConfig, + server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const getManagementRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); + const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); return (): RouteConfigFactory => ({ ...getManagementRouteConfig(), tags: [API_TAG, 'delete'], diff --git a/x-pack/legacy/plugins/reporting/server/types.d.ts b/x-pack/legacy/plugins/reporting/server/types.d.ts index c773e2d556648..59b7bc2020ad9 100644 --- a/x-pack/legacy/plugins/reporting/server/types.d.ts +++ b/x-pack/legacy/plugins/reporting/server/types.d.ts @@ -11,17 +11,16 @@ import { PluginStart as DataPluginStart } from '../../../../../src/plugins/data/ import { SecurityPluginSetup } from '../../../../plugins/security/server'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; import { ReportingPluginSpecOptions } from '../types'; -import { ReportingConfig, ReportingConfigType } from './core'; export interface ReportingSetupDeps { elasticsearch: ElasticsearchServiceSetup; security: SecurityPluginSetup; usageCollection: UsageCollectionSetup; - reporting: { config: ReportingConfig }; __LEGACY: LegacySetup; } export interface ReportingStartDeps { + elasticsearch: ElasticsearchServiceSetup; data: DataPluginStart; __LEGACY: LegacySetup; } @@ -31,7 +30,10 @@ export type ReportingSetup = object; export type ReportingStart = object; export interface LegacySetup { + config: Legacy.Server['config']; + info: Legacy.Server['info']; plugins: { + elasticsearch: Legacy.Server['plugins']['elasticsearch']; xpack_main: XPackMainPlugin & { status?: any; }; @@ -40,7 +42,4 @@ export interface LegacySetup { route: Legacy.Server['route']; } -export { ReportingConfig, ReportingCore } from './core'; - -export type CaptureConfig = ReportingConfigType['capture']; -export type ScrollConfig = ReportingConfigType['csv']['scroll']; +export { ReportingCore } from './core'; diff --git a/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts b/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts index 5f12f2b7f044d..bd2d0cb835a79 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts @@ -5,10 +5,7 @@ */ import { get } from 'lodash'; -import { ESCallCluster, ExportTypesRegistry } from '../../types'; -import { ReportingConfig, ReportingSetupDeps } from '../types'; -import { decorateRangeStats } from './decorate_range_stats'; -import { getExportTypesHandler } from './get_export_type_handler'; +import { ServerFacade, ExportTypesRegistry, ESCallCluster } from '../../types'; import { AggregationBuckets, AggregationResults, @@ -18,6 +15,8 @@ import { RangeAggregationResults, RangeStats, } from './types'; +import { decorateRangeStats } from './decorate_range_stats'; +import { getExportTypesHandler } from './get_export_type_handler'; const JOB_TYPES_KEY = 'jobTypes'; const JOB_TYPES_FIELD = 'jobtype'; @@ -80,7 +79,10 @@ type RangeStatSets = Partial< last7Days: RangeStats; } >; -async function handleResponse(response: AggregationResults): Promise { +async function handleResponse( + server: ServerFacade, + response: AggregationResults +): Promise { const buckets = get(response, 'aggregations.ranges.buckets'); if (!buckets) { return {}; @@ -99,12 +101,12 @@ async function handleResponse(response: AggregationResults): Promise handleResponse(response)) + .then((response: AggregationResults) => handleResponse(server, response)) .then((usage: RangeStatSets) => { // Allow this to explicitly throw an exception if/when this config is deprecated, // because we shouldn't collect browserType in that case! - const browserType = config.get('capture', 'browser', 'type'); + const browserType = config.get('xpack.reporting.capture.browser.type'); + const xpackInfo = server.plugins.xpack_main.info; const exportTypesHandler = getExportTypesHandler(exportTypesRegistry); - const availability = exportTypesHandler.getAvailability( - xpackMainInfo - ) as FeatureAvailabilityMap; + const availability = exportTypesHandler.getAvailability(xpackInfo) as FeatureAvailabilityMap; const { lastDay, last7Days, ...all } = usage; diff --git a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js index 905d2fe9b995c..a6d753f9b107a 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js +++ b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js @@ -24,52 +24,62 @@ function getMockUsageCollection() { makeUsageCollector: options => { return new MockUsageCollector(this, options); }, - registerCollector: sinon.stub(), }; } -function getPluginsMock( - { license, usageCollection = getMockUsageCollection() } = { license: 'platinum' } -) { - const mockXpackMain = { - info: { - isAvailable: sinon.stub().returns(true), - feature: () => ({ - getLicenseCheckResults: sinon.stub(), - }), - license: { - isOneOf: sinon.stub().returns(false), - getType: sinon.stub().returns(license), +function getServerMock(customization) { + const getLicenseCheckResults = sinon.stub().returns({}); + const defaultServerMock = { + plugins: { + security: { + isAuthenticated: sinon.stub().returns(true), }, - toJSON: () => ({ b: 1 }), - }, - }; - return { - usageCollection, - __LEGACY: { - plugins: { - xpack_main: mockXpackMain, + xpack_main: { + info: { + isAvailable: sinon.stub().returns(true), + feature: () => ({ + getLicenseCheckResults, + }), + license: { + isOneOf: sinon.stub().returns(false), + getType: sinon.stub().returns('platinum'), + }, + toJSON: () => ({ b: 1 }), + }, }, }, + log: () => {}, + config: () => ({ + get: key => { + if (key === 'xpack.reporting.enabled') { + return true; + } else if (key === 'xpack.reporting.index') { + return '.reporting-index'; + } + }, + }), }; + return Object.assign(defaultServerMock, customization); } const getResponseMock = (customization = {}) => customization; describe('license checks', () => { - let mockConfig; - beforeAll(async () => { - const mockReporting = await createMockReportingCore(); - mockConfig = await mockReporting.getConfig(); - }); - describe('with a basic license', () => { let usageStats; beforeAll(async () => { - const plugins = getPluginsMock({ license: 'basic' }); + const serverWithBasicLicenseMock = getServerMock(); + serverWithBasicLicenseMock.plugins.xpack_main.info.license.getType = sinon + .stub() + .returns('basic'); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); - usageStats = await fetch(callClusterMock, exportTypesRegistry); + const usageCollection = getMockUsageCollection(); + const { fetch: getReportingUsage } = getReportingUsageCollector( + serverWithBasicLicenseMock, + usageCollection, + exportTypesRegistry + ); + usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -88,10 +98,18 @@ describe('license checks', () => { describe('with no license', () => { let usageStats; beforeAll(async () => { - const plugins = getPluginsMock({ license: 'none' }); + const serverWithNoLicenseMock = getServerMock(); + serverWithNoLicenseMock.plugins.xpack_main.info.license.getType = sinon + .stub() + .returns('none'); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); - usageStats = await fetch(callClusterMock, exportTypesRegistry); + const usageCollection = getMockUsageCollection(); + const { fetch: getReportingUsage } = getReportingUsageCollector( + serverWithNoLicenseMock, + usageCollection, + exportTypesRegistry + ); + usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -110,10 +128,18 @@ describe('license checks', () => { describe('with platinum license', () => { let usageStats; beforeAll(async () => { - const plugins = getPluginsMock({ license: 'platinum' }); + const serverWithPlatinumLicenseMock = getServerMock(); + serverWithPlatinumLicenseMock.plugins.xpack_main.info.license.getType = sinon + .stub() + .returns('platinum'); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); - usageStats = await fetch(callClusterMock, exportTypesRegistry); + const usageCollection = getMockUsageCollection(); + const { fetch: getReportingUsage } = getReportingUsageCollector( + serverWithPlatinumLicenseMock, + usageCollection, + exportTypesRegistry + ); + usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -132,10 +158,18 @@ describe('license checks', () => { describe('with no usage data', () => { let usageStats; beforeAll(async () => { - const plugins = getPluginsMock({ license: 'basic' }); + const serverWithBasicLicenseMock = getServerMock(); + serverWithBasicLicenseMock.plugins.xpack_main.info.license.getType = sinon + .stub() + .returns('basic'); const callClusterMock = jest.fn(() => Promise.resolve({})); - const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); - usageStats = await fetch(callClusterMock, exportTypesRegistry); + const usageCollection = getMockUsageCollection(); + const { fetch: getReportingUsage } = getReportingUsageCollector( + serverWithBasicLicenseMock, + usageCollection, + exportTypesRegistry + ); + usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -149,11 +183,21 @@ describe('license checks', () => { }); describe('data modeling', () => { + let getReportingUsage; + beforeAll(async () => { + const usageCollection = getMockUsageCollection(); + const serverWithPlatinumLicenseMock = getServerMock(); + serverWithPlatinumLicenseMock.plugins.xpack_main.info.license.getType = sinon + .stub() + .returns('platinum'); + ({ fetch: getReportingUsage } = getReportingUsageCollector( + serverWithPlatinumLicenseMock, + usageCollection, + exportTypesRegistry + )); + }); + test('with normal looking usage data', async () => { - const mockReporting = await createMockReportingCore(); - const mockConfig = await mockReporting.getConfig(); - const plugins = getPluginsMock(); - const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); const callClusterMock = jest.fn(() => Promise.resolve( getResponseMock({ @@ -276,7 +320,7 @@ describe('data modeling', () => { ) ); - const usageStats = await fetch(callClusterMock); + const usageStats = await getReportingUsage(callClusterMock); expect(usageStats).toMatchInlineSnapshot(` Object { "PNG": Object { @@ -371,16 +415,20 @@ describe('data modeling', () => { }); describe('Ready for collection observable', () => { - test('converts observable to promise', async () => { - const mockReporting = await createMockReportingCore(); - const mockConfig = await mockReporting.getConfig(); + let mockReporting; - const usageCollection = getMockUsageCollection(); - const makeCollectorSpy = sinon.spy(); - usageCollection.makeUsageCollector = makeCollectorSpy; + beforeEach(async () => { + mockReporting = await createMockReportingCore(); + }); - const plugins = getPluginsMock({ usageCollection }); - registerReportingUsageCollector(mockReporting, mockConfig, plugins); + test('converts observable to promise', async () => { + const serverWithBasicLicenseMock = getServerMock(); + const makeCollectorSpy = sinon.spy(); + const usageCollection = { + makeUsageCollector: makeCollectorSpy, + registerCollector: sinon.stub(), + }; + registerReportingUsageCollector(mockReporting, serverWithBasicLicenseMock, usageCollection); const [args] = makeCollectorSpy.firstCall.args; expect(args).toMatchInlineSnapshot(` diff --git a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts index ab4ec3a0edf57..14202530fb6c7 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { KIBANA_REPORTING_TYPE } from '../../common/constants'; -import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../../server/types'; -import { ESCallCluster, ExportTypesRegistry } from '../../types'; +import { ReportingCore } from '../../server'; +import { ESCallCluster, ExportTypesRegistry, ServerFacade } from '../../types'; import { getReportingUsage } from './get_reporting_usage'; import { RangeStats } from './types'; @@ -14,19 +15,19 @@ import { RangeStats } from './types'; const METATYPE = 'kibana_stats'; /* + * @param {Object} server * @return {Object} kibana usage stats type collection object */ export function getReportingUsageCollector( - config: ReportingConfig, - plugins: ReportingSetupDeps, + server: ServerFacade, + usageCollection: UsageCollectionSetup, exportTypesRegistry: ExportTypesRegistry, isReady: () => Promise ) { - const { usageCollection } = plugins; return usageCollection.makeUsageCollector({ type: KIBANA_REPORTING_TYPE, fetch: (callCluster: ESCallCluster) => - getReportingUsage(config, plugins, callCluster, exportTypesRegistry), + getReportingUsage(server, callCluster, exportTypesRegistry), isReady, /* @@ -51,17 +52,17 @@ export function getReportingUsageCollector( export function registerReportingUsageCollector( reporting: ReportingCore, - config: ReportingConfig, - plugins: ReportingSetupDeps + server: ServerFacade, + usageCollection: UsageCollectionSetup ) { const exportTypesRegistry = reporting.getExportTypesRegistry(); const collectionIsReady = reporting.pluginHasStarted.bind(reporting); const collector = getReportingUsageCollector( - config, - plugins, + server, + usageCollection, exportTypesRegistry, collectionIsReady ); - plugins.usageCollection.registerCollector(collector); + usageCollection.registerCollector(collector); } diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts index 930aa7601b8cb..883276d43e27e 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts @@ -10,8 +10,7 @@ import * as contexts from '../export_types/common/lib/screenshots/constants'; import { ElementsPositionAndAttribute } from '../export_types/common/lib/screenshots/types'; import { HeadlessChromiumDriver, HeadlessChromiumDriverFactory } from '../server/browsers'; import { createDriverFactory } from '../server/browsers/chromium'; -import { CaptureConfig } from '../server/types'; -import { Logger } from '../types'; +import { BrowserConfig, CaptureConfig, Logger } from '../types'; interface CreateMockBrowserDriverFactoryOpts { evaluate: jest.Mock, any[]>; @@ -94,34 +93,24 @@ export const createMockBrowserDriverFactory = async ( logger: Logger, opts: Partial ): Promise => { - const captureConfig = { - timeouts: { openUrl: 30000, waitForElements: 30000, renderComplete: 30000 }, - browser: { - type: 'chromium', - chromium: { - inspect: false, - disableSandbox: false, - userDataDir: '/usr/data/dir', - viewport: { width: 12, height: 12 }, - proxy: { enabled: false, server: undefined, bypass: undefined }, - }, - autoDownload: false, - inspect: true, - userDataDir: '/usr/data/dir', - viewport: { width: 12, height: 12 }, - disableSandbox: false, - proxy: { enabled: false, server: undefined, bypass: undefined }, - maxScreenshotDimension: undefined, - }, - networkPolicy: { enabled: true, rules: [] }, - viewport: { width: 800, height: 600 }, - loadDelay: 2000, - zoom: 1, - maxAttempts: 1, - } as CaptureConfig; + const browserConfig = { + inspect: true, + userDataDir: '/usr/data/dir', + viewport: { width: 12, height: 12 }, + disableSandbox: false, + proxy: { enabled: false }, + } as BrowserConfig; const binaryPath = '/usr/local/share/common/secure/'; - const mockBrowserDriverFactory = await createDriverFactory(binaryPath, logger, captureConfig); + const captureConfig = { networkPolicy: {}, timeouts: {} } as CaptureConfig; + + const mockBrowserDriverFactory = await createDriverFactory( + binaryPath, + logger, + browserConfig, + captureConfig + ); + const mockPage = {} as Page; const mockBrowserDriver = new HeadlessChromiumDriver(mockPage, { inspect: true, diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts index be60b56dcc0c1..0250e6c0a9afd 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LayoutTypes } from '../export_types/common/constants'; import { createLayout } from '../export_types/common/layouts'; +import { LayoutTypes } from '../export_types/common/constants'; import { LayoutInstance } from '../export_types/common/layouts/layout'; -import { CaptureConfig } from '../server/types'; +import { ServerFacade } from '../types'; -export const createMockLayoutInstance = (captureConfig: CaptureConfig) => { - const mockLayout = createLayout(captureConfig, { +export const createMockLayoutInstance = (__LEGACY: ServerFacade) => { + const mockLayout = createLayout(__LEGACY, { id: LayoutTypes.PRESERVE_LAYOUT, dimensions: { height: 12, width: 12 }, }) as LayoutInstance; diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts index 332b37b58cb7d..2cd129d47b3f9 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts @@ -19,24 +19,16 @@ import { coreMock } from 'src/core/server/mocks'; import { ReportingPlugin, ReportingCore } from '../server'; import { ReportingSetupDeps, ReportingStartDeps } from '../server/types'; -const createMockSetupDeps = (setupMock?: any): ReportingSetupDeps => { - const configGetStub = jest.fn(); - return { - elasticsearch: setupMock.elasticsearch, - security: setupMock.security, - usageCollection: {} as any, - reporting: { - config: { - get: configGetStub, - kbnConfig: { get: configGetStub }, - }, - }, - __LEGACY: { plugins: { xpack_main: { status: new EventEmitter() } } } as any, - }; -}; +export const createMockSetupDeps = (setupMock?: any): ReportingSetupDeps => ({ + elasticsearch: setupMock.elasticsearch, + security: setupMock.security, + usageCollection: {} as any, + __LEGACY: { plugins: { xpack_main: { status: new EventEmitter() } } } as any, +}); export const createMockStartDeps = (startMock?: any): ReportingStartDeps => ({ data: startMock.data, + elasticsearch: startMock.elasticsearch, __LEGACY: {} as any, }); diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts index 531e1dcaf84e0..bb7851ba036a9 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts @@ -3,10 +3,36 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import { memoize } from 'lodash'; import { ServerFacade } from '../types'; -export const createMockServer = (): ServerFacade => { - const mockServer = {}; - return mockServer as any; +export const createMockServer = ({ settings = {} }: any): ServerFacade => { + const mockServer = { + config: memoize(() => ({ get: jest.fn() })), + info: { + protocol: 'http', + }, + plugins: { + elasticsearch: { + getCluster: memoize(() => { + return { + callWithRequest: jest.fn(), + }; + }), + }, + }, + }; + + const defaultSettings: any = { + 'xpack.reporting.encryptionKey': 'testencryptionkey', + 'server.basePath': '/sbp', + 'server.host': 'localhost', + 'server.port': 5601, + 'xpack.reporting.kibanaServer': {}, + }; + mockServer.config().get.mockImplementation((key: any) => { + return key in settings ? settings[key] : defaultSettings[key]; + }); + + return (mockServer as unknown) as ServerFacade; }; diff --git a/x-pack/legacy/plugins/reporting/types.d.ts b/x-pack/legacy/plugins/reporting/types.d.ts index 76253752be1b7..238079ba92a29 100644 --- a/x-pack/legacy/plugins/reporting/types.d.ts +++ b/x-pack/legacy/plugins/reporting/types.d.ts @@ -7,11 +7,14 @@ import { EventEmitter } from 'events'; import { ResponseObject } from 'hapi'; import { Legacy } from 'kibana'; +import { ElasticsearchServiceSetup } from 'kibana/server'; import { CallCluster } from '../../../../src/legacy/core_plugins/elasticsearch'; import { CancellationToken } from './common/cancellation_token'; -import { ReportingCore } from './server/core'; +import { HeadlessChromiumDriverFactory } from './server/browsers/chromium/driver_factory'; +import { BrowserType } from './server/browsers/types'; import { LevelLogger } from './server/lib/level_logger'; -import { LegacySetup } from './server/types'; +import { ReportingCore } from './server/core'; +import { LegacySetup, ReportingStartDeps, ReportingSetup, ReportingStart } from './server/types'; export type Job = EventEmitter & { id: string; @@ -22,8 +25,8 @@ export type Job = EventEmitter & { export interface NetworkPolicyRule { allow: boolean; - protocol?: string; - host?: string; + protocol: string; + host: string; } export interface NetworkPolicy { @@ -90,6 +93,51 @@ export type ReportingResponseToolkit = Legacy.ResponseToolkit; export type ESCallCluster = CallCluster; +/* + * Reporting Config + */ + +export interface CaptureConfig { + browser: { + type: BrowserType; + autoDownload: boolean; + chromium: BrowserConfig; + }; + maxAttempts: number; + networkPolicy: NetworkPolicy; + loadDelay: number; + timeouts: { + openUrl: number; + waitForElements: number; + renderComplet: number; + }; +} + +export interface BrowserConfig { + inspect: boolean; + userDataDir: string; + viewport: { width: number; height: number }; + disableSandbox: boolean; + proxy: { + enabled: boolean; + server: string; + bypass?: string[]; + }; +} + +export interface QueueConfig { + indexInterval: string; + pollEnabled: boolean; + pollInterval: number; + pollIntervalErrorMultiplier: number; + timeout: number; +} + +export interface ScrollConfig { + duration: string; + size: number; +} + export interface ElementPosition { boundingClientRect: { // modern browsers support x/y, but older ones don't @@ -226,10 +274,14 @@ export interface ESQueueInstance { export type CreateJobFactory = ( reporting: ReportingCore, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, logger: LevelLogger -) => Promise; +) => CreateJobFnType; export type ExecuteJobFactory = ( reporting: ReportingCore, + server: ServerFacade, + elasticsearch: ElasticsearchServiceSetup, logger: LevelLogger ) => Promise; diff --git a/x-pack/legacy/plugins/siem/common/constants.ts b/x-pack/legacy/plugins/siem/common/constants.ts index c3fc4aea77863..662fb8fb8ef68 100644 --- a/x-pack/legacy/plugins/siem/common/constants.ts +++ b/x-pack/legacy/plugins/siem/common/constants.ts @@ -52,13 +52,21 @@ export const IP_REPUTATION_LINKS_SETTING_DEFAULT = `[ */ export const SIGNALS_ID = `${APP_ID}.signals`; +/** + * Id for the notifications alerting type + */ +export const NOTIFICATIONS_ID = `${APP_ID}.notifications`; + /** * Special internal structure for tags for signals. This is used * to filter out tags that have internal structures within them. */ export const INTERNAL_IDENTIFIER = '__internal'; export const INTERNAL_RULE_ID_KEY = `${INTERNAL_IDENTIFIER}_rule_id`; +export const INTERNAL_RULE_ALERT_ID_KEY = `${INTERNAL_IDENTIFIER}_rule_alert_id`; export const INTERNAL_IMMUTABLE_KEY = `${INTERNAL_IDENTIFIER}_immutable`; +export const INTERNAL_NOTIFICATION_ID_KEY = `${INTERNAL_IDENTIFIER}_notification_id`; +export const INTERNAL_NOTIFICATION_RULE_ID_KEY = `${INTERNAL_IDENTIFIER}_notification_rule_id`; /** * Detection engine routes @@ -74,6 +82,7 @@ export const DETECTION_ENGINE_PREPACKAGED_RULES_STATUS_URL = `${DETECTION_ENGINE export const TIMELINE_URL = '/api/timeline'; export const TIMELINE_EXPORT_URL = `${TIMELINE_URL}/_export`; +export const TIMELINE_IMPORT_URL = `${TIMELINE_URL}/_import`; /** * Default signals index key for kibana.dev.yml @@ -87,3 +96,20 @@ export const DETECTION_ENGINE_QUERY_SIGNALS_URL = `${DETECTION_ENGINE_SIGNALS_UR * Common naming convention for an unauthenticated user */ export const UNAUTHENTICATED_USER = 'Unauthenticated'; + +/* + Licensing requirements + */ +export const MINIMUM_ML_LICENSE = 'platinum'; + +/* + Rule notifications options +*/ +export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [ + '.email', + '.slack', + '.pagerduty', + '.webhook', +]; +export const NOTIFICATION_THROTTLE_NO_ACTIONS = 'no_actions'; +export const NOTIFICATION_THROTTLE_RULE = 'rule'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts b/x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.test.ts similarity index 100% rename from x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.test.ts rename to x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.test.ts diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts b/x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.ts similarity index 83% rename from x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts rename to x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.ts index c1c17d2c70836..aeb4d53933022 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/transform_actions.ts +++ b/x-pack/legacy/plugins/siem/common/detection_engine/transform_actions.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertAction } from '../../../../../../../plugins/alerting/common'; -import { RuleAlertAction } from '../types'; +import { AlertAction } from '../../../../../plugins/alerting/common'; +import { RuleAlertAction } from './types'; export const transformRuleToAlertAction = ({ group, diff --git a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts b/x-pack/legacy/plugins/siem/common/detection_engine/types.ts similarity index 59% rename from x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts rename to x-pack/legacy/plugins/siem/common/detection_engine/types.ts index f8669a4bf813f..0de370b11cdaf 100644 --- a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts +++ b/x-pack/legacy/plugins/siem/common/detection_engine/types.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -/* eslint-disable */ +import { AlertAction } from '../../../../../plugins/alerting/common'; -export { - ActionFactoryDefinition -} from '../../../../../../src/plugins/ui_actions/public/actions/action_factory_definition'; +export type RuleAlertAction = Omit & { + action_type_id: string; +}; diff --git a/x-pack/legacy/plugins/siem/cypress/screens/create_new_rule.ts b/x-pack/legacy/plugins/siem/cypress/screens/create_new_rule.ts index 1ac9278c3ce1c..a61dc3fe61814 100644 --- a/x-pack/legacy/plugins/siem/cypress/screens/create_new_rule.ts +++ b/x-pack/legacy/plugins/siem/cypress/screens/create_new_rule.ts @@ -20,7 +20,9 @@ export const CREATE_AND_ACTIVATE_BTN = '[data-test-subj="create-activate"]'; export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]'; -export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="continue"]'; +export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="define-continue"]'; + +export const SCHEDULE_CONTINUE_BUTTON = '[data-test-subj="schedule-continue"]'; export const FALSE_POSITIVES_INPUT = '[data-test-subj="detectionEngineStepAboutRuleFalsePositives"] input'; @@ -43,7 +45,8 @@ export const RULE_DESCRIPTION_INPUT = export const RULE_NAME_INPUT = '[data-test-subj="detectionEngineStepAboutRuleName"] [data-test-subj="input"]'; -export const SEVERITY_DROPDOWN = '[data-test-subj="select"]'; +export const SEVERITY_DROPDOWN = + '[data-test-subj="detectionEngineStepAboutRuleSeverity"] [data-test-subj="select"]'; export const TAGS_INPUT = '[data-test-subj="detectionEngineStepAboutRuleTags"] [data-test-subj="comboBoxSearchInput"]'; diff --git a/x-pack/legacy/plugins/siem/cypress/tasks/create_new_rule.ts b/x-pack/legacy/plugins/siem/cypress/tasks/create_new_rule.ts index 6bd5e0887e2fc..ccaa065754b5b 100644 --- a/x-pack/legacy/plugins/siem/cypress/tasks/create_new_rule.ts +++ b/x-pack/legacy/plugins/siem/cypress/tasks/create_new_rule.ts @@ -21,11 +21,13 @@ import { REFERENCE_URLS_INPUT, RULE_DESCRIPTION_INPUT, RULE_NAME_INPUT, + SCHEDULE_CONTINUE_BUTTON, SEVERITY_DROPDOWN, TAGS_INPUT, } from '../screens/create_new_rule'; export const createAndActivateRule = () => { + cy.get(SCHEDULE_CONTINUE_BUTTON).click({ force: true }); cy.get(CREATE_AND_ACTIVATE_BTN).click({ force: true }); cy.get(CREATE_AND_ACTIVATE_BTN).should('not.exist'); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/import_data_modal/__snapshots__/index.test.tsx.snap similarity index 75% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/__snapshots__/index.test.tsx.snap rename to x-pack/legacy/plugins/siem/public/components/import_data_modal/__snapshots__/index.test.tsx.snap index 6b5ea2c5390f1..6503dd8dfb508 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/import_data_modal/__snapshots__/index.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ImportRuleModal renders correctly against snapshot 1`] = ` +exports[`ImportDataModal renders correctly against snapshot 1`] = ` - Import rule + title @@ -17,7 +17,7 @@ exports[`ImportRuleModal renders correctly against snapshot 1`] = ` size="s" >

- Select a SIEM rule (as exported from the Detection Engine UI) to import + description

@@ -39,9 +39,9 @@ exports[`ImportRuleModal renders correctly against snapshot 1`] = ` checked={false} compressed={false} disabled={false} - id="rule-overwrite-saved-object" + id="import-data-modal-checkbox-label" indeterminate={false} - label="Automatically overwrite saved objects with the same rule ID" + label="checkBoxLabel" onChange={[Function]} />
@@ -56,7 +56,7 @@ exports[`ImportRuleModal renders correctly against snapshot 1`] = ` fill={true} onClick={[Function]} > - Import rule + submitBtnText
diff --git a/x-pack/legacy/plugins/siem/public/components/import_data_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/import_data_modal/index.test.tsx new file mode 100644 index 0000000000000..85dcf9eeb3e5e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/import_data_modal/index.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; +import { ImportDataModalComponent } from './index'; +jest.mock('../../lib/kibana'); + +describe('ImportDataModal', () => { + test('renders correctly against snapshot', () => { + const wrapper = shallow( + 'successMessage')} + title="title" + /> + ); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/import_data_modal/index.tsx similarity index 65% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx rename to x-pack/legacy/plugins/siem/public/components/import_data_modal/index.tsx index 49a181a1cd897..503710f1ee8aa 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/import_data_modal/index.tsx @@ -21,29 +21,49 @@ import { } from '@elastic/eui'; import React, { useCallback, useState } from 'react'; -import { importRules } from '../../../../../containers/detection_engine/rules'; +import { ImportRulesResponse, ImportRulesProps } from '../../containers/detection_engine/rules'; import { displayErrorToast, displaySuccessToast, useStateToaster, errorToToaster, -} from '../../../../../components/toasters'; +} from '../toasters'; import * as i18n from './translations'; -interface ImportRuleModalProps { - showModal: boolean; +interface ImportDataModalProps { + checkBoxLabel: string; closeModal: () => void; + description: string; + errorMessage: string; + failedDetailed: (id: string, statusCode: number, message: string) => string; importComplete: () => void; + importData: (arg: ImportRulesProps) => Promise; + showCheckBox: boolean; + showModal: boolean; + submitBtnText: string; + subtitle: string; + successMessage: (totalCount: number) => string; + title: string; } /** * Modal component for importing Rules from a json file */ -export const ImportRuleModalComponent = ({ - showModal, +export const ImportDataModalComponent = ({ + checkBoxLabel, closeModal, + description, + errorMessage, + failedDetailed, importComplete, -}: ImportRuleModalProps) => { + importData, + showCheckBox = true, + showModal, + submitBtnText, + subtitle, + successMessage, + title, +}: ImportDataModalProps) => { const [selectedFiles, setSelectedFiles] = useState(null); const [isImporting, setIsImporting] = useState(false); const [overwrite, setOverwrite] = useState(false); @@ -61,7 +81,7 @@ export const ImportRuleModalComponent = ({ const abortCtrl = new AbortController(); try { - const importResponse = await importRules({ + const importResponse = await importData({ fileToImport: selectedFiles[0], overwrite, signal: abortCtrl.signal, @@ -70,23 +90,20 @@ export const ImportRuleModalComponent = ({ // TODO: Improve error toast details for better debugging failed imports // e.g. When success == true && success_count === 0 that means no rules were overwritten, etc if (importResponse.success) { - displaySuccessToast( - i18n.SUCCESSFULLY_IMPORTED_RULES(importResponse.success_count), - dispatchToaster - ); + displaySuccessToast(successMessage(importResponse.success_count), dispatchToaster); } if (importResponse.errors.length > 0) { const formattedErrors = importResponse.errors.map(e => - i18n.IMPORT_FAILED_DETAILED(e.rule_id, e.error.status_code, e.error.message) + failedDetailed(e.rule_id, e.error.status_code, e.error.message) ); - displayErrorToast(i18n.IMPORT_FAILED, formattedErrors, dispatchToaster); + displayErrorToast(errorMessage, formattedErrors, dispatchToaster); } importComplete(); cleanupAndCloseModal(); } catch (error) { cleanupAndCloseModal(); - errorToToaster({ title: i18n.IMPORT_FAILED, error, dispatchToaster }); + errorToToaster({ title: errorMessage, error, dispatchToaster }); } } }, [selectedFiles, overwrite]); @@ -102,18 +119,18 @@ export const ImportRuleModalComponent = ({ - {i18n.IMPORT_RULE} + {title} -

{i18n.SELECT_RULE}

+

{description}

{ setSelectedFiles(files && files.length > 0 ? files : null); }} @@ -122,12 +139,14 @@ export const ImportRuleModalComponent = ({ isLoading={isImporting} /> - setOverwrite(!overwrite)} - /> + {showCheckBox && ( + setOverwrite(!overwrite)} + /> + )}
@@ -137,7 +156,7 @@ export const ImportRuleModalComponent = ({ disabled={selectedFiles == null || isImporting} fill > - {i18n.IMPORT_RULE} + {submitBtnText}
@@ -147,8 +166,8 @@ export const ImportRuleModalComponent = ({ ); }; -ImportRuleModalComponent.displayName = 'ImportRuleModalComponent'; +ImportDataModalComponent.displayName = 'ImportDataModalComponent'; -export const ImportRuleModal = React.memo(ImportRuleModalComponent); +export const ImportDataModal = React.memo(ImportDataModalComponent); -ImportRuleModal.displayName = 'ImportRuleModal'; +ImportDataModal.displayName = 'ImportDataModal'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts b/x-pack/legacy/plugins/siem/public/components/import_data_modal/translations.ts similarity index 65% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts rename to x-pack/legacy/plugins/siem/public/components/import_data_modal/translations.ts index 4e2e5eb7092e4..3fe8f2e3ee4bb 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts +++ b/x-pack/legacy/plugins/siem/public/components/import_data_modal/translations.ts @@ -6,9 +6,9 @@ import { i18n } from '@kbn/i18n'; -export const txtDisplayName = i18n.translate( - 'xpack.dashboard.panel.openFlyoutEditDrilldown.displayName', +export const CANCEL_BUTTON = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.cancelTitle', { - defaultMessage: 'Manage drilldowns', + defaultMessage: 'Cancel', } ); diff --git a/x-pack/legacy/plugins/siem/public/components/markdown/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/markdown/__snapshots__/index.test.tsx.snap index 4b02d23568d26..ce0c797c2b2b4 100644 --- a/x-pack/legacy/plugins/siem/public/components/markdown/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/markdown/__snapshots__/index.test.tsx.snap @@ -10,6 +10,7 @@ exports[`Markdown markdown links it renders the expected content containing a li rawSourcePos={false} renderers={ Object { + "blockquote": [Function], "link": [Function], "root": [Function], "table": [Function], @@ -35,6 +36,7 @@ exports[`Markdown markdown tables it renders the expected table content 1`] = ` rawSourcePos={false} renderers={ Object { + "blockquote": [Function], "link": [Function], "root": [Function], "table": [Function], diff --git a/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx b/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx index 1368c13619d6b..8e051685af56d 100644 --- a/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx @@ -9,12 +9,20 @@ import { EuiLink, EuiTableRow, EuiTableRowCell, EuiText, EuiToolTip } from '@elastic/eui'; import React from 'react'; import ReactMarkdown from 'react-markdown'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; const TableHeader = styled.thead` font-weight: bold; `; +const MyBlockquote = styled.div` + ${({ theme }) => css` + padding: 0 ${theme.eui.euiSize}; + color: ${theme.eui.euiColorMediumShade}; + border-left: ${theme.eui.euiSizeXS} solid ${theme.eui.euiColorLightShade}; + `} +`; + TableHeader.displayName = 'TableHeader'; /** prevents links to the new pages from accessing `window.opener` */ @@ -63,6 +71,9 @@ export const Markdown = React.memo<{ ), + blockquote: ({ children }: { children: React.ReactNode[] }) => ( + {children} + ), }; return ( diff --git a/x-pack/legacy/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts b/x-pack/legacy/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts index ad59d3dc436a7..c4ca7dc203619 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useState, useEffect, useContext } from 'react'; +import { useState, useEffect } from 'react'; import { anomaliesTableData } from '../api/anomalies_table_data'; import { InfluencerInput, Anomalies, CriteriaFields } from '../types'; import { hasMlUserPermissions } from '../permissions/has_ml_user_permissions'; -import { MlCapabilitiesContext } from '../permissions/ml_capabilities_provider'; import { useSiemJobs } from '../../ml_popover/hooks/use_siem_jobs'; +import { useMlCapabilities } from '../../ml_popover/hooks/use_ml_capabilities'; import { useStateToaster, errorToToaster } from '../../toasters'; import * as i18n from './translations'; @@ -59,7 +59,7 @@ export const useAnomaliesTableData = ({ const [tableData, setTableData] = useState(null); const [, siemJobs] = useSiemJobs(true); const [loading, setLoading] = useState(true); - const capabilities = useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const userPermissions = hasMlUserPermissions(capabilities); const [, dispatchToaster] = useStateToaster(); const timeZone = useTimeZone(); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/helpers/index.test.ts b/x-pack/legacy/plugins/siem/public/components/ml/helpers/index.test.ts new file mode 100644 index 0000000000000..693f0bd0dd0fd --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml/helpers/index.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isJobStarted, isJobLoading, isJobFailed } from './'; + +describe('isJobStarted', () => { + test('returns false if only jobState is enabled', () => { + expect(isJobStarted('started', 'closing')).toBe(false); + }); + + test('returns false if only datafeedState is enabled', () => { + expect(isJobStarted('stopping', 'opened')).toBe(false); + }); + + test('returns true if both enabled states are provided', () => { + expect(isJobStarted('started', 'opened')).toBe(true); + }); +}); + +describe('isJobLoading', () => { + test('returns true if both loading states are not provided', () => { + expect(isJobLoading('started', 'closing')).toBe(true); + }); + + test('returns true if only jobState is loading', () => { + expect(isJobLoading('starting', 'opened')).toBe(true); + }); + + test('returns true if only datafeedState is loading', () => { + expect(isJobLoading('started', 'opening')).toBe(true); + }); + + test('returns false if both disabling states are provided', () => { + expect(isJobLoading('stopping', 'closing')).toBe(true); + }); +}); + +describe('isJobFailed', () => { + test('returns true if only jobState is failure/deleted', () => { + expect(isJobFailed('failed', 'stopping')).toBe(true); + }); + + test('returns true if only dataFeed is failure/deleted', () => { + expect(isJobFailed('started', 'deleted')).toBe(true); + }); + + test('returns true if both enabled states are failure/deleted', () => { + expect(isJobFailed('failed', 'deleted')).toBe(true); + }); + + test('returns false only if both states are not failure/deleted', () => { + expect(isJobFailed('opened', 'stopping')).toBe(false); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/ml/helpers/index.ts b/x-pack/legacy/plugins/siem/public/components/ml/helpers/index.ts new file mode 100644 index 0000000000000..c06596b49317d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/ml/helpers/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// Based on ML Job/Datafeed States from x-pack/legacy/plugins/ml/common/constants/states.js +const enabledStates = ['started', 'opened']; +const loadingStates = ['starting', 'stopping', 'opening', 'closing']; +const failureStates = ['deleted', 'failed']; + +export const isJobStarted = (jobState: string, datafeedState: string): boolean => { + return enabledStates.includes(jobState) && enabledStates.includes(datafeedState); +}; + +export const isJobLoading = (jobState: string, datafeedState: string): boolean => { + return loadingStates.includes(jobState) || loadingStates.includes(datafeedState); +}; + +export const isJobFailed = (jobState: string, datafeedState: string): boolean => { + return failureStates.includes(jobState) || failureStates.includes(datafeedState); +}; diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/anomalies_host_table.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/anomalies_host_table.tsx index 9e58e39a08f67..16bde076ef763 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/anomalies_host_table.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/anomalies_host_table.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext } from 'react'; +import React from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; @@ -16,7 +16,7 @@ import { Loader } from '../../loader'; import { getIntervalFromAnomalies } from '../anomaly/get_interval_from_anomalies'; import { AnomaliesHostTableProps } from '../types'; import { hasMlUserPermissions } from '../permissions/has_ml_user_permissions'; -import { MlCapabilitiesContext } from '../permissions/ml_capabilities_provider'; +import { useMlCapabilities } from '../../ml_popover/hooks/use_ml_capabilities'; import { BasicTable } from './basic_table'; import { hostEquality } from './host_equality'; import { getCriteriaFromHostType } from '../criteria/get_criteria_from_host_type'; @@ -37,7 +37,7 @@ const AnomaliesHostTableComponent: React.FC = ({ skip, type, }) => { - const capabilities = useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const [loading, tableData] = useAnomaliesTableData({ startDate, endDate, diff --git a/x-pack/legacy/plugins/siem/public/components/ml/tables/anomalies_network_table.tsx b/x-pack/legacy/plugins/siem/public/components/ml/tables/anomalies_network_table.tsx index 05f3044ff2929..bba6355f0b8b9 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml/tables/anomalies_network_table.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml/tables/anomalies_network_table.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext } from 'react'; +import React from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; @@ -13,8 +13,8 @@ import { convertAnomaliesToNetwork } from './convert_anomalies_to_network'; import { Loader } from '../../loader'; import { AnomaliesNetworkTableProps } from '../types'; import { getAnomaliesNetworkTableColumnsCurated } from './get_anomalies_network_table_columns'; +import { useMlCapabilities } from '../../ml_popover/hooks/use_ml_capabilities'; import { hasMlUserPermissions } from '../permissions/has_ml_user_permissions'; -import { MlCapabilitiesContext } from '../permissions/ml_capabilities_provider'; import { BasicTable } from './basic_table'; import { networkEquality } from './network_equality'; import { getCriteriaFromNetworkType } from '../criteria/get_criteria_from_network_type'; @@ -35,7 +35,7 @@ const AnomaliesNetworkTableComponent: React.FC = ({ type, flowTarget, }) => { - const capabilities = useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const [loading, tableData] = useAnomaliesTableData({ startDate, endDate, diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_ml_capabilities.tsx similarity index 55% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts rename to x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_ml_capabilities.tsx index 98b746bafd24a..d897b2554b4fd 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_ml_capabilities.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; +import { useContext } from 'react'; -export const txtGoToDashboard = i18n.translate('xpack.dashboard.drilldown.goToDashboard', { - defaultMessage: 'Go to Dashboard', -}); +import { MlCapabilitiesContext } from '../../ml/permissions/ml_capabilities_provider'; + +export const useMlCapabilities = () => useContext(MlCapabilitiesContext); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx index 4e4cdbfc109a9..9a82859066f54 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx @@ -4,18 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useContext, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { checkRecognizer, getJobsSummary, getModules } from '../api'; import { SiemJob } from '../types'; import { hasMlUserPermissions } from '../../ml/permissions/has_ml_user_permissions'; -import { MlCapabilitiesContext } from '../../ml/permissions/ml_capabilities_provider'; import { errorToToaster, useStateToaster } from '../../toasters'; import { useUiSetting$ } from '../../../lib/kibana'; import { DEFAULT_INDEX_KEY } from '../../../../common/constants'; import * as i18n from './translations'; import { createSiemJobs } from './use_siem_jobs_helpers'; +import { useMlCapabilities } from './use_ml_capabilities'; type Return = [boolean, SiemJob[]]; @@ -30,8 +30,8 @@ type Return = [boolean, SiemJob[]]; export const useSiemJobs = (refetchData: boolean): Return => { const [siemJobs, setSiemJobs] = useState([]); const [loading, setLoading] = useState(true); - const capabilities = useContext(MlCapabilitiesContext); - const userPermissions = hasMlUserPermissions(capabilities); + const mlCapabilities = useMlCapabilities(); + const userPermissions = hasMlUserPermissions(mlCapabilities); const [siemDefaultIndex] = useUiSetting$(DEFAULT_INDEX_KEY); const [, dispatchToaster] = useStateToaster(); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.test.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.test.tsx index 1186573e3e209..ade8c6fe80525 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.test.tsx @@ -7,7 +7,7 @@ import { shallow, mount } from 'enzyme'; import React from 'react'; -import { isChecked, isFailure, isJobLoading, JobSwitchComponent } from './job_switch'; +import { JobSwitchComponent } from './job_switch'; import { cloneDeep } from 'lodash/fp'; import { mockSiemJobs } from '../__mocks__/api'; import { SiemJob } from '../types'; @@ -75,54 +75,4 @@ describe('JobSwitch', () => { ); expect(wrapper.find('[data-test-subj="job-switch"]').exists()).toBe(false); }); - - describe('isChecked', () => { - test('returns false if only jobState is enabled', () => { - expect(isChecked('started', 'closing')).toBe(false); - }); - - test('returns false if only datafeedState is enabled', () => { - expect(isChecked('stopping', 'opened')).toBe(false); - }); - - test('returns true if both enabled states are provided', () => { - expect(isChecked('started', 'opened')).toBe(true); - }); - }); - - describe('isJobLoading', () => { - test('returns true if both loading states are not provided', () => { - expect(isJobLoading('started', 'closing')).toBe(true); - }); - - test('returns true if only jobState is loading', () => { - expect(isJobLoading('starting', 'opened')).toBe(true); - }); - - test('returns true if only datafeedState is loading', () => { - expect(isJobLoading('started', 'opening')).toBe(true); - }); - - test('returns false if both disabling states are provided', () => { - expect(isJobLoading('stopping', 'closing')).toBe(true); - }); - }); - - describe('isFailure', () => { - test('returns true if only jobState is failure/deleted', () => { - expect(isFailure('failed', 'stopping')).toBe(true); - }); - - test('returns true if only dataFeed is failure/deleted', () => { - expect(isFailure('started', 'deleted')).toBe(true); - }); - - test('returns true if both enabled states are failure/deleted', () => { - expect(isFailure('failed', 'deleted')).toBe(true); - }); - - test('returns false only if both states are not failure/deleted', () => { - expect(isFailure('opened', 'stopping')).toBe(false); - }); - }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx index 39c48413737e2..e5066eef18c8b 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx @@ -8,6 +8,7 @@ import styled from 'styled-components'; import React, { useState, useCallback } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSwitch } from '@elastic/eui'; import { SiemJob } from '../types'; +import { isJobLoading, isJobStarted, isJobFailed } from '../../ml/helpers'; const StaticSwitch = styled(EuiSwitch)` .euiSwitch__thumb, @@ -24,23 +25,6 @@ export interface JobSwitchProps { onJobStateChange: (job: SiemJob, latestTimestampMs: number, enable: boolean) => Promise; } -// Based on ML Job/Datafeed States from x-pack/legacy/plugins/ml/common/constants/states.js -const enabledStates = ['started', 'opened']; -const loadingStates = ['starting', 'stopping', 'opening', 'closing']; -const failureStates = ['deleted', 'failed']; - -export const isChecked = (jobState: string, datafeedState: string): boolean => { - return enabledStates.includes(jobState) && enabledStates.includes(datafeedState); -}; - -export const isJobLoading = (jobState: string, datafeedState: string): boolean => { - return loadingStates.includes(jobState) || loadingStates.includes(datafeedState); -}; - -export const isFailure = (jobState: string, datafeedState: string): boolean => { - return failureStates.includes(jobState) || failureStates.includes(datafeedState); -}; - export const JobSwitchComponent = ({ job, isSiemJobsLoading, @@ -64,8 +48,8 @@ export const JobSwitchComponent = ({ ) : ( { const [filterProperties, setFilterProperties] = useState(defaultFilterProps); const [isLoadingSiemJobs, siemJobs] = useSiemJobs(refreshToggle); const [, dispatchToaster] = useStateToaster(); - const capabilities = useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const docLinks = useKibana().services.docLinks; // Enable/Disable Job & Datafeed -- passed to JobsTable for use as callback on JobSwitch diff --git a/x-pack/legacy/plugins/siem/public/components/ml_popover/types.ts b/x-pack/legacy/plugins/siem/public/components/ml_popover/types.ts index f3bf78fdbb94c..991c82cf701e8 100644 --- a/x-pack/legacy/plugins/siem/public/components/ml_popover/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/ml_popover/types.ts @@ -5,6 +5,7 @@ */ import { MlError } from '../ml/types'; +import { AuditMessageBase } from '../../../../../../plugins/ml/common/types/audit_message'; export interface Group { id: string; @@ -101,6 +102,7 @@ export interface MlSetupArgs { * Representation of an ML Job as returned from the `ml/jobs/jobs_summary` API */ export interface JobSummary { + auditMessage?: AuditMessageBase; datafeedId: string; datafeedIndices: string[]; datafeedState: string; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx index 6d00edf28a88f..6c2cd21d808b7 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx @@ -52,7 +52,10 @@ interface OwnProps { } export type OpenTimelineOwnProps = OwnProps & - Pick & + Pick< + OpenTimelineProps, + 'defaultPageSize' | 'title' | 'importCompleteToggle' | 'setImportCompleteToggle' + > & PropsFromRedux; /** Returns a collection of selected timeline ids */ @@ -74,7 +77,9 @@ export const StatefulOpenTimelineComponent = React.memo( defaultPageSize, hideActions = [], isModal = false, + importCompleteToggle, onOpenTimeline, + setImportCompleteToggle, timeline, title, updateTimeline, @@ -264,6 +269,7 @@ export const StatefulOpenTimelineComponent = React.memo( defaultPageSize={defaultPageSize} isLoading={loading} itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} + importCompleteToggle={importCompleteToggle} onAddTimelinesToFavorites={undefined} onDeleteSelected={onDeleteSelected} onlyFavorites={onlyFavorites} @@ -278,6 +284,7 @@ export const StatefulOpenTimelineComponent = React.memo( query={search} refetch={refetch} searchResults={timelines} + setImportCompleteToggle={setImportCompleteToggle} selectedItems={selectedItems} sortDirection={sortDirection} sortField={sortField} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index b1b100349eb86..8b3da4427a362 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -12,8 +12,10 @@ import { OpenTimelineProps, OpenTimelineResult } from './types'; import { SearchRow } from './search_row'; import { TimelinesTable } from './timelines_table'; import { TitleRow } from './title_row'; - +import { ImportDataModal } from '../import_data_modal'; import * as i18n from './translations'; +import { importTimelines } from '../../containers/timeline/all/api'; + import { UtilityBarGroup, UtilityBarText, @@ -31,6 +33,7 @@ export const OpenTimeline = React.memo( defaultPageSize, isLoading, itemIdToExpandedNotesRowMap, + importCompleteToggle, onAddTimelinesToFavorites, onDeleteSelected, onlyFavorites, @@ -47,6 +50,7 @@ export const OpenTimeline = React.memo( searchResults, selectedItems, sortDirection, + setImportCompleteToggle, sortField, title, totalSearchResultsCount, @@ -93,9 +97,25 @@ export const OpenTimeline = React.memo( ); const onRefreshBtnClick = useCallback(() => { - if (typeof refetch === 'function') refetch(); + if (refetch != null) { + refetch(); + } }, [refetch]); + const handleCloseModal = useCallback(() => { + if (setImportCompleteToggle != null) { + setImportCompleteToggle(false); + } + }, [setImportCompleteToggle]); + const handleComplete = useCallback(() => { + if (setImportCompleteToggle != null) { + setImportCompleteToggle(false); + } + if (refetch != null) { + refetch(); + } + }, [setImportCompleteToggle, refetch]); + return ( <> ( onComplete={onCompleteEditTimelineAction} title={actionItem?.title ?? i18n.UNTITLED_TIMELINE} /> + defaultMessage: 'Successfully exported {totalTimelines, plural, =0 {all timelines} =1 {{totalTimelines} timeline} other {{totalTimelines} timelines}}', }); + +export const IMPORT_TIMELINE_BTN_TITLE = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.importTimelineTitle', + { + defaultMessage: 'Import timeline', + } +); + +export const SELECT_TIMELINE = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.selectTimelineDescription', + { + defaultMessage: 'Select a SIEM timeline (as exported from the Timeline view) to import', + } +); + +export const INITIAL_PROMPT_TEXT = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.initialPromptTextDescription', + { + defaultMessage: 'Select or drag and drop a valid timelines_export.ndjson file', + } +); + +export const OVERWRITE_WITH_SAME_NAME = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.overwriteDescription', + { + defaultMessage: 'Automatically overwrite saved objects with the same timeline ID', + } +); + +export const SUCCESSFULLY_IMPORTED_TIMELINES = (totalCount: number) => + i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.successfullyImportedTimelinesTitle', + { + values: { totalCount }, + defaultMessage: + 'Successfully imported {totalCount} {totalCount, plural, =1 {timeline} other {timelines}}', + } + ); + +export const IMPORT_FAILED = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.importFailedTitle', + { + defaultMessage: 'Failed to import timelines', + } +); + +export const IMPORT_TIMELINE = i18n.translate( + 'xpack.siem.timelines.components.importTimelineModal.importTitle', + { + defaultMessage: 'Import timeline…', + } +); + +export const IMPORT_FAILED_DETAILED = (id: string, statusCode: number, message: string) => + i18n.translate('xpack.siem.timelines.components.importTimelineModal.importFailedDetailedTitle', { + values: { id, statusCode, message }, + defaultMessage: 'Timeline ID: {id}\n Status Code: {statusCode}\n Message: {message}', + }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts index b466ea32799d9..1265c056ec506 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts @@ -120,6 +120,8 @@ export interface OpenTimelineProps { isLoading: boolean; /** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */ itemIdToExpandedNotesRowMap: Record; + /** Display import timelines modal*/ + importCompleteToggle?: boolean; /** If this callback is specified, a "Favorite Selected" button will be displayed, and this callback will be invoked when the button is clicked */ onAddTimelinesToFavorites?: OnAddTimelinesToFavorites; /** If this callback is specified, a "Delete Selected" button will be displayed, and this callback will be invoked when the button is clicked */ @@ -144,13 +146,14 @@ export interface OpenTimelineProps { pageSize: number; /** The currently applied search criteria */ query: string; - /** Refetch timelines data */ + /** Refetch table */ refetch?: Refetch; - /** The results of executing a search */ searchResults: OpenTimelineResult[]; /** the currently-selected timelines in the table */ selectedItems: OpenTimelineResult[]; + /** Toggle export timelines modal*/ + setImportCompleteToggle?: React.Dispatch>; /** the requested sort direction of the query results */ sortDirection: 'asc' | 'desc'; /** the requested field to sort on */ diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/host_overview/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/hosts/host_overview/index.tsx index bf32a33af1eac..4d0e6a737d303 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/host_overview/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/host_overview/index.tsx @@ -8,7 +8,7 @@ import { EuiFlexItem } from '@elastic/eui'; import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; import { getOr } from 'lodash/fp'; -import React, { useContext } from 'react'; +import React from 'react'; import { DEFAULT_DARK_MODE } from '../../../../../common/constants'; import { DescriptionList } from '../../../../../common/utility_types'; @@ -19,8 +19,8 @@ import { InspectButton, InspectButtonContainer } from '../../../inspect'; import { HostItem } from '../../../../graphql/types'; import { Loader } from '../../../loader'; import { IPDetailsLink } from '../../../links'; -import { MlCapabilitiesContext } from '../../../ml/permissions/ml_capabilities_provider'; import { hasMlUserPermissions } from '../../../ml/permissions/has_ml_user_permissions'; +import { useMlCapabilities } from '../../../ml_popover/hooks/use_ml_capabilities'; import { AnomalyScores } from '../../../ml/score/anomaly_scores'; import { Anomalies, NarrowDateRange } from '../../../ml/types'; import { DescriptionListStyled, OverviewWrapper } from '../../index'; @@ -56,7 +56,7 @@ export const HostOverview = React.memo( anomaliesData, narrowDateRange, }) => { - const capabilities = useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const userPermissions = hasMlUserPermissions(capabilities); const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx index 901b82210a661..56b59ca97156f 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx @@ -7,7 +7,7 @@ import { EuiFlexItem } from '@elastic/eui'; import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; -import React, { useContext } from 'react'; +import React from 'react'; import { DEFAULT_DARK_MODE } from '../../../../../common/constants'; import { DescriptionList } from '../../../../../common/utility_types'; @@ -30,7 +30,7 @@ import { DescriptionListStyled, OverviewWrapper } from '../../index'; import { Loader } from '../../../loader'; import { Anomalies, NarrowDateRange } from '../../../ml/types'; import { AnomalyScores } from '../../../ml/score/anomaly_scores'; -import { MlCapabilitiesContext } from '../../../ml/permissions/ml_capabilities_provider'; +import { useMlCapabilities } from '../../../ml_popover/hooks/use_ml_capabilities'; import { hasMlUserPermissions } from '../../../ml/permissions/has_ml_user_permissions'; import { InspectButton, InspectButtonContainer } from '../../../inspect'; @@ -71,7 +71,7 @@ export const IpOverview = React.memo( anomaliesData, narrowDateRange, }) => { - const capabilities = useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const userPermissions = hasMlUserPermissions(capabilities); const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); const typeData: Overview = data[flowTarget]!; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/__mocks__/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/__mocks__/api.ts index 9f37f3fecd508..6c9964af25430 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/__mocks__/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/__mocks__/api.ts @@ -52,6 +52,36 @@ export const getRuleStatusById = async ({ last_success_at: 'mm/dd/yyyyTHH:MM:sssz', last_failure_message: null, last_success_message: 'it is a success', + gap: null, + bulk_create_time_durations: ['2235.01'], + search_after_time_durations: ['616.97'], + last_look_back_date: '2020-03-19T00:32:07.996Z', + }, + failures: [], + }, + }); + +export const getRulesStatusByIds = async ({ + ids, + signal, +}: { + ids: string[]; + signal: AbortSignal; +}): Promise => + Promise.resolve({ + '12345678987654321': { + current_status: { + alert_id: 'alertId', + status_date: 'mm/dd/yyyyTHH:MM:sssz', + status: 'succeeded', + last_failure_at: null, + last_success_at: 'mm/dd/yyyyTHH:MM:sssz', + last_failure_message: null, + last_success_message: 'it is a success', + gap: null, + bulk_create_time_durations: ['2235.01'], + search_after_time_durations: ['616.97'], + last_look_back_date: '2020-03-19T00:32:07.996Z', }, failures: [], }, diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts index 8fdc6a67f7d71..e8019659d49c6 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts @@ -39,7 +39,7 @@ describe('Detections Rules API', () => { await addRule({ rule: ruleMock, signal: abortCtrl.signal }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules', { body: - '{"description":"some desc","enabled":true,"false_positives":[],"filters":[],"from":"now-360s","index":["apm-*-transaction*","auditbeat-*","endgame-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf","language":"kuery","risk_score":75,"name":"Test rule","query":"user.email: \'root@elastic.co\'","references":[],"severity":"high","tags":["APM"],"to":"now","type":"query","threat":[]}', + '{"description":"some desc","enabled":true,"false_positives":[],"filters":[],"from":"now-360s","index":["apm-*-transaction*","auditbeat-*","endgame-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf","language":"kuery","risk_score":75,"name":"Test rule","query":"user.email: \'root@elastic.co\'","references":[],"severity":"high","tags":["APM"],"to":"now","type":"query","threat":[],"throttle":null}', method: 'POST', signal: abortCtrl.signal, }); @@ -291,7 +291,7 @@ describe('Detections Rules API', () => { await duplicateRules({ rules: rulesMock.data }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_bulk_create', { body: - '[{"description":"Elastic Endpoint detected Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":73,"name":"Credential Dumping - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection","filters":[],"references":[],"severity":"high","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"version":1},{"description":"Elastic Endpoint detected an Adversary Behavior. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":47,"name":"Adversary Behavior - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:rules_engine_event","filters":[],"references":[],"severity":"medium","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"version":1}]', + '[{"actions":[],"description":"Elastic Endpoint detected Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":73,"name":"Credential Dumping - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:cred_theft_event and endgame.metadata.type:detection","filters":[],"references":[],"severity":"high","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"throttle":null,"version":1},{"actions":[],"description":"Elastic Endpoint detected an Adversary Behavior. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.","enabled":false,"false_positives":[],"from":"now-660s","index":["endgame-*"],"interval":"10m","language":"kuery","output_index":".siem-signals-default","max_signals":100,"risk_score":47,"name":"Adversary Behavior - Detected - Elastic Endpoint [Duplicate]","query":"event.kind:alert and event.module:endgame and event.action:rules_engine_event","filters":[],"references":[],"severity":"medium","tags":["Elastic","Endpoint"],"to":"now","type":"query","threat":[],"throttle":null,"version":1}]', method: 'POST', }); }); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index 126de9762a696..4b0e0030be53d 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -271,6 +271,32 @@ export const getRuleStatusById = async ({ signal, }); +/** + * Return rule statuses given list of alert ids + * + * @param ids array of string of Rule ID's (not rule_id) + * @param signal AbortSignal for cancelling request + * + * @throws An error if response is not OK + */ +export const getRulesStatusByIds = async ({ + ids, + signal, +}: { + ids: string[]; + signal: AbortSignal; +}): Promise => { + const res = await KibanaServices.get().http.fetch( + DETECTION_ENGINE_RULES_STATUS_URL, + { + method: 'GET', + query: { ids: JSON.stringify(ids) }, + signal, + } + ); + return res; +}; + /** * Fetch all unique Tags used by Rules * diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/mock.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/mock.ts index 51526c0ab9949..59782e8a36338 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/mock.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/mock.ts @@ -32,9 +32,11 @@ export const ruleMock: NewRule = { to: 'now', type: 'query', threat: [], + throttle: null, }; export const savedRuleMock: Rule = { + actions: [], created_at: 'mm/dd/yyyyTHH:MM:sssz', created_by: 'mockUser', description: 'some desc', @@ -65,6 +67,7 @@ export const savedRuleMock: Rule = { to: 'now', type: 'query', threat: [], + throttle: null, updated_at: 'mm/dd/yyyyTHH:MM:sssz', updated_by: 'mockUser', }; @@ -75,6 +78,7 @@ export const rulesMock: FetchRulesResponse = { total: 2, data: [ { + actions: [], created_at: '2020-02-14T19:49:28.178Z', updated_at: '2020-02-14T19:49:28.320Z', created_by: 'elastic', @@ -103,9 +107,11 @@ export const rulesMock: FetchRulesResponse = { to: 'now', type: 'query', threat: [], + throttle: null, version: 1, }, { + actions: [], created_at: '2020-02-14T19:49:28.189Z', updated_at: '2020-02-14T19:49:28.326Z', created_by: 'elastic', @@ -133,6 +139,7 @@ export const rulesMock: FetchRulesResponse = { to: 'now', type: 'query', threat: [], + throttle: null, version: 1, }, ], diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index c75d7b78cf92f..53a1c0770028c 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -13,6 +13,19 @@ export const RuleTypeSchema = t.keyof({ }); export type RuleType = t.TypeOf; +/** + * Params is an "record", since it is a type of AlertActionParams which is action templates. + * @see x-pack/plugins/alerting/common/alert.ts + */ +export const action = t.exact( + t.type({ + group: t.string, + id: t.string, + action_type_id: t.string, + params: t.record(t.string, t.any), + }) +); + export const NewRuleSchema = t.intersection([ t.type({ description: t.string, @@ -24,6 +37,7 @@ export const NewRuleSchema = t.intersection([ type: RuleTypeSchema, }), t.partial({ + actions: t.array(action), anomaly_threshold: t.number, created_by: t.string, false_positives: t.array(t.string), @@ -40,6 +54,7 @@ export const NewRuleSchema = t.intersection([ saved_id: t.string, tags: t.array(t.string), threat: t.array(t.unknown), + throttle: t.union([t.string, t.null]), to: t.string, updated_by: t.string, note: t.string, @@ -54,9 +69,15 @@ export interface AddRulesProps { signal: AbortSignal; } -const MetaRule = t.type({ - from: t.string, -}); +const MetaRule = t.intersection([ + t.type({ + from: t.string, + }), + t.partial({ + throttle: t.string, + kibanaSiemAppUrl: t.string, + }), +]); export const RuleSchema = t.intersection([ t.type({ @@ -81,6 +102,8 @@ export const RuleSchema = t.intersection([ threat: t.array(t.unknown), updated_at: t.string, updated_by: t.string, + actions: t.array(action), + throttle: t.union([t.string, t.null]), }), t.partial({ anomaly_threshold: t.number, @@ -212,6 +235,10 @@ export interface RuleInfoStatus { last_success_at: string | null; last_failure_message: string | null; last_success_message: string | null; + last_look_back_date: string | null | undefined; + gap: string | null | undefined; + bulk_create_time_durations: string[] | null | undefined; + search_after_time_durations: string[] | null | undefined; } export type RuleStatusResponse = Record; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.test.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.test.tsx index e0bf2c4907370..ab09f796ad49b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.test.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule.test.tsx @@ -31,6 +31,7 @@ describe('useRule', () => { expect(result.current).toEqual([ false, { + actions: [], created_at: 'mm/dd/yyyyTHH:MM:sssz', created_by: 'mockUser', description: 'some desc', @@ -59,6 +60,7 @@ describe('useRule', () => { severity: 'high', tags: ['APM'], threat: [], + throttle: null, to: 'now', type: 'query', updated_at: 'mm/dd/yyyyTHH:MM:sssz', diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.test.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.test.tsx index 25011adcfe98b..7269bf1baa5e5 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.test.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.test.tsx @@ -4,13 +4,74 @@ * you may not use this file except in compliance with the Elastic License. */ -import { renderHook, act } from '@testing-library/react-hooks'; -import { useRuleStatus, ReturnRuleStatus } from './use_rule_status'; +import { renderHook, act, cleanup } from '@testing-library/react-hooks'; +import { + useRuleStatus, + ReturnRuleStatus, + useRulesStatuses, + ReturnRulesStatuses, +} from './use_rule_status'; import * as api from './api'; +import { RuleType, Rule } from '../rules/types'; jest.mock('./api'); +const testRule: Rule = { + actions: [ + { + group: 'fake group', + id: 'fake id', + action_type_id: 'fake action_type_id', + params: { + someKey: 'someVal', + }, + }, + ], + created_at: 'mm/dd/yyyyTHH:MM:sssz', + created_by: 'mockUser', + description: 'some desc', + enabled: true, + false_positives: [], + filters: [], + from: 'now-360s', + id: '12345678987654321', + immutable: false, + index: [ + 'apm-*-transaction*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + interval: '5m', + language: 'kuery', + name: 'Test rule', + max_signals: 100, + query: "user.email: 'root@elastic.co'", + references: [], + risk_score: 75, + rule_id: 'bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf', + severity: 'high', + tags: ['APM'], + threat: [], + throttle: null, + to: 'now', + type: 'query' as RuleType, + updated_at: 'mm/dd/yyyyTHH:MM:sssz', + updated_by: 'mockUser', +}; + describe('useRuleStatus', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + afterEach(async () => { + cleanup(); + }); + test('init', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => @@ -39,6 +100,10 @@ describe('useRuleStatus', () => { last_success_message: 'it is a success', status: 'succeeded', status_date: 'mm/dd/yyyyTHH:MM:sssz', + gap: null, + bulk_create_time_durations: ['2235.01'], + search_after_time_durations: ['616.97'], + last_look_back_date: '2020-03-19T00:32:07.996Z', }, failures: [], }, @@ -62,4 +127,50 @@ describe('useRuleStatus', () => { expect(spyOngetRuleStatusById).toHaveBeenCalledTimes(2); }); }); + + test('init rules statuses', async () => { + const payload = [testRule]; + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useRulesStatuses(payload) + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ loading: false, rulesStatuses: [] }); + }); + }); + + test('fetch rules statuses', async () => { + const payload = [testRule]; + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useRulesStatuses(payload) + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(result.current).toEqual({ + loading: false, + rulesStatuses: [ + { + current_status: { + alert_id: 'alertId', + bulk_create_time_durations: ['2235.01'], + gap: null, + last_failure_at: null, + last_failure_message: null, + last_look_back_date: '2020-03-19T00:32:07.996Z', + last_success_at: 'mm/dd/yyyyTHH:MM:sssz', + last_success_message: 'it is a success', + search_after_time_durations: ['616.97'], + status: 'succeeded', + status_date: 'mm/dd/yyyyTHH:MM:sssz', + }, + failures: [], + id: '12345678987654321', + activate: true, + name: 'Test rule', + }, + ], + }); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.tsx index 8d06e037e0979..0d37cce1fd85c 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rule_status.tsx @@ -7,12 +7,17 @@ import { useEffect, useRef, useState } from 'react'; import { errorToToaster, useStateToaster } from '../../../components/toasters'; -import { getRuleStatusById } from './api'; +import { RuleStatusRowItemType } from '../../../pages/detection_engine/rules/all/columns'; +import { getRuleStatusById, getRulesStatusByIds } from './api'; import * as i18n from './translations'; -import { RuleStatus } from './types'; +import { RuleStatus, Rules } from './types'; type Func = (ruleId: string) => void; export type ReturnRuleStatus = [boolean, RuleStatus | null, Func | null]; +export interface ReturnRulesStatuses { + loading: boolean; + rulesStatuses: RuleStatusRowItemType[] | null; +} /** * Hook for using to get a Rule from the Detection Engine API @@ -33,7 +38,6 @@ export const useRuleStatus = (id: string | undefined | null): ReturnRuleStatus = const fetchData = async (idToFetch: string) => { try { setLoading(true); - const ruleStatusResponse = await getRuleStatusById({ id: idToFetch, signal: abortCtrl.signal, @@ -64,3 +68,58 @@ export const useRuleStatus = (id: string | undefined | null): ReturnRuleStatus = return [loading, ruleStatus, fetchRuleStatus.current]; }; + +/** + * Hook for using to get all the statuses for all given rule ids + * + * @param ids desired Rule ID's (not rule_id) + * + */ +export const useRulesStatuses = (rules: Rules): ReturnRulesStatuses => { + const [rulesStatuses, setRuleStatuses] = useState([]); + const [loading, setLoading] = useState(false); + const [, dispatchToaster] = useStateToaster(); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const fetchData = async (ids: string[]) => { + try { + setLoading(true); + const ruleStatusesResponse = await getRulesStatusByIds({ + ids, + signal: abortCtrl.signal, + }); + + if (isSubscribed) { + setRuleStatuses( + rules.map(rule => ({ + id: rule.id, + activate: rule.enabled, + name: rule.name, + ...ruleStatusesResponse[rule.id], + })) + ); + } + } catch (error) { + if (isSubscribed) { + setRuleStatuses([]); + errorToToaster({ title: i18n.RULE_FETCH_FAILURE, error, dispatchToaster }); + } + } + if (isSubscribed) { + setLoading(false); + } + }; + if (rules != null && rules.length > 0) { + fetchData(rules.map(r => r.id)); + } + return () => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [rules]); + + return { loading, rulesStatuses }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.test.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.test.tsx index 242d715e20f77..5d13b57f862bc 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.test.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/use_rules.test.tsx @@ -58,6 +58,7 @@ describe('useRules', () => { { data: [ { + actions: [], created_at: '2020-02-14T19:49:28.178Z', created_by: 'elastic', description: @@ -82,6 +83,7 @@ describe('useRules', () => { severity: 'high', tags: ['Elastic', 'Endpoint'], threat: [], + throttle: null, to: 'now', type: 'query', updated_at: '2020-02-14T19:49:28.320Z', @@ -89,6 +91,7 @@ describe('useRules', () => { version: 1, }, { + actions: [], created_at: '2020-02-14T19:49:28.189Z', created_by: 'elastic', description: @@ -113,6 +116,7 @@ describe('useRules', () => { severity: 'medium', tags: ['Elastic', 'Endpoint'], threat: [], + throttle: null, to: 'now', type: 'query', updated_at: '2020-02-14T19:49:28.326Z', diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts b/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts index edda2e30ea400..0479851fc5b55 100644 --- a/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts @@ -4,9 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ImportRulesProps, ImportRulesResponse } from '../../detection_engine/rules'; import { KibanaServices } from '../../../lib/kibana'; +import { TIMELINE_IMPORT_URL, TIMELINE_EXPORT_URL } from '../../../../common/constants'; import { ExportSelectedData } from '../../../components/generic_downloader'; -import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; + +export const importTimelines = async ({ + fileToImport, + overwrite = false, + signal, +}: ImportRulesProps): Promise => { + const formData = new FormData(); + formData.append('file', fileToImport); + + return KibanaServices.get().http.fetch(`${TIMELINE_IMPORT_URL}`, { + method: 'POST', + headers: { 'Content-Type': undefined }, + query: { overwrite }, + body: formData, + signal, + }); +}; export const exportSelectedTimeline: ExportSelectedData = async ({ excludeExportDetails = false, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx index 836595c7c45d9..21e4724797c5d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx @@ -30,13 +30,14 @@ const initialCommentValue: CommentRequest = { interface AddCommentProps { caseId: string; + insertQuote: string | null; onCommentSaving?: () => void; onCommentPosted: (commentResponse: Comment) => void; showLoading?: boolean; } export const AddComment = React.memo( - ({ caseId, showLoading = true, onCommentPosted, onCommentSaving }) => { + ({ caseId, insertQuote, showLoading = true, onCommentPosted, onCommentSaving }) => { const { commentData, isLoading, postComment, resetCommentData } = usePostComment(caseId); const { form } = useForm({ defaultValue: initialCommentValue, @@ -48,6 +49,16 @@ export const AddComment = React.memo( 'comment' ); + useEffect(() => { + if (insertQuote !== null) { + const { comment } = form.getFormData(); + form.setFieldValue( + 'comment', + `${comment}${comment.length > 0 ? '\n\n' : ''}${insertQuote}` + ); + } + }, [insertQuote]); + useEffect(() => { if (commentData !== null) { onCommentPosted(commentData); @@ -67,7 +78,7 @@ export const AddComment = React.memo( }, [form]); return ( - <> + {isLoading && showLoading && }
( }} /> - +
); } ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index 32a29483e9c75..5ca54c7f429d2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -3,13 +3,15 @@ * 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 React, { useCallback } from 'react'; import { EuiBadge, EuiTableFieldDataColumnType, EuiTableComputedColumnType, EuiTableActionsColumnType, EuiAvatar, + EuiLink, + EuiLoadingSpinner, } from '@elastic/eui'; import styled from 'styled-components'; import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types'; @@ -19,6 +21,7 @@ import { FormattedRelativePreferenceDate } from '../../../../components/formatte import { CaseDetailsLink } from '../../../../components/links'; import { TruncatableText } from '../../../../components/truncatable_text'; import * as i18n from './translations'; +import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; export type CasesColumns = | EuiTableFieldDataColumnType @@ -60,7 +63,6 @@ export const getCasesColumns = ( } return getEmptyTagValue(); }, - width: '25%', }, { field: 'createdBy', @@ -105,7 +107,6 @@ export const getCasesColumns = ( return getEmptyTagValue(); }, truncateText: true, - width: '20%', }, { align: 'right', @@ -148,8 +149,47 @@ export const getCasesColumns = ( return getEmptyTagValue(); }, }, + { + name: 'ServiceNow Incident', + render: (theCase: Case) => { + if (theCase.id != null) { + return ; + } + return getEmptyTagValue(); + }, + }, { name: 'Actions', actions, }, ]; + +interface Props { + theCase: Case; +} + +const ServiceNowColumn: React.FC = ({ theCase }) => { + const { hasDataToPush, isLoading } = useGetCaseUserActions(theCase.id); + const handleRenderDataToPush = useCallback( + () => + isLoading ? ( + + ) : ( +

+ + {theCase.externalService?.externalTitle} + + {hasDataToPush ? i18n.REQUIRES_UPDATE : i18n.UP_TO_DATE} +

+ ), + [hasDataToPush, isLoading, theCase.externalService] + ); + if (theCase.externalService !== null) { + return handleRenderDataToPush(); + } + return renderStringField(i18n.NOT_PUSHED, `case-table-column-external-notPushed`); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index cbb9ddae22d04..27316ab8427cb 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -109,19 +109,21 @@ export const AllCases = React.memo(() => { const { dispatchResetIsUpdated, isUpdated, updateBulkStatus } = useUpdateCases(); + const refreshCases = useCallback(() => { + refetchCases(filterOptions, queryParams); + fetchCasesStatus(); + }, [filterOptions, queryParams]); + useEffect(() => { if (isDeleted) { - refetchCases(filterOptions, queryParams); - fetchCasesStatus(); + refreshCases(); dispatchResetIsDeleted(); } if (isUpdated) { - refetchCases(filterOptions, queryParams); - fetchCasesStatus(); + refreshCases(); dispatchResetIsUpdated(); } - }, [isDeleted, isUpdated, filterOptions, queryParams]); - + }, [isDeleted, isUpdated]); const [deleteThisCase, setDeleteThisCase] = useState({ title: '', id: '', @@ -327,6 +329,10 @@ export const AllCases = React.memo(() => { > {i18n.BULK_ACTIONS} + + + {i18n.REFRESH} + diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts index b18134f6d093e..e8459454576e3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/translations.ts @@ -62,3 +62,17 @@ export const CLOSED = i18n.translate('xpack.siem.case.caseTable.closed', { export const DELETE = i18n.translate('xpack.siem.case.caseTable.delete', { defaultMessage: 'Delete', }); +export const REQUIRES_UPDATE = i18n.translate('xpack.siem.case.caseTable.requiresUpdate', { + defaultMessage: ' requires update', +}); + +export const UP_TO_DATE = i18n.translate('xpack.siem.case.caseTable.upToDate', { + defaultMessage: ' is up to date', +}); +export const NOT_PUSHED = i18n.translate('xpack.siem.case.caseTable.notPushed', { + defaultMessage: 'Not pushed', +}); + +export const REFRESH = i18n.translate('xpack.siem.case.caseTable.refreshTitle', { + defaultMessage: 'Refresh', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index beba80ccd934c..c081567e3be72 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -59,6 +59,10 @@ export const EDIT_DESCRIPTION = i18n.translate('xpack.siem.case.caseView.edit.de defaultMessage: 'Edit description', }); +export const QUOTE = i18n.translate('xpack.siem.case.caseView.edit.quote', { + defaultMessage: 'Quote', +}); + export const EDIT_COMMENT = i18n.translate('xpack.siem.case.caseView.edit.comment', { defaultMessage: 'Edit comment', }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/index.tsx index 01ccf3c510b60..25332982dca1a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/property_actions/index.tsx @@ -45,7 +45,7 @@ export const PropertyActions = React.memo(({ propertyActio const onClosePopover = useCallback((cb?: () => void) => { setShowActions(false); - if (cb) { + if (cb != null) { cb(); } }, []); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 8b77186f76f77..d8b9ac115426a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -57,6 +57,7 @@ export const UserActionTree = React.memo( ); const currentUser = useCurrentUser(); const [manageMarkdownEditIds, setManangeMardownEditIds] = useState([]); + const [insertQuote, setInsertQuote] = useState(null); const handleManageMarkdownEditId = useCallback( (id: string) => { @@ -92,6 +93,9 @@ export const UserActionTree = React.memo( top: y, behavior: 'smooth', }); + if (id === 'add-comment') { + moveToTarget.getElementsByTagName('textarea')[0].focus(); + } } window.clearTimeout(handlerTimeoutId.current); setSelectedOutlineCommentId(id); @@ -103,6 +107,15 @@ export const UserActionTree = React.memo( [handlerTimeoutId.current] ); + const handleManageQuote = useCallback( + (quote: string) => { + const addCarrots = quote.replace(new RegExp('\r?\n', 'g'), ' \n> '); + setInsertQuote(`> ${addCarrots} \n`); + handleOutlineComment('add-comment'); + }, + [handleOutlineComment] + ); + const handleUpdate = useCallback( (comment: Comment) => { addPostedComment(comment); @@ -131,12 +144,13 @@ export const UserActionTree = React.memo( () => ( ), - [caseData.id, handleUpdate] + [caseData.id, handleUpdate, insertQuote] ); useEffect(() => { @@ -156,10 +170,12 @@ export const UserActionTree = React.memo( isEditable={manageMarkdownEditIds.includes(DESCRIPTION_ID)} isLoading={isLoadingDescription} labelEditAction={i18n.EDIT_DESCRIPTION} + labelQuoteAction={i18n.QUOTE} labelTitle={<>{i18n.ADDED_DESCRIPTION}} fullName={caseData.createdBy.fullName ?? caseData.createdBy.username} markdown={MarkdownDescription} onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)} + onQuote={handleManageQuote.bind(null, caseData.description)} userName={caseData.createdBy.username} /> @@ -176,6 +192,7 @@ export const UserActionTree = React.memo( isEditable={manageMarkdownEditIds.includes(comment.id)} isLoading={isLoadingIds.includes(comment.id)} labelEditAction={i18n.EDIT_COMMENT} + labelQuoteAction={i18n.QUOTE} labelTitle={<>{i18n.ADDED_COMMENT}} fullName={comment.createdBy.fullName ?? comment.createdBy.username} markdown={ @@ -188,6 +205,7 @@ export const UserActionTree = React.memo( /> } onEdit={handleManageMarkdownEditId.bind(null, comment.id)} + onQuote={handleManageQuote.bind(null, comment.comment)} outlineComment={handleOutlineComment} userName={comment.createdBy.username} updatedAt={comment.updatedAt} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index 10a7c56e2eb2d..c1dbe3b5fdbfa 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -25,11 +25,13 @@ interface UserActionItemProps { isEditable: boolean; isLoading: boolean; labelEditAction?: string; + labelQuoteAction?: string; labelTitle?: JSX.Element; linkId?: string | null; fullName: string; markdown?: React.ReactNode; onEdit?: (id: string) => void; + onQuote?: (id: string) => void; userName: string; updatedAt?: string | null; outlineComment?: (id: string) => void; @@ -113,11 +115,13 @@ export const UserActionItem = ({ isEditable, isLoading, labelEditAction, + labelQuoteAction, labelTitle, linkId, fullName, markdown, onEdit, + onQuote, outlineComment, showBottomFooter, showTopFooter, @@ -147,11 +151,13 @@ export const UserActionItem = ({ id={id} isLoading={isLoading} labelEditAction={labelEditAction} + labelQuoteAction={labelQuoteAction} labelTitle={labelTitle ?? <>} linkId={linkId} userName={userName} updatedAt={updatedAt} onEdit={onEdit} + onQuote={onQuote} outlineComment={outlineComment} /> {markdown} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx index 6ca81667d9712..391f54da7e972 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx @@ -30,11 +30,13 @@ interface UserActionTitleProps { id: string; isLoading: boolean; labelEditAction?: string; + labelQuoteAction?: string; labelTitle: JSX.Element; linkId?: string | null; updatedAt?: string | null; userName: string; onEdit?: (id: string) => void; + onQuote?: (id: string) => void; outlineComment?: (id: string) => void; } @@ -43,27 +45,39 @@ export const UserActionTitle = ({ id, isLoading, labelEditAction, + labelQuoteAction, labelTitle, linkId, userName, updatedAt, onEdit, + onQuote, outlineComment, }: UserActionTitleProps) => { const { detailName: caseId } = useParams(); const urlSearch = useGetUrlSearch(navTabs.case); const propertyActions = useMemo(() => { - if (labelEditAction != null && onEdit != null) { - return [ - { - iconType: 'pencil', - label: labelEditAction, - onClick: () => onEdit(id), - }, - ]; - } - return []; - }, [id, labelEditAction, onEdit]); + return [ + ...(labelEditAction != null && onEdit != null + ? [ + { + iconType: 'pencil', + label: labelEditAction, + onClick: () => onEdit(id), + }, + ] + : []), + ...(labelQuoteAction != null && onQuote != null + ? [ + { + iconType: 'quote', + label: labelQuoteAction, + onClick: () => onQuote(id), + }, + ] + : []), + ]; + }, [id, labelEditAction, onEdit, labelQuoteAction, onQuote]); const handleAnchorLink = useCallback(() => { copy( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts index a6aefefedd5c3..6d76fde49634d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -6,7 +6,7 @@ import { esFilters } from '../../../../../../../../../../src/plugins/data/public'; import { Rule, RuleError } from '../../../../../containers/detection_engine/rules'; -import { AboutStepRule, DefineStepRule, ScheduleStepRule } from '../../types'; +import { AboutStepRule, ActionsStepRule, DefineStepRule, ScheduleStepRule } from '../../types'; import { FieldValueQueryBar } from '../../components/query_bar'; export const mockQueryBar: FieldValueQueryBar = { @@ -40,6 +40,7 @@ export const mockQueryBar: FieldValueQueryBar = { }; export const mockRule = (id: string): Rule => ({ + actions: [], created_at: '2020-01-10T21:11:45.839Z', updated_at: '2020-01-10T21:11:45.839Z', created_by: 'elastic', @@ -70,11 +71,13 @@ export const mockRule = (id: string): Rule => ({ to: 'now', type: 'saved_query', threat: [], + throttle: null, note: '# this is some markdown documentation', version: 1, }); export const mockRuleWithEverything = (id: string): Rule => ({ + actions: [], created_at: '2020-01-10T21:11:45.839Z', updated_at: '2020-01-10T21:11:45.839Z', created_by: 'elastic', @@ -142,6 +145,7 @@ export const mockRuleWithEverything = (id: string): Rule => ({ ], }, ], + throttle: null, note: '# this is some markdown documentation', version: 1, }); @@ -175,6 +179,14 @@ export const mockAboutStepRule = (isNew = false): AboutStepRule => ({ note: '# this is some markdown documentation', }); +export const mockActionsStepRule = (isNew = false, enabled = false): ActionsStepRule => ({ + isNew, + actions: [], + kibanaSiemAppUrl: 'http://localhost:5601/app/siem', + enabled, + throttle: null, +}); + export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ isNew, ruleType: 'query', @@ -188,9 +200,8 @@ export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ }, }); -export const mockScheduleStepRule = (isNew = false, enabled = false): ScheduleStepRule => ({ +export const mockScheduleStepRule = (isNew = false): ScheduleStepRule => ({ isNew, - enabled, interval: '5m', from: '6m', to: 'now', diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx index be64499fd47fa..7bfccc554b7e6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx @@ -14,10 +14,11 @@ import { EuiText, EuiHealth, } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; import * as H from 'history'; import React, { Dispatch } from 'react'; -import { Rule } from '../../../../containers/detection_engine/rules'; +import { Rule, RuleStatus } from '../../../../containers/detection_engine/rules'; import { getEmptyTagValue } from '../../../../components/empty_value'; import { FormattedDate } from '../../../../components/formatted_date'; import { getRuleDetailsUrl } from '../../../../components/link_to/redirect_to_detection_engine'; @@ -34,6 +35,7 @@ import { exportRulesAction, } from './actions'; import { Action } from './reducer'; +import { LocalizedDateTooltip } from '../../../../components/localized_date_tooltip'; export const getActions = ( dispatch: React.Dispatch, @@ -75,7 +77,12 @@ export const getActions = ( }, ]; +export type RuleStatusRowItemType = RuleStatus & { + name: string; + id: string; +}; type RulesColumns = EuiBasicTableColumn | EuiTableActionsColumnType; +type RulesStatusesColumns = EuiBasicTableColumn; interface GetColumns { dispatch: React.Dispatch; @@ -132,7 +139,9 @@ export const getColumns = ({ return value == null ? ( getEmptyTagValue() ) : ( - + + + ); }, sortable: true, @@ -196,3 +205,114 @@ export const getColumns = ({ return hasNoPermissions ? cols : [...cols, ...actions]; }; + +export const getMonitoringColumns = (): RulesStatusesColumns[] => { + const cols: RulesStatusesColumns[] = [ + { + field: 'name', + name: i18n.COLUMN_RULE, + render: (value: RuleStatus['current_status']['status'], item: RuleStatusRowItemType) => { + return ( + + {value} + + ); + }, + truncateText: true, + width: '24%', + }, + { + field: 'current_status.bulk_create_time_durations', + name: i18n.COLUMN_INDEXING_TIMES, + render: (value: RuleStatus['current_status']['bulk_create_time_durations']) => ( + + {value != null && value.length > 0 + ? Math.max(...value?.map(item => Number.parseFloat(item))) + : null} + + ), + truncateText: true, + width: '14%', + }, + { + field: 'current_status.search_after_time_durations', + name: i18n.COLUMN_QUERY_TIMES, + render: (value: RuleStatus['current_status']['search_after_time_durations']) => ( + + {value != null && value.length > 0 + ? Math.max(...value?.map(item => Number.parseFloat(item))) + : null} + + ), + truncateText: true, + width: '14%', + }, + { + field: 'current_status.gap', + name: i18n.COLUMN_GAP, + render: (value: RuleStatus['current_status']['gap']) => ( + + {value} + + ), + truncateText: true, + width: '14%', + }, + { + field: 'current_status.last_look_back_date', + name: i18n.COLUMN_LAST_LOOKBACK_DATE, + render: (value: RuleStatus['current_status']['last_look_back_date']) => { + return value == null ? ( + getEmptyTagValue() + ) : ( + + ); + }, + truncateText: true, + width: '16%', + }, + { + field: 'current_status.status_date', + name: i18n.COLUMN_LAST_COMPLETE_RUN, + render: (value: RuleStatus['current_status']['status_date']) => { + return value == null ? ( + getEmptyTagValue() + ) : ( + + + + ); + }, + sortable: true, + truncateText: true, + width: '20%', + }, + { + field: 'current_status.status', + name: i18n.COLUMN_LAST_RESPONSE, + render: (value: RuleStatus['current_status']['status']) => { + return ( + <> + + {value ?? getEmptyTagValue()} + + + ); + }, + width: '16%', + truncateText: true, + }, + { + field: 'activate', + name: i18n.COLUMN_ACTIVATE, + render: (value: Rule['enabled']) => ( + + {value ? i18n.ACTIVE : i18n.INACTIVE} + + ), + width: '95px', + }, + ]; + + return cols; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index 621c70e391319..1a0de46729312 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -4,20 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiBasicTable, - EuiContextMenuPanel, - EuiEmptyPrompt, - EuiLoadingContent, - EuiSpacer, -} from '@elastic/eui'; +import { EuiBasicTable, EuiContextMenuPanel, EuiLoadingContent, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import { useHistory } from 'react-router-dom'; -import styled from 'styled-components'; import uuid from 'uuid'; import { useRules, + useRulesStatuses, CreatePreBuiltRules, FilterOptions, Rule, @@ -37,20 +31,16 @@ import { Loader } from '../../../../components/loader'; import { Panel } from '../../../../components/panel'; import { PrePackagedRulesPrompt } from '../components/pre_packaged_rules/load_empty_prompt'; import { GenericDownloader } from '../../../../components/generic_downloader'; +import { AllRulesTables } from '../components/all_rules_tables'; import { getPrePackagedRuleStatus } from '../helpers'; import * as i18n from '../translations'; import { EuiBasicTableOnChange } from '../types'; import { getBatchItems } from './batch_actions'; -import { getColumns } from './columns'; +import { getColumns, getMonitoringColumns } from './columns'; import { showRulesTable } from './helpers'; import { allRulesReducer, State } from './reducer'; import { RulesTableFilters } from './rules_table_filters/rules_table_filters'; -// EuiBasicTable give me a hardtime with adding the ref attributes so I went the easy way -// after few hours of fight with typescript !!!! I lost :( -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const MyEuiBasicTable = styled(EuiBasicTable as any)`` as any; - const initialState: State = { exportRuleIds: [], filterOptions: { @@ -117,6 +107,7 @@ export const AllRules = React.memo( }, dispatch, ] = useReducer(allRulesReducer(tableRef), initialState); + const { loading: isLoadingRulesStatuses, rulesStatuses } = useRulesStatuses(rules); const history = useHistory(); const [, dispatchToaster] = useStateToaster(); @@ -135,6 +126,13 @@ export const AllRules = React.memo( dispatchRulesInReducer: setRules, }); + const sorting = useMemo( + () => ({ + sort: { field: 'enabled', direction: filterOptions.sortOrder }, + }), + [filterOptions.sortOrder] + ); + const prePackagedRuleStatus = getPrePackagedRuleStatus( rulesInstalled, rulesNotInstalled, @@ -158,6 +156,16 @@ export const AllRules = React.memo( [dispatch, dispatchToaster, loadingRuleIds, reFetchRulesData, rules, selectedRuleIds] ); + const paginationMemo = useMemo( + () => ({ + pageIndex: pagination.page - 1, + pageSize: pagination.perPage, + totalItemCount: pagination.total, + pageSizeOptions: [5, 10, 20, 50, 100, 200, 300], + }), + [pagination] + ); + const tableOnChangeCallback = useCallback( ({ page, sort }: EuiBasicTableOnChange) => { dispatch({ @@ -172,7 +180,7 @@ export const AllRules = React.memo( [dispatch] ); - const columns = useMemo(() => { + const rulesColumns = useMemo(() => { return getColumns({ dispatch, dispatchToaster, @@ -187,6 +195,8 @@ export const AllRules = React.memo( }); }, [dispatch, dispatchToaster, history, loadingRuleIds, loadingRulesAction, reFetchRulesData]); + const monitoringColumns = useMemo(() => getMonitoringColumns(), []); + useEffect(() => { if (reFetchRulesData != null) { setRefreshRulesData(reFetchRulesData); @@ -194,10 +204,10 @@ export const AllRules = React.memo( }, [reFetchRulesData, setRefreshRulesData]); useEffect(() => { - if (initLoading && !loading && !isLoadingRules) { + if (initLoading && !loading && !isLoadingRules && !isLoadingRulesStatuses) { setInitLoading(false); } - }, [initLoading, loading, isLoadingRules]); + }, [initLoading, loading, isLoadingRules, isLoadingRulesStatuses]); const handleCreatePrePackagedRules = useCallback(async () => { if (createPrePackagedRules != null && reFetchRulesData != null) { @@ -225,12 +235,6 @@ export const AllRules = React.memo( }); }, []); - const emptyPrompt = useMemo(() => { - return ( - {i18n.NO_RULES}} titleSize="xs" body={i18n.NO_RULES_BODY} /> - ); - }, []); - const isLoadingAnActionOnRule = useMemo(() => { if ( loadingRuleIds.length > 0 && @@ -264,7 +268,7 @@ export const AllRules = React.memo( /> - + <> ( /> - {(loading || isLoadingRules || isLoadingAnActionOnRule) && !initLoading && ( - - )} + {(loading || isLoadingRules || isLoadingAnActionOnRule || isLoadingRulesStatuses) && + !initLoading && ( + + )} {rulesCustomInstalled != null && rulesCustomInstalled === 0 && - prePackagedRuleStatus === 'ruleNotInstalled' && ( + prePackagedRuleStatus === 'ruleNotInstalled' && + !initLoading && ( ( - )} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/all_rules_tables/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/all_rules_tables/index.tsx new file mode 100644 index 0000000000000..92ccbc864ab5a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/all_rules_tables/index.tsx @@ -0,0 +1,121 @@ +/* + * 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 { EuiBasicTable, EuiTab, EuiTabs, EuiEmptyPrompt } from '@elastic/eui'; +import React, { useMemo, memo, useState } from 'react'; +import styled from 'styled-components'; + +import * as i18n from '../../translations'; +import { RuleStatusRowItemType } from '../../../../../pages/detection_engine/rules/all/columns'; +import { Rules } from '../../../../../containers/detection_engine/rules'; + +// EuiBasicTable give me a hardtime with adding the ref attributes so I went the easy way +// after few hours of fight with typescript !!!! I lost :( +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const MyEuiBasicTable = styled(EuiBasicTable as any)`` as any; + +interface AllRulesTablesProps { + euiBasicTableSelectionProps: unknown; + hasNoPermissions: boolean; + monitoringColumns: unknown; + paginationMemo: unknown; + rules: Rules; + rulesColumns: unknown; + rulesStatuses: RuleStatusRowItemType[] | null; + sorting: unknown; + tableOnChangeCallback: unknown; + tableRef?: unknown; +} + +enum AllRulesTabs { + rules = 'rules', + monitoring = 'monitoring', +} + +const allRulesTabs = [ + { + id: AllRulesTabs.rules, + name: i18n.RULES_TAB, + disabled: false, + }, + { + id: AllRulesTabs.monitoring, + name: i18n.MONITORING_TAB, + disabled: false, + }, +]; + +const AllRulesTablesComponent: React.FC = ({ + euiBasicTableSelectionProps, + hasNoPermissions, + monitoringColumns, + paginationMemo, + rules, + rulesColumns, + rulesStatuses, + sorting, + tableOnChangeCallback, + tableRef, +}) => { + const [allRulesTab, setAllRulesTab] = useState(AllRulesTabs.rules); + const emptyPrompt = useMemo(() => { + return ( + {i18n.NO_RULES}} titleSize="xs" body={i18n.NO_RULES_BODY} /> + ); + }, []); + const tabs = useMemo( + () => ( + + {allRulesTabs.map(tab => ( + setAllRulesTab(tab.id)} + isSelected={tab.id === allRulesTab} + disabled={tab.disabled} + key={tab.id} + > + {tab.name} + + ))} + + ), + [allRulesTabs, allRulesTab, setAllRulesTab] + ); + return ( + <> + {tabs} + {allRulesTab === AllRulesTabs.rules && ( + + )} + {allRulesTab === AllRulesTabs.monitoring && ( + + )} + + ); +}; + +export const AllRulesTables = memo(AllRulesTablesComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx index 1e18023e0c326..19d1c698cbd9b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/anomaly_threshold_slider/index.tsx @@ -5,7 +5,7 @@ */ import React, { useCallback } from 'react'; -import { EuiFlexGrid, EuiFlexItem, EuiRange, EuiFormRow } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiRange, EuiFormRow } from '@elastic/eui'; import { FieldHook } from '../../../../../shared_imports'; @@ -31,12 +31,11 @@ export const AnomalyThresholdSlider: React.FC = ({ return ( - + = ({ tickInterval={25} /> - + ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.test.tsx index 7a3f0105d3d15..af946c6f02cbb 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.test.tsx @@ -22,6 +22,7 @@ import { buildSeverityDescription, buildUrlsDescription, buildNoteDescription, + buildRuleTypeDescription, } from './helpers'; import { ListItems } from './types'; @@ -385,4 +386,30 @@ describe('helpers', () => { expect(result).toHaveLength(0); }); }); + + describe('buildRuleTypeDescription', () => { + it('returns the label for a machine_learning type', () => { + const [result]: ListItems[] = buildRuleTypeDescription('Test label', 'machine_learning'); + + expect(result.title).toEqual('Test label'); + }); + + it('returns a humanized description for a machine_learning type', () => { + const [result]: ListItems[] = buildRuleTypeDescription('Test label', 'machine_learning'); + + expect(result.description).toEqual('Machine Learning'); + }); + + it('returns the label for a query type', () => { + const [result]: ListItems[] = buildRuleTypeDescription('Test label', 'query'); + + expect(result.title).toEqual('Test label'); + }); + + it('returns a humanized description for a query type', () => { + const [result]: ListItems[] = buildRuleTypeDescription('Test label', 'query'); + + expect(result.description).toEqual('Query'); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx index 7b22078c89d1b..f9b255a95d869 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx @@ -27,6 +27,8 @@ import * as i18n from './translations'; import { BuildQueryBarDescription, BuildThreatDescription, ListItems } from './types'; import { SeverityBadge } from '../severity_badge'; import ListTreeIcon from './assets/list_tree_icon.svg'; +import { RuleType } from '../../../../../containers/detection_engine/rules'; +import { assertUnreachable } from '../../../../../lib/helpers'; const NoteDescriptionContainer = styled(EuiFlexItem)` height: 105px; @@ -266,3 +268,27 @@ export const buildNoteDescription = (label: string, note: string): ListItems[] = } return []; }; + +export const buildRuleTypeDescription = (label: string, ruleType: RuleType): ListItems[] => { + switch (ruleType) { + case 'machine_learning': { + return [ + { + title: label, + description: i18n.ML_TYPE_DESCRIPTION, + }, + ]; + } + case 'query': + case 'saved_query': { + return [ + { + title: label, + description: i18n.QUERY_TYPE_DESCRIPTION, + }, + ]; + } + default: + return assertUnreachable(ruleType); + } +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.test.tsx index 557da8677f777..a01aec0ccf2cf 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.test.tsx @@ -27,6 +27,8 @@ import { schema } from '../step_about_rule/schema'; import { ListItems } from './types'; import { AboutStepRule } from '../../types'; +jest.mock('../../../../../lib/kibana'); + describe('description_step', () => { const setupMock = coreMock.createSetup(); const uiSettingsMock = (pinnedByDefault: boolean) => (key: string) => { @@ -41,13 +43,6 @@ describe('description_step', () => { let mockAboutStep: AboutStepRule; beforeEach(() => { - // jest carries state between mocked implementations when using - // spyOn. So now we're doing all three of these. - // https://github.com/facebook/jest/issues/7136#issuecomment-565976599 - jest.resetAllMocks(); - jest.restoreAllMocks(); - jest.clearAllMocks(); - setupMock.uiSettings.get.mockImplementation(uiSettingsMock(true)); mockFilterManager = new FilterManager(setupMock.uiSettings); mockAboutStep = mockAboutStepRule(); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx index 43b4a5f781b89..69c4ee1017155 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx @@ -15,6 +15,7 @@ import { esFilters, FilterManager, } from '../../../../../../../../../../src/plugins/data/public'; +import { RuleType } from '../../../../../containers/detection_engine/rules'; import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations'; import { useKibana } from '../../../../../lib/kibana'; import { IMitreEnterpriseAttack } from '../../types'; @@ -29,7 +30,10 @@ import { buildUnorderedListArrayDescription, buildUrlsDescription, buildNoteDescription, + buildRuleTypeDescription, } from './helpers'; +import { useSiemJobs } from '../../../../../components/ml_popover/hooks/use_siem_jobs'; +import { buildMlJobDescription } from './ml_job_description'; const DescriptionListContainer = styled(EuiDescriptionList)` &.euiDescriptionList--column .euiDescriptionList__title { @@ -55,15 +59,22 @@ export const StepRuleDescriptionComponent: React.FC = }) => { const kibana = useKibana(); const [filterManager] = useState(new FilterManager(kibana.services.uiSettings)); + const [, siemJobs] = useSiemJobs(true); const keys = Object.keys(schema); - const listItems = keys.reduce( - (acc: ListItems[], key: string) => [ - ...acc, - ...buildListItems(data, pick(key, schema), filterManager, indexPatterns), - ], - [] - ); + const listItems = keys.reduce((acc: ListItems[], key: string) => { + if (key === 'machineLearningJobId') { + return [ + ...acc, + buildMlJobDescription( + get(key, data) as string, + (get(key, schema) as { label: string }).label, + siemJobs + ), + ]; + } + return [...acc, ...buildListItems(data, pick(key, schema), filterManager, indexPatterns)]; + }, []); if (columns === 'multi') { return ( @@ -176,6 +187,9 @@ export const getDescriptionItem = ( } else if (field === 'note') { const val: string = get(field, data); return buildNoteDescription(label, val); + } else if (field === 'ruleType') { + const ruleType: RuleType = get(field, data); + return buildRuleTypeDescription(label, ruleType); } const description: string = get(field, data); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx new file mode 100644 index 0000000000000..6697268defbac --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import styled from 'styled-components'; +import { EuiBadge, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; + +import { useKibana } from '../../../../../lib/kibana'; +import { SiemJob } from '../../../../../components/ml_popover/types'; +import { ListItems } from './types'; +import { isJobStarted } from '../../../../../components/ml/helpers'; +import { ML_JOB_STARTED, ML_JOB_STOPPED } from './translations'; + +enum MessageLevels { + info = 'info', + warning = 'warning', + error = 'error', +} + +const AuditIcon: React.FC<{ + message: SiemJob['auditMessage']; +}> = ({ message }) => { + if (!message) { + return null; + } + + let color = 'primary'; + let icon = 'alert'; + + if (message.level === MessageLevels.info) { + icon = 'iInCircle'; + } else if (message.level === MessageLevels.warning) { + color = 'warning'; + } else if (message.level === MessageLevels.error) { + color = 'danger'; + } + + return ( + + + + ); +}; + +export const JobStatusBadge: React.FC<{ job: SiemJob }> = ({ job }) => { + const isStarted = isJobStarted(job.jobState, job.datafeedState); + + return isStarted ? ( + {ML_JOB_STARTED} + ) : ( + {ML_JOB_STOPPED} + ); +}; + +const JobLink = styled(EuiLink)` + margin-right: ${({ theme }) => theme.eui.euiSizeS}; +`; + +const Wrapper = styled.div` + overflow: hidden; +`; + +export const MlJobDescription: React.FC<{ job: SiemJob }> = ({ job }) => { + const jobUrl = useKibana().services.application.getUrlForApp('ml#/jobs'); + + return ( + +
+ + {job.id} + + +
+ +
+ ); +}; + +export const buildMlJobDescription = ( + jobId: string, + label: string, + siemJobs: SiemJob[] +): ListItems => { + const siemJob = siemJobs.find(job => job.id === jobId); + + return { + title: label, + description: siemJob ? : jobId, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx index 9695fd21067ee..b494d824679f3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/translations.tsx @@ -17,3 +17,31 @@ export const QUERY_LABEL = i18n.translate('xpack.siem.detectionEngine.createRule export const SAVED_ID_LABEL = i18n.translate('xpack.siem.detectionEngine.createRule.savedIdLabel', { defaultMessage: 'Saved query name', }); + +export const ML_TYPE_DESCRIPTION = i18n.translate( + 'xpack.siem.detectionEngine.createRule.mlRuleTypeDescription', + { + defaultMessage: 'Machine Learning', + } +); + +export const QUERY_TYPE_DESCRIPTION = i18n.translate( + 'xpack.siem.detectionEngine.createRule.queryRuleTypeDescription', + { + defaultMessage: 'Query', + } +); + +export const ML_JOB_STARTED = i18n.translate( + 'xpack.siem.detectionEngine.ruleDescription.mlJobStartedDescription', + { + defaultMessage: 'Started', + } +); + +export const ML_JOB_STOPPED = i18n.translate( + 'xpack.siem.detectionEngine.ruleDescription.mlJobStoppedDescription', + { + defaultMessage: 'Stopped', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts deleted file mode 100644 index dab1c9490591f..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/translations.ts +++ /dev/null @@ -1,68 +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 { i18n } from '@kbn/i18n'; - -export const IMPORT_RULE = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.importRuleTitle', - { - defaultMessage: 'Import rule', - } -); - -export const SELECT_RULE = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.selectRuleDescription', - { - defaultMessage: 'Select a SIEM rule (as exported from the Detection Engine UI) to import', - } -); - -export const INITIAL_PROMPT_TEXT = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.initialPromptTextDescription', - { - defaultMessage: 'Select or drag and drop a valid rules_export.ndjson file', - } -); - -export const OVERWRITE_WITH_SAME_NAME = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.overwriteDescription', - { - defaultMessage: 'Automatically overwrite saved objects with the same rule ID', - } -); - -export const CANCEL_BUTTON = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.cancelTitle', - { - defaultMessage: 'Cancel', - } -); - -export const SUCCESSFULLY_IMPORTED_RULES = (totalRules: number) => - i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.successfullyImportedRulesTitle', - { - values: { totalRules }, - defaultMessage: - 'Successfully imported {totalRules} {totalRules, plural, =1 {rule} other {rules}}', - } - ); - -export const IMPORT_FAILED = i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.importFailedTitle', - { - defaultMessage: 'Failed to import rules', - } -); - -export const IMPORT_FAILED_DETAILED = (ruleId: string, statusCode: number, message: string) => - i18n.translate( - 'xpack.siem.detectionEngine.components.importRuleModal.importFailedDetailedTitle', - { - values: { ruleId, statusCode, message }, - defaultMessage: 'Rule ID: {ruleId}\n Status Code: {statusCode}\n Message: {message}', - } - ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx index bc32162c2660b..3d253b71b53d6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx @@ -5,12 +5,39 @@ */ import React, { useCallback } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSuperSelect, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiLink, + EuiSuperSelect, + EuiText, +} from '@elastic/eui'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports'; import { useSiemJobs } from '../../../../../components/ml_popover/hooks/use_siem_jobs'; +import { useKibana } from '../../../../../lib/kibana'; +import { ML_JOB_SELECT_PLACEHOLDER_TEXT } from '../step_define_rule/translations'; -const JobDisplay = ({ title, description }: { title: string; description: string }) => ( +const HelpText: React.FC<{ href: string }> = ({ href }) => ( + + + + ), + }} + /> +); + +const JobDisplay: React.FC<{ title: string; description: string }> = ({ title, description }) => ( <> {title} @@ -28,23 +55,32 @@ export const MlJobSelect: React.FC = ({ describedByIds = [], f const jobId = field.value as string; const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); const [isLoading, siemJobs] = useSiemJobs(false); + const mlUrl = useKibana().services.application.getUrlForApp('ml'); const handleJobChange = useCallback( (machineLearningJobId: string) => { field.setValue(machineLearningJobId); }, [field] ); + const placeholderOption = { + value: 'placeholder', + inputDisplay: ML_JOB_SELECT_PLACEHOLDER_TEXT, + dropdownDisplay: ML_JOB_SELECT_PLACEHOLDER_TEXT, + disabled: true, + }; - const options = siemJobs.map(job => ({ + const jobOptions = siemJobs.map(job => ({ value: job.id, inputDisplay: job.id, dropdownDisplay: , })); + const options = [placeholderOption, ...jobOptions]; + return ( } isInvalid={isInvalid} error={errorMessage} data-test-subj="mlJobSelect" @@ -57,7 +93,7 @@ export const MlJobSelect: React.FC = ({ describedByIds = [], f isLoading={isLoading} onChange={handleJobChange} options={options} - valueOfSelected={jobId} + valueOfSelected={jobId || 'placeholder'} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..433b38773c14a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/__snapshots__/index.test.tsx.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NextStep renders correctly against snapshot 1`] = ` + + + + + + Continue + + + + +`; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.test.tsx similarity index 57% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.test.tsx rename to x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.test.tsx index e10194853e7f9..552ede90cd018 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/import_rule_modal/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.test.tsx @@ -6,19 +6,11 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { ImportRuleModalComponent } from './index'; +import { NextStep } from './index'; -jest.mock('../../../../../lib/kibana'); - -describe('ImportRuleModal', () => { +describe('NextStep', () => { test('renders correctly against snapshot', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.tsx new file mode 100644 index 0000000000000..11332e7af9266 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/next_step/index.tsx @@ -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 React from 'react'; +import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; +import * as RuleI18n from '../../translations'; + +interface NextStepProps { + onClick: () => Promise; + isDisabled: boolean; + dataTestSubj?: string; +} + +export const NextStep = React.memo( + ({ onClick, isDisabled, dataTestSubj = 'nextStep-continue' }) => ( + <> + + + + + {RuleI18n.CONTINUE} + + + + + ) +); + +NextStep.displayName = 'NextStep'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx index 923ec3a7f0066..27d668dc6166c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/pick_timeline/index.tsx @@ -59,7 +59,6 @@ export const PickTimeline = ({ helpText={field.helpText} error={errorMessage} isInvalid={isInvalid} - fullWidth data-test-subj={dataTestSubj} describedByIds={idAria ? [idAria] : undefined} > diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.tsx new file mode 100644 index 0000000000000..a746d381c494c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_field/index.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import deepMerge from 'deepmerge'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { loadActionTypes } from '../../../../../../../../../plugins/triggers_actions_ui/public/application/lib/action_connector_api'; +import { SelectField } from '../../../../../shared_imports'; +import { + ActionForm, + ActionType, +} from '../../../../../../../../../plugins/triggers_actions_ui/public'; +import { AlertAction } from '../../../../../../../../../plugins/alerting/common'; +import { useKibana } from '../../../../../lib/kibana'; +import { NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS } from '../../../../../../common/constants'; + +type ThrottleSelectField = typeof SelectField; + +const DEFAULT_ACTION_GROUP_ID = 'default'; +const DEFAULT_ACTION_MESSAGE = + 'Rule {{context.rule.name}} generated {{state.signals_count}} signals'; + +export const RuleActionsField: ThrottleSelectField = ({ field, messageVariables }) => { + const [supportedActionTypes, setSupportedActionTypes] = useState(); + const { + http, + triggers_actions_ui: { actionTypeRegistry }, + notifications, + } = useKibana().services; + + const setActionIdByIndex = useCallback( + (id: string, index: number) => { + const updatedActions = [...(field.value as Array>)]; + updatedActions[index] = deepMerge(updatedActions[index], { id }); + field.setValue(updatedActions); + }, + [field] + ); + + const setAlertProperty = useCallback( + (updatedActions: AlertAction[]) => field.setValue(updatedActions), + [field] + ); + + const setActionParamsProperty = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (key: string, value: any, index: number) => { + const updatedActions = [...(field.value as AlertAction[])]; + updatedActions[index].params[key] = value; + field.setValue(updatedActions); + }, + [field] + ); + + useEffect(() => { + (async function() { + const actionTypes = await loadActionTypes({ http }); + const supportedTypes = actionTypes.filter(actionType => + NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.includes(actionType.id) + ); + setSupportedActionTypes(supportedTypes); + })(); + }, []); + + if (!supportedActionTypes) return <>; + + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx index 219b3d6dc4d58..af7150e083817 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx @@ -48,14 +48,16 @@ interface SelectRuleTypeProps { describedByIds?: string[]; field: FieldHook; hasValidLicense?: boolean; + isMlAdmin?: boolean; isReadOnly?: boolean; } export const SelectRuleType: React.FC = ({ describedByIds = [], field, - hasValidLicense = false, isReadOnly = false, + hasValidLicense = false, + isMlAdmin = false, }) => { const ruleType = field.value as RuleType; const setType = useCallback( @@ -66,7 +68,7 @@ export const SelectRuleType: React.FC = ({ ); const setMl = useCallback(() => setType('machine_learning'), [setType]); const setQuery = useCallback(() => setType('query'), [setType]); - const mlCardDisabled = isReadOnly || !hasValidLicense; + const mlCardDisabled = isReadOnly || !hasValidLicense || !isMlAdmin; return ( ({ eui: euiDarkVars, darkMode: true }); +/* eslint-disable no-console */ +// Silence until enzyme fixed to use ReactTestUtils.act() +const originalError = console.error; +beforeAll(() => { + console.error = jest.fn(); +}); +afterAll(() => { + console.error = originalError; +}); +/* eslint-enable no-console */ + describe('StepAboutRuleComponent', () => { test('it renders StepRuleDescription if isReadOnlyView is true and "name" property exists', () => { const wrapper = shallow( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx index 58b6ca54f5bbd..eaf543780d777 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx @@ -4,22 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiAccordion, - EuiButton, - EuiHorizontalRule, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiButtonEmpty, -} from '@elastic/eui'; +import { EuiAccordion, EuiFlexItem, EuiSpacer, EuiButtonEmpty } from '@elastic/eui'; import React, { FC, memo, useCallback, useEffect, useState } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; import { setFieldValue } from '../../helpers'; import { RuleStepProps, RuleStep, AboutStepRule } from '../../types'; -import * as RuleI18n from '../../translations'; import { AddItem } from '../add_item_form'; import { StepRuleDescription } from '../description_step'; import { AddMitreThreat } from '../mitre'; @@ -38,6 +29,7 @@ import { isUrlInvalid } from './helpers'; import { schema } from './schema'; import * as I18n from './translations'; import { StepContentWrapper } from '../step_content_wrapper'; +import { NextStep } from '../next_step'; import { MarkdownEditorForm } from '../../../../../components/markdown_editor/form'; const CommonUseField = getUseField({ component: Field }); @@ -276,27 +268,9 @@ const StepAboutRuleComponent: FC = ({ + {!isUpdateView && ( - <> - - - - - {RuleI18n.CONTINUE} - - - - + )} ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.test.tsx index 4a4e96ec74902..bbd037af10c3f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.test.tsx @@ -15,19 +15,14 @@ import { HeaderSection } from '../../../../../components/header_section'; import { StepAboutRule } from '../step_about_rule/'; import { AboutStepRule } from '../../types'; +jest.mock('../../../../../lib/kibana'); + const theme = () => ({ eui: euiDarkVars, darkMode: true }); describe('StepAboutRuleToggleDetails', () => { let mockRule: AboutStepRule; beforeEach(() => { - // jest carries state between mocked implementations when using - // spyOn. So now we're doing all three of these. - // https://github.com/facebook/jest/issues/7136#issuecomment-565976599 - jest.resetAllMocks(); - jest.restoreAllMocks(); - jest.clearAllMocks(); - mockRule = mockAboutStepRule(); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.tsx index c61566cb841e8..5d9803214fa0a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule_details/index.tsx @@ -15,7 +15,7 @@ import { EuiFlexGroup, EuiResizeObserver, } from '@elastic/eui'; -import React, { memo, useState } from 'react'; +import React, { memo, useCallback, useState } from 'react'; import styled from 'styled-components'; import { isEmpty } from 'lodash/fp'; @@ -71,9 +71,12 @@ const StepAboutRuleToggleDetailsComponent: React.FC = ({ const [selectedToggleOption, setToggleOption] = useState('details'); const [aboutPanelHeight, setAboutPanelHeight] = useState(0); - const onResize = (e: { height: number; width: number }) => { - setAboutPanelHeight(e.height); - }; + const onResize = useCallback( + (e: { height: number; width: number }) => { + setAboutPanelHeight(e.height); + }, + [setAboutPanelHeight] + ); return ( @@ -85,7 +88,7 @@ const StepAboutRuleToggleDetailsComponent: React.FC = ({ )} {stepData != null && stepDataDetails != null && ( - + {!isEmpty(stepDataDetails.note) && stepDataDetails.note.trim() !== '' && ( = ({ )} - + {selectedToggleOption === 'details' ? ( {resizeRef => ( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index 4027c98a52ace..6c46ab0b171a2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -4,15 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiButtonEmpty, - EuiHorizontalRule, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiButton, -} from '@elastic/eui'; -import React, { FC, memo, useCallback, useState, useEffect, useContext } from 'react'; +import { EuiButtonEmpty, EuiFormRow } from '@elastic/eui'; +import React, { FC, memo, useCallback, useState, useEffect } from 'react'; import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; @@ -20,10 +13,9 @@ import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/pu import { useFetchIndexPatterns } from '../../../../../containers/detection_engine/rules'; import { DEFAULT_INDEX_KEY } from '../../../../../../common/constants'; import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations'; -import { MlCapabilitiesContext } from '../../../../../components/ml/permissions/ml_capabilities_provider'; +import { useMlCapabilities } from '../../../../../components/ml_popover/hooks/use_ml_capabilities'; import { useUiSetting$ } from '../../../../../lib/kibana'; import { setFieldValue, isMlRule } from '../../helpers'; -import * as RuleI18n from '../../translations'; import { DefineStepRule, RuleStep, RuleStepProps } from '../../types'; import { StepRuleDescription } from '../description_step'; import { QueryBarDefineRule } from '../query_bar'; @@ -32,6 +24,7 @@ import { AnomalyThresholdSlider } from '../anomaly_threshold_slider'; import { MlJobSelect } from '../ml_job_select'; import { PickTimeline } from '../pick_timeline'; import { StepContentWrapper } from '../step_content_wrapper'; +import { NextStep } from '../next_step'; import { Field, Form, @@ -44,6 +37,7 @@ import { import { schema } from './schema'; import * as i18n from './translations'; import { filterRuleFieldsForType, RuleFields } from '../../create/helpers'; +import { hasMlAdminPermissions } from '../../../../../components/ml/permissions/has_ml_admin_permissions'; const CommonUseField = getUseField({ component: Field }); @@ -92,7 +86,7 @@ const StepDefineRuleComponent: FC = ({ setForm, setStepData, }) => { - const mlCapabilities = useContext(MlCapabilitiesContext); + const mlCapabilities = useMlCapabilities(); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); const [indexModified, setIndexModified] = useState(false); const [localIsMlRule, setIsMlRule] = useState(false); @@ -169,8 +163,9 @@ const StepDefineRuleComponent: FC = ({ component={SelectRuleType} componentProps={{ describedByIds: ['detectionEngineStepDefineRuleType'], - hasValidLicense: mlCapabilities.isPlatinumOrTrialLicense, isReadOnly: isUpdateView, + hasValidLicense: mlCapabilities.isPlatinumOrTrialLicense, + isMlAdmin: hasMlAdminPermissions(mlCapabilities), }} /> @@ -269,22 +264,9 @@ const StepDefineRuleComponent: FC = ({ + {!isUpdateView && ( - <> - - - - - {RuleI18n.CONTINUE} - - - - + )} ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx index 8394f090e346c..1d8821aceb249 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/translations.tsx @@ -55,3 +55,10 @@ export const IMPORT_TIMELINE_QUERY = i18n.translate( defaultMessage: 'Import query from saved timeline', } ); + +export const ML_JOB_SELECT_PLACEHOLDER_TEXT = i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepDefineRule.mlJobSelectPlaceholderText', + { + defaultMessage: 'Select a job', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/index.tsx new file mode 100644 index 0000000000000..9c16a61822662 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/index.tsx @@ -0,0 +1,184 @@ +/* + * 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 { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton, EuiSpacer } from '@elastic/eui'; +import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import deepEqual from 'fast-deep-equal'; + +import { setFieldValue } from '../../helpers'; +import { RuleStep, RuleStepProps, ActionsStepRule } from '../../types'; +import { StepRuleDescription } from '../description_step'; +import { Form, UseField, useForm } from '../../../../../shared_imports'; +import { StepContentWrapper } from '../step_content_wrapper'; +import { ThrottleSelectField, THROTTLE_OPTIONS } from '../throttle_select_field'; +import { RuleActionsField } from '../rule_actions_field'; +import { useKibana } from '../../../../../lib/kibana'; +import { schema } from './schema'; +import * as I18n from './translations'; + +interface StepRuleActionsProps extends RuleStepProps { + defaultValues?: ActionsStepRule | null; + actionMessageParams: string[]; +} + +const stepActionsDefaultValue = { + enabled: true, + isNew: true, + actions: [], + kibanaSiemAppUrl: '', + throttle: THROTTLE_OPTIONS[0].value, +}; + +const GhostFormField = () => <>; + +const StepRuleActionsComponent: FC = ({ + addPadding = false, + defaultValues, + isReadOnlyView, + isLoading, + isUpdateView = false, + setStepData, + setForm, + actionMessageParams, +}) => { + const [myStepData, setMyStepData] = useState(stepActionsDefaultValue); + const { + services: { application }, + } = useKibana(); + + const { form } = useForm({ + defaultValue: myStepData, + options: { stripEmptyFields: false }, + schema, + }); + + const kibanaAbsoluteUrl = useMemo(() => application.getUrlForApp('siem', { absolute: true }), [ + application, + ]); + + const onSubmit = useCallback( + async (enabled: boolean) => { + if (setStepData) { + setStepData(RuleStep.ruleActions, null, false); + const { isValid: newIsValid, data } = await form.submit(); + if (newIsValid) { + setStepData(RuleStep.ruleActions, { ...data, enabled }, newIsValid); + setMyStepData({ ...data, isNew: false } as ActionsStepRule); + } + } + }, + [form] + ); + + useEffect(() => { + const { isNew, ...initDefaultValue } = myStepData; + if (defaultValues != null && !deepEqual(initDefaultValue, defaultValues)) { + const myDefaultValues = { + ...defaultValues, + isNew: false, + }; + setMyStepData(myDefaultValues); + setFieldValue(form, schema, myDefaultValues); + } + }, [defaultValues]); + + useEffect(() => { + if (setForm != null) { + setForm(RuleStep.ruleActions, form); + } + }, [form]); + + const updateThrottle = useCallback(throttle => setMyStepData({ ...myStepData, throttle }), [ + myStepData, + setMyStepData, + ]); + + const throttleFieldComponentProps = useMemo( + () => ({ + idAria: 'detectionEngineStepRuleActionsThrottle', + isDisabled: isLoading, + dataTestSubj: 'detectionEngineStepRuleActionsThrottle', + hasNoInitialSelection: false, + handleChange: updateThrottle, + euiFieldProps: { + options: THROTTLE_OPTIONS, + }, + }), + [isLoading, updateThrottle] + ); + + return isReadOnlyView && myStepData != null ? ( + + + + ) : ( + <> + +
+ + {myStepData.throttle !== stepActionsDefaultValue.throttle && ( + <> + + + + + )} + +
+ + {!isUpdateView && ( + <> + + + + + {I18n.COMPLETE_WITHOUT_ACTIVATING} + + + + + {I18n.COMPLETE_WITH_ACTIVATING} + + + + + )} + + ); +}; + +export const StepRuleActions = memo(StepRuleActionsComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/schema.tsx new file mode 100644 index 0000000000000..511427978db3a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/schema.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +import { FormSchema } from '../../../../../shared_imports'; + +export const schema: FormSchema = { + actions: {}, + kibanaSiemAppUrl: {}, + throttle: { + label: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepRuleActions.fieldThrottleLabel', + { + defaultMessage: 'Actions frequency', + } + ), + helpText: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepRuleActions.fieldThrottleHelpText', + { + defaultMessage: + 'Select when automated actions should be performed if a rule evaluates as true.', + } + ), + }, +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/translations.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/translations.tsx new file mode 100644 index 0000000000000..67bcc1af8150b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_rule_actions/translations.tsx @@ -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 { i18n } from '@kbn/i18n'; + +export const COMPLETE_WITHOUT_ACTIVATING = i18n.translate( + 'xpack.siem.detectionEngine.createRule. stepScheduleRule.completeWithoutActivatingTitle', + { + defaultMessage: 'Create rule without activating it', + } +); + +export const COMPLETE_WITH_ACTIVATING = i18n.translate( + 'xpack.siem.detectionEngine.createRule. stepScheduleRule.completeWithActivatingTitle', + { + defaultMessage: 'Create & activate rule', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx index e365443a79fb8..de9abcefdea2e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; import React, { FC, memo, useCallback, useEffect, useState } from 'react'; import deepEqual from 'fast-deep-equal'; import styled from 'styled-components'; @@ -15,8 +14,8 @@ import { StepRuleDescription } from '../description_step'; import { ScheduleItem } from '../schedule_item_form'; import { Form, UseField, useForm } from '../../../../../shared_imports'; import { StepContentWrapper } from '../step_content_wrapper'; +import { NextStep } from '../next_step'; import { schema } from './schema'; -import * as I18n from './translations'; interface StepScheduleRuleProps extends RuleStepProps { defaultValues?: ScheduleStepRule | null; @@ -27,7 +26,6 @@ const RestrictedWidthContainer = styled.div` `; const stepScheduleDefaultValue = { - enabled: true, interval: '5m', isNew: true, from: '1m', @@ -51,19 +49,16 @@ const StepScheduleRuleComponent: FC = ({ schema, }); - const onSubmit = useCallback( - async (enabled: boolean) => { - if (setStepData) { - setStepData(RuleStep.scheduleRule, null, false); - const { isValid: newIsValid, data } = await form.submit(); - if (newIsValid) { - setStepData(RuleStep.scheduleRule, { ...data, enabled }, newIsValid); - setMyStepData({ ...data, isNew: false } as ScheduleStepRule); - } + const onSubmit = useCallback(async () => { + if (setStepData) { + setStepData(RuleStep.scheduleRule, null, false); + const { isValid: newIsValid, data } = await form.submit(); + if (newIsValid) { + setStepData(RuleStep.scheduleRule, { ...data }, newIsValid); + setMyStepData({ ...data, isNew: false } as ScheduleStepRule); } - }, - [form] - ); + } + }, [form]); useEffect(() => { const { isNew, ...initDefaultValue } = myStepData; @@ -118,37 +113,7 @@ const StepScheduleRuleComponent: FC = ({ {!isUpdateView && ( - <> - - - - - {I18n.COMPLETE_WITHOUT_ACTIVATING} - - - - - {I18n.COMPLETE_WITH_ACTIVATING} - - - - + )} ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/throttle_select_field/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/throttle_select_field/index.tsx new file mode 100644 index 0000000000000..0cf15c41a0f91 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/throttle_select_field/index.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; + +import { + NOTIFICATION_THROTTLE_RULE, + NOTIFICATION_THROTTLE_NO_ACTIONS, +} from '../../../../../../common/constants'; +import { SelectField } from '../../../../../shared_imports'; + +export const THROTTLE_OPTIONS = [ + { value: NOTIFICATION_THROTTLE_NO_ACTIONS, text: 'Perform no actions' }, + { value: NOTIFICATION_THROTTLE_RULE, text: 'On each rule execution' }, + { value: '1h', text: 'Hourly' }, + { value: '1d', text: 'Daily' }, + { value: '7d', text: 'Weekly' }, +]; + +type ThrottleSelectField = typeof SelectField; + +export const ThrottleSelectField: ThrottleSelectField = props => { + const onChange = useCallback( + e => { + const throttle = e.target.value; + props.field.setValue(throttle); + props.handleChange(throttle); + }, + [props.field.setValue, props.handleChange] + ); + const newEuiFieldProps = { ...props.euiFieldProps, onChange }; + return ; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts index dc0459c54adb0..212147ec6d4d8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts @@ -9,7 +9,9 @@ import { DefineStepRuleJson, ScheduleStepRuleJson, AboutStepRuleJson, + ActionsStepRuleJson, AboutStepRule, + ActionsStepRule, ScheduleStepRule, DefineStepRule, } from '../types'; @@ -18,6 +20,7 @@ import { formatDefineStepData, formatScheduleStepData, formatAboutStepData, + formatActionsStepData, formatRule, filterRuleFieldsForType, } from './helpers'; @@ -26,6 +29,7 @@ import { mockQueryBar, mockScheduleStepRule, mockAboutStepRule, + mockActionsStepRule, } from '../all/__mocks__/mock'; describe('helpers', () => { @@ -241,7 +245,6 @@ describe('helpers', () => { test('returns formatted object as ScheduleStepRuleJson', () => { const result: ScheduleStepRuleJson = formatScheduleStepData(mockData); const expected = { - enabled: false, from: 'now-660s', to: 'now', interval: '5m', @@ -260,7 +263,6 @@ describe('helpers', () => { delete mockStepData.to; const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); const expected = { - enabled: false, from: 'now-660s', to: 'now', interval: '5m', @@ -279,7 +281,6 @@ describe('helpers', () => { }; const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); const expected = { - enabled: false, from: 'now-660s', to: 'now', interval: '5m', @@ -298,7 +299,6 @@ describe('helpers', () => { }; const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); const expected = { - enabled: false, from: 'now-300s', to: 'now', interval: '5m', @@ -317,7 +317,6 @@ describe('helpers', () => { }; const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); const expected = { - enabled: false, from: 'now-360s', to: 'now', interval: 'random', @@ -503,19 +502,164 @@ describe('helpers', () => { }); }); + describe('formatActionsStepData', () => { + let mockData: ActionsStepRule; + + beforeEach(() => { + mockData = mockActionsStepRule(); + }); + + test('returns formatted object as ActionsStepRuleJson', () => { + const result: ActionsStepRuleJson = formatActionsStepData(mockData); + const expected = { + actions: [], + enabled: false, + meta: { + throttle: 'no_actions', + kibanaSiemAppUrl: 'http://localhost:5601/app/siem', + }, + throttle: null, + }; + + expect(result).toEqual(expected); + }); + + test('returns proper throttle value for no_actions', () => { + const mockStepData = { + ...mockData, + throttle: 'no_actions', + }; + const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); + const expected = { + actions: [], + enabled: false, + meta: { + throttle: mockStepData.throttle, + kibanaSiemAppUrl: mockStepData.kibanaSiemAppUrl, + }, + throttle: null, + }; + + expect(result).toEqual(expected); + }); + + test('returns proper throttle value for rule', () => { + const mockStepData = { + ...mockData, + throttle: 'rule', + actions: [ + { + group: 'default', + id: 'id', + actionTypeId: 'actionTypeId', + params: {}, + }, + ], + }; + const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); + const expected = { + actions: [ + { + group: mockStepData.actions[0].group, + id: mockStepData.actions[0].id, + action_type_id: mockStepData.actions[0].actionTypeId, + params: mockStepData.actions[0].params, + }, + ], + enabled: false, + meta: { + throttle: mockStepData.throttle, + kibanaSiemAppUrl: mockStepData.kibanaSiemAppUrl, + }, + throttle: null, + }; + + expect(result).toEqual(expected); + }); + + test('returns proper throttle value for interval', () => { + const mockStepData = { + ...mockData, + throttle: '1d', + actions: [ + { + group: 'default', + id: 'id', + actionTypeId: 'actionTypeId', + params: {}, + }, + ], + }; + const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); + const expected = { + actions: [ + { + group: mockStepData.actions[0].group, + id: mockStepData.actions[0].id, + action_type_id: mockStepData.actions[0].actionTypeId, + params: mockStepData.actions[0].params, + }, + ], + enabled: false, + meta: { + throttle: mockStepData.throttle, + kibanaSiemAppUrl: mockStepData.kibanaSiemAppUrl, + }, + throttle: mockStepData.throttle, + }; + + expect(result).toEqual(expected); + }); + + test('returns actions with action_type_id', () => { + const mockAction = { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'ML Rule generated {{state.signals_count}} signals' }, + actionTypeId: '.slack', + }; + + const mockStepData = { + ...mockData, + actions: [mockAction], + }; + const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); + const expected = { + actions: [ + { + group: mockAction.group, + id: mockAction.id, + params: mockAction.params, + action_type_id: mockAction.actionTypeId, + }, + ], + enabled: false, + meta: { + throttle: null, + kibanaSiemAppUrl: mockStepData.kibanaSiemAppUrl, + }, + throttle: null, + }; + + expect(result).toEqual(expected); + }); + }); + describe('formatRule', () => { let mockAbout: AboutStepRule; let mockDefine: DefineStepRule; let mockSchedule: ScheduleStepRule; + let mockActions: ActionsStepRule; beforeEach(() => { mockAbout = mockAboutStepRule(); mockDefine = mockDefineStepRule(); mockSchedule = mockScheduleStepRule(); + mockActions = mockActionsStepRule(); }); test('returns NewRule with type of saved_query when saved_id exists', () => { - const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule); + const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule, mockActions); expect(result.type).toEqual('saved_query'); }); @@ -528,13 +672,18 @@ describe('helpers', () => { saved_id: '', }, }; - const result: NewRule = formatRule(mockDefineStepRuleWithoutSavedId, mockAbout, mockSchedule); + const result: NewRule = formatRule( + mockDefineStepRuleWithoutSavedId, + mockAbout, + mockSchedule, + mockActions + ); expect(result.type).toEqual('query'); }); test('returns NewRule without id if ruleId does not exist', () => { - const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule); + const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule, mockActions); expect(result.id).toBeUndefined(); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts index f8900e6a1129e..7abe5a576c0e5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts @@ -6,16 +6,24 @@ import { has, isEmpty } from 'lodash/fp'; import moment from 'moment'; +import deepmerge from 'deepmerge'; +import { + NOTIFICATION_THROTTLE_RULE, + NOTIFICATION_THROTTLE_NO_ACTIONS, +} from '../../../../../common/constants'; import { NewRule, RuleType } from '../../../../containers/detection_engine/rules'; +import { transformAlertToRuleAction } from '../../../../../common/detection_engine/transform_actions'; import { AboutStepRule, DefineStepRule, ScheduleStepRule, + ActionsStepRule, DefineStepRuleJson, ScheduleStepRuleJson, AboutStepRuleJson, + ActionsStepRuleJson, } from '../types'; import { isMlRule } from '../helpers'; @@ -136,12 +144,39 @@ export const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRule }; }; +export const getAlertThrottle = (throttle: string | null) => + throttle && ![NOTIFICATION_THROTTLE_NO_ACTIONS, NOTIFICATION_THROTTLE_RULE].includes(throttle) + ? throttle + : null; + +export const formatActionsStepData = (actionsStepData: ActionsStepRule): ActionsStepRuleJson => { + const { + actions = [], + enabled, + kibanaSiemAppUrl, + throttle = NOTIFICATION_THROTTLE_NO_ACTIONS, + } = actionsStepData; + + return { + actions: actions.map(transformAlertToRuleAction), + enabled, + throttle: actions.length ? getAlertThrottle(throttle) : null, + meta: { + throttle: actions.length ? throttle : NOTIFICATION_THROTTLE_NO_ACTIONS, + kibanaSiemAppUrl, + }, + }; +}; + export const formatRule = ( defineStepData: DefineStepRule, aboutStepData: AboutStepRule, - scheduleData: ScheduleStepRule -): NewRule => ({ - ...formatDefineStepData(defineStepData), - ...formatAboutStepData(aboutStepData), - ...formatScheduleStepData(scheduleData), -}); + scheduleData: ScheduleStepRule, + actionsData: ActionsStepRule +): NewRule => + deepmerge.all([ + formatDefineStepData(defineStepData), + formatAboutStepData(aboutStepData), + formatScheduleStepData(scheduleData), + formatActionsStepData(actionsData), + ]) as NewRule; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx index 67aaabfe70fda..0335216672915 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx @@ -5,7 +5,7 @@ */ import { EuiButtonEmpty, EuiAccordion, EuiHorizontalRule, EuiPanel, EuiSpacer } from '@elastic/eui'; -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useRef, useState, useMemo } from 'react'; import { Redirect } from 'react-router-dom'; import styled, { StyledComponent } from 'styled-components'; @@ -21,14 +21,27 @@ import { FormData, FormHook } from '../../../../shared_imports'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; import { StepScheduleRule } from '../components/step_schedule_rule'; +import { StepRuleActions } from '../components/step_rule_actions'; import { DetectionEngineHeaderPage } from '../../components/detection_engine_header_page'; import * as RuleI18n from '../translations'; -import { redirectToDetections } from '../helpers'; -import { AboutStepRule, DefineStepRule, RuleStep, RuleStepData, ScheduleStepRule } from '../types'; +import { redirectToDetections, getActionMessageParams } from '../helpers'; +import { + AboutStepRule, + DefineStepRule, + RuleStep, + RuleStepData, + ScheduleStepRule, + ActionsStepRule, +} from '../types'; import { formatRule } from './helpers'; import * as i18n from './translations'; -const stepsRuleOrder = [RuleStep.defineRule, RuleStep.aboutRule, RuleStep.scheduleRule]; +const stepsRuleOrder = [ + RuleStep.defineRule, + RuleStep.aboutRule, + RuleStep.scheduleRule, + RuleStep.ruleActions, +]; const MyEuiPanel = styled(EuiPanel)<{ zindex?: number; @@ -79,22 +92,31 @@ const CreateRulePageComponent: React.FC = () => { const defineRuleRef = useRef(null); const aboutRuleRef = useRef(null); const scheduleRuleRef = useRef(null); + const ruleActionsRef = useRef(null); const stepsForm = useRef | null>>({ [RuleStep.defineRule]: null, [RuleStep.aboutRule]: null, [RuleStep.scheduleRule]: null, + [RuleStep.ruleActions]: null, }); const stepsData = useRef>({ [RuleStep.defineRule]: { isValid: false, data: {} }, [RuleStep.aboutRule]: { isValid: false, data: {} }, [RuleStep.scheduleRule]: { isValid: false, data: {} }, + [RuleStep.ruleActions]: { isValid: false, data: {} }, }); const [isStepRuleInReadOnlyView, setIsStepRuleInEditView] = useState>({ [RuleStep.defineRule]: false, [RuleStep.aboutRule]: false, [RuleStep.scheduleRule]: false, + [RuleStep.ruleActions]: false, }); const [{ isLoading, isSaved }, setRule] = usePersistRule(); + const actionMessageParams = useMemo( + () => + getActionMessageParams((stepsData.current['define-rule'].data as DefineStepRule).ruleType), + [stepsData.current['define-rule'].data] + ); const userHasNoPermissions = canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; @@ -103,7 +125,7 @@ const CreateRulePageComponent: React.FC = () => { stepsData.current[step] = { ...stepsData.current[step], data, isValid }; if (isValid) { const stepRuleIdx = stepsRuleOrder.findIndex(item => step === item); - if ([0, 1].includes(stepRuleIdx)) { + if ([0, 1, 2].includes(stepRuleIdx)) { if (isStepRuleInReadOnlyView[stepsRuleOrder[stepRuleIdx + 1]]) { setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]); setIsStepRuleInEditView({ @@ -120,15 +142,17 @@ const CreateRulePageComponent: React.FC = () => { setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]); } } else if ( - stepRuleIdx === 2 && + stepRuleIdx === 3 && stepsData.current[RuleStep.defineRule].isValid && - stepsData.current[RuleStep.aboutRule].isValid + stepsData.current[RuleStep.aboutRule].isValid && + stepsData.current[RuleStep.scheduleRule].isValid ) { setRule( formatRule( stepsData.current[RuleStep.defineRule].data as DefineStepRule, stepsData.current[RuleStep.aboutRule].data as AboutStepRule, - stepsData.current[RuleStep.scheduleRule].data as ScheduleStepRule + stepsData.current[RuleStep.scheduleRule].data as ScheduleStepRule, + stepsData.current[RuleStep.ruleActions].data as ActionsStepRule ) ); } @@ -177,6 +201,14 @@ const CreateRulePageComponent: React.FC = () => { /> ); + const ruleActionsButton = ( + + ); + const openCloseAccordion = (accordionId: RuleStep | null) => { if (accordionId != null) { if (accordionId === RuleStep.defineRule && defineRuleRef.current != null) { @@ -185,6 +217,8 @@ const CreateRulePageComponent: React.FC = () => { aboutRuleRef.current.onToggle(); } else if (accordionId === RuleStep.scheduleRule && scheduleRuleRef.current != null) { scheduleRuleRef.current.onToggle(); + } else if (accordionId === RuleStep.ruleActions && ruleActionsRef.current != null) { + ruleActionsRef.current.onToggle(); } } }; @@ -253,7 +287,7 @@ const CreateRulePageComponent: React.FC = () => { isLoading={isLoading || loading} title={i18n.PAGE_TITLE} /> - + { - + { - + { /> + + + + {i18n.EDIT_RULE} + + ) + } + > + + + + diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx index 8618bf9504861..f89e3206cc67d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/edit/index.tsx @@ -31,10 +31,17 @@ import { StepPanel } from '../components/step_panel'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; import { StepScheduleRule } from '../components/step_schedule_rule'; +import { StepRuleActions } from '../components/step_rule_actions'; import { formatRule } from '../create/helpers'; -import { getStepsData, redirectToDetections } from '../helpers'; +import { getStepsData, redirectToDetections, getActionMessageParams } from '../helpers'; import * as ruleI18n from '../translations'; -import { RuleStep, DefineStepRule, AboutStepRule, ScheduleStepRule } from '../types'; +import { + RuleStep, + DefineStepRule, + AboutStepRule, + ScheduleStepRule, + ActionsStepRule, +} from '../types'; import * as i18n from './translations'; interface StepRuleForm { @@ -50,6 +57,10 @@ interface ScheduleStepRuleForm extends StepRuleForm { data: ScheduleStepRule | null; } +interface ActionsStepRuleForm extends StepRuleForm { + data: ActionsStepRule | null; +} + const EditRulePageComponent: FC = () => { const [, dispatchToaster] = useStateToaster(); const { @@ -79,14 +90,20 @@ const EditRulePageComponent: FC = () => { data: null, isValid: false, }); + const [myActionsRuleForm, setMyActionsRuleForm] = useState({ + data: null, + isValid: false, + }); const [selectedTab, setSelectedTab] = useState(); const stepsForm = useRef | null>>({ [RuleStep.defineRule]: null, [RuleStep.aboutRule]: null, [RuleStep.scheduleRule]: null, + [RuleStep.ruleActions]: null, }); const [{ isLoading, isSaved }, setRule] = usePersistRule(); const [tabHasError, setTabHasError] = useState([]); + const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule]); const setStepsForm = useCallback( (step: RuleStep, form: FormHook) => { stepsForm.current[step] = form; @@ -162,6 +179,28 @@ const EditRulePageComponent: FC = () => { ), }, + { + id: RuleStep.ruleActions, + name: ruleI18n.ACTIONS, + content: ( + <> + + + {myActionsRuleForm.data != null && ( + + )} + + + + ), + }, ], [ loading, @@ -170,8 +209,10 @@ const EditRulePageComponent: FC = () => { myAboutRuleForm, myDefineRuleForm, myScheduleRuleForm, + myActionsRuleForm, setStepsForm, stepsForm, + actionMessageParams, ] ); @@ -179,14 +220,18 @@ const EditRulePageComponent: FC = () => { const activeFormId = selectedTab?.id as RuleStep; const activeForm = await stepsForm.current[activeFormId]?.submit(); - const invalidForms = [RuleStep.aboutRule, RuleStep.defineRule, RuleStep.scheduleRule].reduce< - RuleStep[] - >((acc, step) => { + const invalidForms = [ + RuleStep.aboutRule, + RuleStep.defineRule, + RuleStep.scheduleRule, + RuleStep.ruleActions, + ].reduce((acc, step) => { if ( (step === activeFormId && activeForm != null && !activeForm?.isValid) || (step === RuleStep.aboutRule && !myAboutRuleForm.isValid) || (step === RuleStep.defineRule && !myDefineRuleForm.isValid) || - (step === RuleStep.scheduleRule && !myScheduleRuleForm.isValid) + (step === RuleStep.scheduleRule && !myScheduleRuleForm.isValid) || + (step === RuleStep.ruleActions && !myActionsRuleForm.isValid) ) { return [...acc, step]; } @@ -205,21 +250,35 @@ const EditRulePageComponent: FC = () => { : myAboutRuleForm.data) as AboutStepRule, (activeFormId === RuleStep.scheduleRule ? activeForm.data - : myScheduleRuleForm.data) as ScheduleStepRule + : myScheduleRuleForm.data) as ScheduleStepRule, + (activeFormId === RuleStep.ruleActions + ? activeForm.data + : myActionsRuleForm.data) as ActionsStepRule ), ...(ruleId ? { id: ruleId } : {}), }); } else { setTabHasError(invalidForms); } - }, [stepsForm, myAboutRuleForm, myDefineRuleForm, myScheduleRuleForm, selectedTab, ruleId]); + }, [ + stepsForm, + myAboutRuleForm, + myDefineRuleForm, + myScheduleRuleForm, + myActionsRuleForm, + selectedTab, + ruleId, + ]); useEffect(() => { if (rule != null) { - const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ rule }); + const { aboutRuleData, defineRuleData, scheduleRuleData, ruleActionsData } = getStepsData({ + rule, + }); setMyAboutRuleForm({ data: aboutRuleData, isValid: true }); setMyDefineRuleForm({ data: defineRuleData, isValid: true }); setMyScheduleRuleForm({ data: scheduleRuleData, isValid: true }); + setMyActionsRuleForm({ data: ruleActionsData, isValid: true }); } }, [rule]); @@ -228,6 +287,7 @@ const EditRulePageComponent: FC = () => { if (selectedTab != null) { const ruleStep = selectedTab.id as RuleStep; const respForm = await stepsForm.current[ruleStep]?.submit(); + if (respForm != null) { if (ruleStep === RuleStep.aboutRule) { setMyAboutRuleForm({ @@ -244,6 +304,11 @@ const EditRulePageComponent: FC = () => { data: respForm.data as ScheduleStepRule, isValid: respForm.isValid, }); + } else if (ruleStep === RuleStep.ruleActions) { + setMyActionsRuleForm({ + data: respForm.data as ActionsStepRule, + isValid: respForm.isValid, + }); } } } @@ -255,10 +320,13 @@ const EditRulePageComponent: FC = () => { useEffect(() => { if (rule != null) { - const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ rule }); + const { aboutRuleData, defineRuleData, scheduleRuleData, ruleActionsData } = getStepsData({ + rule, + }); setMyAboutRuleForm({ data: aboutRuleData, isValid: true }); setMyDefineRuleForm({ data: defineRuleData, isValid: true }); setMyScheduleRuleForm({ data: scheduleRuleData, isValid: true }); + setMyActionsRuleForm({ data: ruleActionsData, isValid: true }); } }, [rule]); @@ -303,6 +371,8 @@ const EditRulePageComponent: FC = () => { return ruleI18n.DEFINITION; } else if (t === RuleStep.scheduleRule) { return ruleI18n.SCHEDULE; + } else if (t === RuleStep.ruleActions) { + return ruleI18n.RULE_ACTIONS; } return t; }) diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx index 3224c605192e6..fbdfcf4fc75d8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx @@ -10,6 +10,7 @@ import { getScheduleStepsData, getStepsData, getAboutStepsData, + getActionsStepsData, getHumanizedDuration, getModifiedAboutDetailsData, determineDetailsValue, @@ -17,16 +18,23 @@ import { import { mockRuleWithEverything, mockRule } from './all/__mocks__/mock'; import { esFilters } from '../../../../../../../../src/plugins/data/public'; import { Rule } from '../../../containers/detection_engine/rules'; -import { AboutStepRule, AboutStepRuleDetails, DefineStepRule, ScheduleStepRule } from './types'; +import { + AboutStepRule, + AboutStepRuleDetails, + DefineStepRule, + ScheduleStepRule, + ActionsStepRule, +} from './types'; describe('rule helpers', () => { describe('getStepsData', () => { - test('returns object with about, define, and schedule step properties formatted', () => { + test('returns object with about, define, schedule and actions step properties formatted', () => { const { defineRuleData, modifiedAboutRuleDetailsData, aboutRuleData, scheduleRuleData, + ruleActionsData, }: GetStepsData = getStepsData({ rule: mockRuleWithEverything('test-id'), }); @@ -98,7 +106,8 @@ describe('rule helpers', () => { }, ], }; - const scheduleRuleStepData = { enabled: true, from: '0s', interval: '5m', isNew: false }; + const scheduleRuleStepData = { from: '0s', interval: '5m', isNew: false }; + const ruleActionsStepData = { enabled: true, throttle: undefined, isNew: false, actions: [] }; const aboutRuleDataDetailsData = { note: '# this is some markdown documentation', description: '24/7', @@ -107,6 +116,7 @@ describe('rule helpers', () => { expect(defineRuleData).toEqual(defineRuleStepData); expect(aboutRuleData).toEqual(aboutRuleStepData); expect(scheduleRuleData).toEqual(scheduleRuleStepData); + expect(ruleActionsData).toEqual(ruleActionsStepData); expect(modifiedAboutRuleDetailsData).toEqual(aboutRuleDataDetailsData); }); }); @@ -274,7 +284,6 @@ describe('rule helpers', () => { const result: ScheduleStepRule = getScheduleStepsData(mockedRule); const expected = { isNew: false, - enabled: mockedRule.enabled, interval: mockedRule.interval, from: '0s', }; @@ -283,6 +292,24 @@ describe('rule helpers', () => { }); }); + describe('getActionsStepsData', () => { + test('returns expected ActionsStepRule rule object', () => { + const mockedRule = { + ...mockRule('test-id'), + actions: [], + }; + const result: ActionsStepRule = getActionsStepsData(mockedRule); + const expected = { + actions: [], + enabled: mockedRule.enabled, + isNew: false, + throttle: undefined, + }; + + expect(result).toEqual(expected); + }); + }); + describe('getModifiedAboutDetailsData', () => { test('returns object with "note" and "description" being those of passed in rule', () => { const result: AboutStepRuleDetails = getModifiedAboutDetailsData( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx index 2ace154482a27..50b76552ddc8f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx @@ -7,8 +7,11 @@ import dateMath from '@elastic/datemath'; import { get } from 'lodash/fp'; import moment from 'moment'; +import memoizeOne from 'memoize-one'; import { useLocation } from 'react-router-dom'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { Filter } from '../../../../../../../../src/plugins/data/public'; import { Rule, RuleType } from '../../../containers/detection_engine/rules'; import { FormData, FormHook, FormSchema } from '../../../shared_imports'; @@ -18,6 +21,7 @@ import { DefineStepRule, IMitreEnterpriseAttack, ScheduleStepRule, + ActionsStepRule, } from './types'; export interface GetStepsData { @@ -25,6 +29,7 @@ export interface GetStepsData { modifiedAboutRuleDetailsData: AboutStepRuleDetails; defineRuleData: DefineStepRule; scheduleRuleData: ScheduleStepRule; + ruleActionsData: ActionsStepRule; } export const getStepsData = ({ @@ -38,8 +43,29 @@ export const getStepsData = ({ const aboutRuleData: AboutStepRule = getAboutStepsData(rule, detailsView); const modifiedAboutRuleDetailsData: AboutStepRuleDetails = getModifiedAboutDetailsData(rule); const scheduleRuleData: ScheduleStepRule = getScheduleStepsData(rule); + const ruleActionsData: ActionsStepRule = getActionsStepsData(rule); - return { aboutRuleData, modifiedAboutRuleDetailsData, defineRuleData, scheduleRuleData }; + return { + aboutRuleData, + modifiedAboutRuleDetailsData, + defineRuleData, + scheduleRuleData, + ruleActionsData, + }; +}; + +export const getActionsStepsData = ( + rule: Omit & { actions: RuleAlertAction[] } +): ActionsStepRule => { + const { enabled, actions = [], meta } = rule; + + return { + actions: actions?.map(transformRuleToAlertAction), + isNew: false, + throttle: meta?.throttle, + kibanaSiemAppUrl: meta?.kibanaSiemAppUrl, + enabled, + }; }; export const getDefineStepsData = (rule: Rule): DefineStepRule => ({ @@ -60,12 +86,11 @@ export const getDefineStepsData = (rule: Rule): DefineStepRule => ({ }); export const getScheduleStepsData = (rule: Rule): ScheduleStepRule => { - const { enabled, interval, from } = rule; + const { interval, from } = rule; const fromHumanizedValue = getHumanizedDuration(from, interval); return { isNew: false, - enabled, interval, from: fromHumanizedValue, }; @@ -200,3 +225,46 @@ export const redirectToDetections = ( isAuthenticated != null && hasEncryptionKey != null && (!isSignalIndexExists || !isAuthenticated || !hasEncryptionKey); + +export const getActionMessageRuleParams = (ruleType: RuleType): string[] => { + const commonRuleParamsKeys = [ + 'id', + 'name', + 'description', + 'false_positives', + 'rule_id', + 'max_signals', + 'risk_score', + 'output_index', + 'references', + 'severity', + 'timeline_id', + 'timeline_title', + 'threat', + 'type', + 'version', + // 'lists', + ]; + + const ruleParamsKeys = [ + ...commonRuleParamsKeys, + ...(isMlRule(ruleType) + ? ['anomaly_threshold', 'machine_learning_job_id'] + : ['index', 'filters', 'language', 'query', 'saved_id']), + ].sort(); + + return ruleParamsKeys; +}; + +export const getActionMessageParams = memoizeOne((ruleType: RuleType | undefined): string[] => { + if (!ruleType) { + return []; + } + const actionMessageRuleParams = getActionMessageRuleParams(ruleType); + + return [ + 'state.signals_count', + '{context.results_link}', + ...actionMessageRuleParams.map(param => `context.rule.${param}`), + ]; +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx index b2c17fb8d38a8..d228ded5dd741 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/index.tsx @@ -8,7 +8,7 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useCallback, useRef, useState } from 'react'; import { Redirect } from 'react-router-dom'; -import { usePrePackagedRules } from '../../../containers/detection_engine/rules'; +import { usePrePackagedRules, importRules } from '../../../containers/detection_engine/rules'; import { DETECTION_ENGINE_PAGE_NAME, getDetectionEngineUrl, @@ -20,7 +20,7 @@ import { SpyRoute } from '../../../utils/route/spy_routes'; import { useUserInfo } from '../components/user_info'; import { AllRules } from './all'; -import { ImportRuleModal } from './components/import_rule_modal'; +import { ImportDataModal } from '../../../components/import_data_modal'; import { ReadOnlyCallOut } from './components/read_only_callout'; import { UpdatePrePackagedRulesCallOut } from './components/pre_packaged_rules/update_callout'; import { getPrePackagedRuleStatus, redirectToDetections } from './helpers'; @@ -96,10 +96,20 @@ const RulesPageComponent: React.FC = () => { return ( <> {userHasNoPermissions && } - setShowImportModal(false)} + description={i18n.SELECT_RULE} + errorMessage={i18n.IMPORT_FAILED} + failedDetailed={i18n.IMPORT_FAILED_DETAILED} importComplete={handleRefreshRules} + importData={importRules} + successMessage={i18n.SUCCESSFULLY_IMPORTED_RULES} + showCheckBox={true} + showModal={showImportModal} + submitBtnText={i18n.IMPORT_RULE_BTN_TITLE} + subtitle={i18n.INITIAL_PROMPT_TEXT} + title={i18n.IMPORT_RULE} /> defaultMessage: 'Reload {missingRules} deleted Elastic prebuilt {missingRules, plural, =1 {rule} other {rules}} ', }); + +export const IMPORT_RULE_BTN_TITLE = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.importRuleTitle', + { + defaultMessage: 'Import rule', + } +); + +export const SELECT_RULE = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.selectRuleDescription', + { + defaultMessage: 'Select a SIEM rule (as exported from the Detection Engine view) to import', + } +); + +export const INITIAL_PROMPT_TEXT = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.initialPromptTextDescription', + { + defaultMessage: 'Select or drag and drop a valid rules_export.ndjson file', + } +); + +export const OVERWRITE_WITH_SAME_NAME = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.overwriteDescription', + { + defaultMessage: 'Automatically overwrite saved objects with the same rule ID', + } +); + +export const SUCCESSFULLY_IMPORTED_RULES = (totalRules: number) => + i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.successfullyImportedRulesTitle', + { + values: { totalRules }, + defaultMessage: + 'Successfully imported {totalRules} {totalRules, plural, =1 {rule} other {rules}}', + } + ); + +export const IMPORT_FAILED = i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.importFailedTitle', + { + defaultMessage: 'Failed to import rules', + } +); + +export const IMPORT_FAILED_DETAILED = (ruleId: string, statusCode: number, message: string) => + i18n.translate( + 'xpack.siem.detectionEngine.components.importRuleModal.importFailedDetailedTitle', + { + values: { ruleId, statusCode, message }, + defaultMessage: 'Rule ID: {ruleId}\n Status Code: {statusCode}\n Message: {message}', + } + ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index d4caa4639f338..c1db24991c17c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AlertAction } from '../../../../../../../plugins/alerting/common'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; import { Filter } from '../../../../../../../../src/plugins/data/common'; import { RuleType } from '../../../containers/detection_engine/rules/types'; import { FieldValueQueryBar } from './components/query_bar'; @@ -27,6 +29,7 @@ export enum RuleStep { defineRule = 'define-rule', aboutRule = 'about-rule', scheduleRule = 'schedule-rule', + ruleActions = 'rule-actions', } export type RuleStatusType = 'passive' | 'active' | 'valid'; @@ -76,12 +79,18 @@ export interface DefineStepRule extends StepRuleData { } export interface ScheduleStepRule extends StepRuleData { - enabled: boolean; interval: string; from: string; to?: string; } +export interface ActionsStepRule extends StepRuleData { + actions: AlertAction[]; + enabled: boolean; + kibanaSiemAppUrl?: string; + throttle?: string | null; +} + export interface DefineStepRuleJson { anomaly_threshold?: number; index?: string[]; @@ -108,16 +117,18 @@ export interface AboutStepRuleJson { } export interface ScheduleStepRuleJson { - enabled: boolean; interval: string; from: string; to?: string; meta?: unknown; } -export type MyRule = Omit & { - immutable: boolean; -}; +export interface ActionsStepRuleJson { + actions: RuleAlertAction[]; + enabled: boolean; + throttle?: string | null; + meta?: unknown; +} export interface IMitreAttack { id: string; diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/details/index.tsx index 8af4731e4dda4..a12c95b3b5a6f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/details/index.tsx @@ -5,7 +5,7 @@ */ import { EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; -import React, { useContext, useEffect, useCallback, useMemo } from 'react'; +import React, { useEffect, useCallback, useMemo } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { StickyContainer } from 'react-sticky'; @@ -15,7 +15,7 @@ import { LastEventTime } from '../../../components/last_event_time'; import { AnomalyTableProvider } from '../../../components/ml/anomaly/anomaly_table_provider'; import { hostToCriteria } from '../../../components/ml/criteria/host_to_criteria'; import { hasMlUserPermissions } from '../../../components/ml/permissions/has_ml_user_permissions'; -import { MlCapabilitiesContext } from '../../../components/ml/permissions/ml_capabilities_provider'; +import { useMlCapabilities } from '../../../components/ml_popover/hooks/use_ml_capabilities'; import { scoreIntervalToDateTime } from '../../../components/ml/score/score_interval_to_datetime'; import { SiemNavigation } from '../../../components/navigation'; import { KpiHostsComponent } from '../../../components/page/hosts'; @@ -62,7 +62,7 @@ const HostDetailsComponent = React.memo( useEffect(() => { setHostDetailsTablesActivePageToZero(); }, [setHostDetailsTablesActivePageToZero, detailName]); - const capabilities = useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const kibana = useKibana(); const hostDetailsPageFilters: Filter[] = useMemo(() => getHostDetailsPageFilters(detailName), [ detailName, diff --git a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx index a7aa9920b7d08..d574925a91600 100644 --- a/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/hosts/hosts.tsx @@ -14,7 +14,6 @@ import { FiltersGlobal } from '../../components/filters_global'; import { HeaderPage } from '../../components/header_page'; import { LastEventTime } from '../../components/last_event_time'; import { hasMlUserPermissions } from '../../components/ml/permissions/has_ml_user_permissions'; -import { MlCapabilitiesContext } from '../../components/ml/permissions/ml_capabilities_provider'; import { SiemNavigation } from '../../components/navigation'; import { KpiHostsComponent } from '../../components/page/hosts'; import { manageQuery } from '../../components/page/manage_query'; @@ -30,6 +29,7 @@ import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from import { SpyRoute } from '../../utils/route/spy_routes'; import { esQuery } from '../../../../../../../src/plugins/data/public'; +import { useMlCapabilities } from '../../components/ml_popover/hooks/use_ml_capabilities'; import { HostsEmptyPage } from './hosts_empty_page'; import { HostsTabs } from './hosts_tabs'; import { navTabsHosts } from './nav_tabs'; @@ -52,7 +52,7 @@ export const HostsComponent = React.memo( to, hostsPagePath, }) => { - const capabilities = React.useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const kibana = useKibana(); const { tabName } = useParams(); const tabsFilters = React.useMemo(() => { diff --git a/x-pack/legacy/plugins/siem/public/pages/network/index.tsx b/x-pack/legacy/plugins/siem/public/pages/network/index.tsx index 48fc1421d90bb..babc153823b5a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/network/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/network/index.tsx @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { Redirect, Route, Switch, RouteComponentProps } from 'react-router-dom'; -import { MlCapabilitiesContext } from '../../components/ml/permissions/ml_capabilities_provider'; +import { useMlCapabilities } from '../../components/ml_popover/hooks/use_ml_capabilities'; import { hasMlUserPermissions } from '../../components/ml/permissions/has_ml_user_permissions'; import { FlowTarget } from '../../graphql/types'; @@ -24,7 +24,7 @@ const networkPagePath = `/:pageName(${SiemPageName.network})`; const ipDetailsPageBasePath = `${networkPagePath}/ip/:detailName`; const NetworkContainerComponent: React.FC = () => { - const capabilities = useContext(MlCapabilitiesContext); + const capabilities = useMlCapabilities(); const capabilitiesFetched = capabilities.capabilitiesFetched; const userHasMlUserPermissions = useMemo(() => hasMlUserPermissions(capabilities), [ capabilities, diff --git a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx index 6d30ea58089f0..38462e6526454 100644 --- a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx @@ -5,9 +5,10 @@ */ import ApolloClient from 'apollo-client'; -import React from 'react'; +import React, { useState, useCallback } from 'react'; import styled from 'styled-components'; +import { EuiButton } from '@elastic/eui'; import { HeaderPage } from '../../components/header_page'; import { StatefulOpenTimeline } from '../../components/open_timeline'; import { WrapperPage } from '../../components/wrapper_page'; @@ -27,16 +28,26 @@ type OwnProps = TimelinesProps; export const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; const TimelinesPageComponent: React.FC = ({ apolloClient }) => { + const [importCompleteToggle, setImportCompleteToggle] = useState(false); + const onImportTimelineBtnClick = useCallback(() => { + setImportCompleteToggle(true); + }, [setImportCompleteToggle]); return ( <> - + + + {i18n.ALL_TIMELINES_IMPORT_TIMELINE_TITLE} + + diff --git a/x-pack/legacy/plugins/siem/public/shared_imports.ts b/x-pack/legacy/plugins/siem/public/shared_imports.ts index edd7812b3bd16..c83433ef129c9 100644 --- a/x-pack/legacy/plugins/siem/public/shared_imports.ts +++ b/x-pack/legacy/plugins/siem/public/shared_imports.ts @@ -18,6 +18,9 @@ export { useForm, ValidationFunc, } from '../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; -export { Field } from '../../../../../src/plugins/es_ui_shared/static/forms/components'; +export { + Field, + SelectField, +} from '../../../../../src/plugins/es_ui_shared/static/forms/components'; export { fieldValidators } from '../../../../../src/plugins/es_ui_shared/static/forms/helpers'; export { ERROR_CODE } from '../../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.ts new file mode 100644 index 0000000000000..e14d20e3bc56e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.test.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 { addTags } from './add_tags'; +import { INTERNAL_RULE_ALERT_ID_KEY } from '../../../../common/constants'; + +describe('add_tags', () => { + test('it should add a rule id as an internal structure', () => { + const tags = addTags([], 'rule-1'); + expect(tags).toEqual([`${INTERNAL_RULE_ALERT_ID_KEY}:rule-1`]); + }); + + test('it should not allow duplicate tags to be created', () => { + const tags = addTags(['tag-1', 'tag-1'], 'rule-1'); + expect(tags).toEqual(['tag-1', `${INTERNAL_RULE_ALERT_ID_KEY}:rule-1`]); + }); + + test('it should not allow duplicate internal tags to be created when called two times in a row', () => { + const tags1 = addTags(['tag-1'], 'rule-1'); + const tags2 = addTags(tags1, 'rule-1'); + expect(tags2).toEqual(['tag-1', `${INTERNAL_RULE_ALERT_ID_KEY}:rule-1`]); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.ts new file mode 100644 index 0000000000000..6955e57d099be --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/add_tags.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { INTERNAL_RULE_ALERT_ID_KEY } from '../../../../common/constants'; + +export const addTags = (tags: string[] = [], ruleAlertId: string): string[] => + Array.from(new Set([...tags, `${INTERNAL_RULE_ALERT_ID_KEY}:${ruleAlertId}`])); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts new file mode 100644 index 0000000000000..189c596a77125 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { buildSignalsSearchQuery } from './build_signals_query'; + +describe('buildSignalsSearchQuery', () => { + it('returns proper query object', () => { + const index = 'index'; + const ruleId = 'ruleId-12'; + const from = '123123123'; + const to = '1123123123'; + + expect( + buildSignalsSearchQuery({ + index, + from, + to, + ruleId, + }) + ).toEqual({ + index, + body: { + query: { + bool: { + filter: [ + { + bool: { + should: { + match: { + 'signal.rule.rule_id': ruleId, + }, + }, + minimum_should_match: 1, + }, + }, + { + range: { + '@timestamp': { + gt: from, + lte: to, + }, + }, + }, + ], + }, + }, + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts new file mode 100644 index 0000000000000..b973d4c5f4e98 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/build_signals_query.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface BuildSignalsSearchQuery { + ruleId: string; + index: string; + from: string; + to: string; +} + +export const buildSignalsSearchQuery = ({ ruleId, index, from, to }: BuildSignalsSearchQuery) => ({ + index, + body: { + query: { + bool: { + filter: [ + { + bool: { + should: { + match: { + 'signal.rule.rule_id': ruleId, + }, + }, + minimum_should_match: 1, + }, + }, + { + range: { + '@timestamp': { + gt: from, + lte: to, + }, + }, + }, + ], + }, + }, + }, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts new file mode 100644 index 0000000000000..073251b68f414 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.test.ts @@ -0,0 +1,73 @@ +/* + * 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 { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { createNotifications } from './create_notifications'; + +describe('createNotifications', () => { + let alertsClient: ReturnType; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + it('calls the alertsClient with proper params', async () => { + const ruleAlertId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + await createNotifications({ + alertsClient, + actions: [], + ruleAlertId, + enabled: true, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + params: expect.objectContaining({ + ruleAlertId, + }), + }), + }) + ); + }); + + it('calls the alertsClient with transformed actions', async () => { + const action = { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'Rule generated {{state.signals_count}} signals' }, + action_type_id: '.slack', + }; + await createNotifications({ + alertsClient, + actions: [action], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + actions: expect.arrayContaining([ + { + group: action.group, + id: action.id, + params: action.params, + actionTypeId: '.slack', + }, + ]), + }), + }) + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.ts new file mode 100644 index 0000000000000..3a1697f1c8afc --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/create_notifications.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 { Alert } from '../../../../../../../plugins/alerting/common'; +import { APP_ID, NOTIFICATIONS_ID } from '../../../../common/constants'; +import { CreateNotificationParams } from './types'; +import { addTags } from './add_tags'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; + +export const createNotifications = async ({ + alertsClient, + actions, + enabled, + ruleAlertId, + interval, + name, + tags, +}: CreateNotificationParams): Promise => + alertsClient.create({ + data: { + name, + tags: addTags(tags, ruleAlertId), + alertTypeId: NOTIFICATIONS_ID, + consumer: APP_ID, + params: { + ruleAlertId, + }, + schedule: { interval }, + enabled, + actions: actions?.map(transformRuleToAlertAction), + throttle: null, + }, + }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts new file mode 100644 index 0000000000000..7e5c0eaf6286e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.test.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { deleteNotifications } from './delete_notifications'; +import { readNotifications } from './read_notifications'; +jest.mock('./read_notifications'); + +describe('deleteNotifications', () => { + let alertsClient: ReturnType; + const notificationId = 'notification-52128c15-0d1b-4716-a4c5-46997ac7f3bd'; + const ruleAlertId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + it('should return null if notification was not found', async () => { + (readNotifications as jest.Mock).mockResolvedValue(null); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(result).toBe(null); + }); + + it('should call alertsClient.delete if notification was found', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: notificationId, + }); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: notificationId }); + }); + + it('should call alertsClient.delete if notification.id was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: null }); + }); + + it('should return null if alertsClient.delete rejects with 404 if notification.id was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + alertsClient.delete.mockRejectedValue({ + output: { + statusCode: 404, + }, + }); + + const result = await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual(null); + }); + + it('should return error object if alertsClient.delete rejects with status different than 404 and if notification.id was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + const errorObject = { + output: { + statusCode: 500, + }, + }; + + alertsClient.delete.mockRejectedValue(errorObject); + + let errorResult; + try { + await deleteNotifications({ + alertsClient, + id: notificationId, + ruleAlertId, + }); + } catch (error) { + errorResult = error; + } + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(errorResult).toEqual(errorObject); + }); + + it('should return null if notification.id and id were null', async () => { + (readNotifications as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteNotifications({ + alertsClient, + id: undefined, + ruleAlertId, + }); + + expect(result).toEqual(null); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts new file mode 100644 index 0000000000000..7e244f96f1649 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/delete_notifications.ts @@ -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 { readNotifications } from './read_notifications'; +import { DeleteNotificationParams } from './types'; + +export const deleteNotifications = async ({ + alertsClient, + id, + ruleAlertId, +}: DeleteNotificationParams) => { + const notification = await readNotifications({ alertsClient, id, ruleAlertId }); + if (notification == null) { + return null; + } + + if (notification.id != null) { + await alertsClient.delete({ id: notification.id }); + return notification; + } else if (id != null) { + try { + await alertsClient.delete({ id }); + return notification; + } catch (err) { + if (err.output.statusCode === 404) { + return null; + } else { + throw err; + } + } + } else { + return null; + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.ts new file mode 100644 index 0000000000000..0e9e4a8370ec8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.test.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getFilter } from './find_notifications'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; + +describe('find_notifications', () => { + test('it returns a full filter with an AND if sent down', () => { + expect(getFilter('alert.attributes.enabled: true')).toEqual( + `alert.attributes.alertTypeId: ${NOTIFICATIONS_ID} AND alert.attributes.enabled: true` + ); + }); + + test('it returns existing filter with no AND when not set', () => { + expect(getFilter(null)).toEqual(`alert.attributes.alertTypeId: ${NOTIFICATIONS_ID}`); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts new file mode 100644 index 0000000000000..fcdeda608fe4e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/find_notifications.ts @@ -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 { FindResult } from '../../../../../../../plugins/alerting/server'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; +import { FindNotificationParams } from './types'; + +export const getFilter = (filter: string | null | undefined) => { + if (filter == null) { + return `alert.attributes.alertTypeId: ${NOTIFICATIONS_ID}`; + } else { + return `alert.attributes.alertTypeId: ${NOTIFICATIONS_ID} AND ${filter}`; + } +}; + +export const findNotifications = async ({ + alertsClient, + perPage, + page, + fields, + filter, + sortField, + sortOrder, +}: FindNotificationParams): Promise => + alertsClient.find({ + options: { + fields, + page, + perPage, + filter: getFilter(filter), + sortOrder, + sortField, + }, + }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts new file mode 100644 index 0000000000000..33cee6d074b70 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/get_signals_count.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +import { getNotificationResultsLink } from './utils'; +import { NotificationExecutorOptions } from './types'; +import { parseScheduleDates } from '../signals/utils'; +import { buildSignalsSearchQuery } from './build_signals_query'; + +interface SignalsCountResults { + signalsCount: string; + resultsLink: string; +} + +interface GetSignalsCount { + from: Date | string; + to: Date | string; + ruleAlertId: string; + ruleId: string; + index: string; + kibanaSiemAppUrl: string | undefined; + callCluster: NotificationExecutorOptions['services']['callCluster']; +} + +export const getSignalsCount = async ({ + from, + to, + ruleAlertId, + ruleId, + index, + callCluster, + kibanaSiemAppUrl = '', +}: GetSignalsCount): Promise => { + const fromMoment = moment.isDate(from) ? moment(from) : parseScheduleDates(from); + const toMoment = moment.isDate(to) ? moment(to) : parseScheduleDates(to); + + if (!fromMoment || !toMoment) { + throw new Error(`There was an issue with parsing ${from} or ${to} into Moment object`); + } + + const fromInMs = fromMoment.format('x'); + const toInMs = toMoment.format('x'); + + const query = buildSignalsSearchQuery({ + index, + ruleId, + to: toInMs, + from: fromInMs, + }); + + const result = await callCluster('count', query); + const resultsLink = getNotificationResultsLink({ + kibanaSiemAppUrl: `${kibanaSiemAppUrl}`, + id: ruleAlertId, + from: fromInMs, + to: toInMs, + }); + + return { + signalsCount: result.count, + resultsLink, + }; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts new file mode 100644 index 0000000000000..834ad2460959c --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.test.ts @@ -0,0 +1,154 @@ +/* + * 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 { readNotifications } from './read_notifications'; +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { + getNotificationResult, + getFindNotificationsResultWithSingleHit, +} from '../routes/__mocks__/request_responses'; + +class TestError extends Error { + constructor() { + super(); + + this.name = 'CustomError'; + this.output = { statusCode: 404 }; + } + public output: { statusCode: number }; +} + +describe('read_notifications', () => { + let alertsClient: ReturnType; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + describe('readNotifications', () => { + test('should return the output from alertsClient if id is set but ruleAlertId is undefined', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + expect(rule).toEqual(getNotificationResult()); + }); + test('should return null if saved object found by alerts client given id is not alert type', async () => { + const result = getNotificationResult(); + delete result.alertTypeId; + alertsClient.get.mockResolvedValue(result); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + expect(rule).toEqual(null); + }); + + test('should return error if alerts client throws 404 error on get', async () => { + alertsClient.get.mockImplementation(() => { + throw new TestError(); + }); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + expect(rule).toEqual(null); + }); + + test('should return error if alerts client throws error on get', async () => { + alertsClient.get.mockImplementation(() => { + throw new Error('Test error'); + }); + try { + await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: undefined, + }); + } catch (exc) { + expect(exc.message).toEqual('Test error'); + } + }); + + test('should return the output from alertsClient if id is set but ruleAlertId is null', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + + const rule = await readNotifications({ + alertsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ruleAlertId: null, + }); + expect(rule).toEqual(getNotificationResult()); + }); + + test('should return the output from alertsClient if id is undefined but ruleAlertId is set', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: undefined, + ruleAlertId: 'rule-1', + }); + expect(rule).toEqual(getNotificationResult()); + }); + + test('should return null if the output from alertsClient with ruleAlertId set is empty', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue({ data: [], page: 0, perPage: 1, total: 0 }); + + const rule = await readNotifications({ + alertsClient, + id: undefined, + ruleAlertId: 'rule-1', + }); + expect(rule).toEqual(null); + }); + + test('should return the output from alertsClient if id is null but ruleAlertId is set', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: null, + ruleAlertId: 'rule-1', + }); + expect(rule).toEqual(getNotificationResult()); + }); + + test('should return null if id and ruleAlertId are null', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: null, + ruleAlertId: null, + }); + expect(rule).toEqual(null); + }); + + test('should return null if id and ruleAlertId are undefined', async () => { + alertsClient.get.mockResolvedValue(getNotificationResult()); + alertsClient.find.mockResolvedValue(getFindNotificationsResultWithSingleHit()); + + const rule = await readNotifications({ + alertsClient, + id: undefined, + ruleAlertId: undefined, + }); + expect(rule).toEqual(null); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts new file mode 100644 index 0000000000000..87bdd6f3f40e1 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/read_notifications.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SanitizedAlert } from '../../../../../../../plugins/alerting/common'; +import { ReadNotificationParams, isAlertType } from './types'; +import { findNotifications } from './find_notifications'; +import { INTERNAL_RULE_ALERT_ID_KEY } from '../../../../common/constants'; + +export const readNotifications = async ({ + alertsClient, + id, + ruleAlertId, +}: ReadNotificationParams): Promise => { + if (id != null) { + try { + const notification = await alertsClient.get({ id }); + if (isAlertType(notification)) { + return notification; + } else { + return null; + } + } catch (err) { + if (err?.output?.statusCode === 404) { + return null; + } else { + // throw non-404 as they would be 500 or other internal errors + throw err; + } + } + } else if (ruleAlertId != null) { + const notificationFromFind = await findNotifications({ + alertsClient, + filter: `alert.attributes.tags: "${INTERNAL_RULE_ALERT_ID_KEY}:${ruleAlertId}"`, + page: 1, + }); + if (notificationFromFind.data.length === 0 || !isAlertType(notificationFromFind.data[0])) { + return null; + } else { + return notificationFromFind.data[0]; + } + } else { + // should never get here, and yet here we are. + return null; + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts new file mode 100644 index 0000000000000..50ac10347e062 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.test.ts @@ -0,0 +1,142 @@ +/* + * 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 { savedObjectsClientMock } from 'src/core/server/mocks'; +import { loggerMock } from 'src/core/server/logging/logger.mock'; +import { getResult } from '../routes/__mocks__/request_responses'; +import { rulesNotificationAlertType } from './rules_notification_alert_type'; +import { buildSignalsSearchQuery } from './build_signals_query'; +import { AlertInstance } from '../../../../../../../plugins/alerting/server'; +import { NotificationExecutorOptions } from './types'; +jest.mock('./build_signals_query'); + +describe('rules_notification_alert_type', () => { + let payload: NotificationExecutorOptions; + let alert: ReturnType; + let alertInstanceMock: Record; + let alertInstanceFactoryMock: () => AlertInstance; + let savedObjectsClient: ReturnType; + let logger: ReturnType; + let callClusterMock: jest.Mock; + + beforeEach(() => { + alertInstanceMock = { + scheduleActions: jest.fn(), + replaceState: jest.fn(), + }; + alertInstanceMock.replaceState.mockReturnValue(alertInstanceMock); + alertInstanceFactoryMock = jest.fn().mockReturnValue(alertInstanceMock); + callClusterMock = jest.fn(); + savedObjectsClient = savedObjectsClientMock.create(); + logger = loggerMock.create(); + + payload = { + alertId: '1111', + services: { + savedObjectsClient, + alertInstanceFactory: alertInstanceFactoryMock, + callCluster: callClusterMock, + }, + params: { ruleAlertId: '2222' }, + state: {}, + spaceId: '', + name: 'name', + tags: [], + startedAt: new Date('2019-12-14T16:40:33.400Z'), + previousStartedAt: new Date('2019-12-13T16:40:33.400Z'), + createdBy: 'elastic', + updatedBy: 'elastic', + }; + + alert = rulesNotificationAlertType({ + logger, + }); + }); + + describe('executor', () => { + it('throws an error if rule alert was not found', async () => { + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + attributes: {}, + type: 'type', + references: [], + }); + await alert.executor(payload); + expect(logger.error).toHaveBeenCalledWith( + `Saved object for alert ${payload.params.ruleAlertId} was not found` + ); + }); + + it('should call buildSignalsSearchQuery with proper params', async () => { + const ruleAlert = getResult(); + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + callClusterMock.mockResolvedValue({ + count: 0, + }); + + await alert.executor(payload); + + expect(buildSignalsSearchQuery).toHaveBeenCalledWith( + expect.objectContaining({ + from: '1576255233400', + index: '.siem-signals', + ruleId: 'rule-1', + to: '1576341633400', + }) + ); + }); + + it('should not call alertInstanceFactory if signalsCount was 0', async () => { + const ruleAlert = getResult(); + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + callClusterMock.mockResolvedValue({ + count: 0, + }); + + await alert.executor(payload); + + expect(alertInstanceFactoryMock).not.toHaveBeenCalled(); + }); + + it('should call scheduleActions if signalsCount was greater than 0', async () => { + const ruleAlert = getResult(); + savedObjectsClient.get.mockResolvedValue({ + id: 'id', + type: 'type', + references: [], + attributes: ruleAlert, + }); + callClusterMock.mockResolvedValue({ + count: 10, + }); + + await alert.executor(payload); + + expect(alertInstanceFactoryMock).toHaveBeenCalled(); + expect(alertInstanceMock.replaceState).toHaveBeenCalledWith( + expect.objectContaining({ signals_count: 10 }) + ); + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledWith( + 'default', + expect.objectContaining({ + rule: expect.objectContaining({ + name: ruleAlert.name, + }), + }) + ); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.ts new file mode 100644 index 0000000000000..32e64138ff6e0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/rules_notification_alert_type.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 { Logger } from 'src/core/server'; +import { schema } from '@kbn/config-schema'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; + +import { NotificationAlertTypeDefinition } from './types'; +import { getSignalsCount } from './get_signals_count'; +import { RuleAlertAttributes } from '../signals/types'; +import { siemRuleActionGroups } from '../signals/siem_rule_action_groups'; +import { scheduleNotificationActions } from './schedule_notification_actions'; + +export const rulesNotificationAlertType = ({ + logger, +}: { + logger: Logger; +}): NotificationAlertTypeDefinition => ({ + id: NOTIFICATIONS_ID, + name: 'SIEM Notifications', + actionGroups: siemRuleActionGroups, + defaultActionGroupId: 'default', + validate: { + params: schema.object({ + ruleAlertId: schema.string(), + }), + }, + async executor({ startedAt, previousStartedAt, alertId, services, params }) { + const ruleAlertSavedObject = await services.savedObjectsClient.get( + 'alert', + params.ruleAlertId + ); + + if (!ruleAlertSavedObject.attributes.params) { + logger.error(`Saved object for alert ${params.ruleAlertId} was not found`); + return; + } + + const { params: ruleAlertParams, name: ruleName } = ruleAlertSavedObject.attributes; + const ruleParams = { ...ruleAlertParams, name: ruleName, id: ruleAlertSavedObject.id }; + + const { signalsCount, resultsLink } = await getSignalsCount({ + from: previousStartedAt ?? `now-${ruleParams.interval}`, + to: startedAt, + index: ruleParams.outputIndex, + ruleId: ruleParams.ruleId!, + kibanaSiemAppUrl: ruleAlertParams.meta?.kibanaSiemAppUrl as string, + ruleAlertId: ruleAlertSavedObject.id, + callCluster: services.callCluster, + }); + + logger.info( + `Found ${signalsCount} signals using signal rule name: "${ruleParams.name}", id: "${params.ruleAlertId}", rule_id: "${ruleParams.ruleId}" in "${ruleParams.outputIndex}" index` + ); + + if (signalsCount) { + const alertInstance = services.alertInstanceFactory(alertId); + scheduleNotificationActions({ alertInstance, signalsCount, resultsLink, ruleParams }); + } + }, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.ts new file mode 100644 index 0000000000000..b858b25377ffe --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/schedule_notification_actions.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 { mapKeys, snakeCase } from 'lodash/fp'; +import { AlertInstance } from '../../../../../../../plugins/alerting/server'; +import { RuleTypeParams } from '../types'; + +type NotificationRuleTypeParams = RuleTypeParams & { + name: string; + id: string; +}; + +interface ScheduleNotificationActions { + alertInstance: AlertInstance; + signalsCount: string; + resultsLink: string; + ruleParams: NotificationRuleTypeParams; +} + +export const scheduleNotificationActions = ({ + alertInstance, + signalsCount, + resultsLink, + ruleParams, +}: ScheduleNotificationActions): AlertInstance => + alertInstance + .replaceState({ + signals_count: signalsCount, + }) + .scheduleActions('default', { + results_link: resultsLink, + rule: mapKeys(snakeCase, ruleParams), + }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.ts new file mode 100644 index 0000000000000..4fce037b483d5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.test.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 { loggerMock } from 'src/core/server/logging/logger.mock'; +import { getNotificationResult, getResult } from '../routes/__mocks__/request_responses'; +import { isAlertTypes, isNotificationAlertExecutor } from './types'; +import { rulesNotificationAlertType } from './rules_notification_alert_type'; + +describe('types', () => { + it('isAlertTypes should return true if is RuleNotificationAlertType type', () => { + expect(isAlertTypes([getNotificationResult()])).toEqual(true); + }); + + it('isAlertTypes should return false if is not RuleNotificationAlertType', () => { + expect(isAlertTypes([getResult()])).toEqual(false); + }); + + it('isNotificationAlertExecutor should return true it passed object is NotificationAlertTypeDefinition type', () => { + expect( + isNotificationAlertExecutor(rulesNotificationAlertType({ logger: loggerMock.create() })) + ).toEqual(true); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts new file mode 100644 index 0000000000000..edcd821353bc8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/types.ts @@ -0,0 +1,107 @@ +/* + * 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 { + AlertsClient, + PartialAlert, + AlertType, + State, + AlertExecutorOptions, +} from '../../../../../../../plugins/alerting/server'; +import { Alert } from '../../../../../../../plugins/alerting/common'; +import { NOTIFICATIONS_ID } from '../../../../common/constants'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; + +export interface RuleNotificationAlertType extends Alert { + params: { + ruleAlertId: string; + }; +} + +export interface FindNotificationParams { + alertsClient: AlertsClient; + perPage?: number; + page?: number; + sortField?: string; + filter?: string; + fields?: string[]; + sortOrder?: 'asc' | 'desc'; +} + +export interface FindNotificationsRequestParams { + per_page: number; + page: number; + search?: string; + sort_field?: string; + filter?: string; + fields?: string[]; + sort_order?: 'asc' | 'desc'; +} + +export interface Clients { + alertsClient: AlertsClient; +} + +export type UpdateNotificationParams = Omit & { + actions: RuleAlertAction[]; + id?: string; + tags?: string[]; + interval: string | null; + ruleAlertId: string; +} & Clients; + +export type DeleteNotificationParams = Clients & { + id?: string; + ruleAlertId?: string; +}; + +export interface NotificationAlertParams { + actions: RuleAlertAction[]; + enabled: boolean; + ruleAlertId: string; + interval: string; + name: string; + tags?: string[]; + throttle?: null; +} + +export type CreateNotificationParams = NotificationAlertParams & Clients; + +export interface ReadNotificationParams { + alertsClient: AlertsClient; + id?: string | null; + ruleAlertId?: string | null; +} + +export const isAlertTypes = ( + partialAlert: PartialAlert[] +): partialAlert is RuleNotificationAlertType[] => { + return partialAlert.every(rule => isAlertType(rule)); +}; + +export const isAlertType = ( + partialAlert: PartialAlert +): partialAlert is RuleNotificationAlertType => { + return partialAlert.alertTypeId === NOTIFICATIONS_ID; +}; + +export type NotificationExecutorOptions = Omit & { + params: { + ruleAlertId: string; + }; +}; + +// This returns true because by default a NotificationAlertTypeDefinition is an AlertType +// since we are only increasing the strictness of params. +export const isNotificationAlertExecutor = ( + obj: NotificationAlertTypeDefinition +): obj is AlertType => { + return true; +}; + +export type NotificationAlertTypeDefinition = Omit & { + executor: ({ services, params, state }: NotificationExecutorOptions) => Promise; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts new file mode 100644 index 0000000000000..4c077dd9fc1fb --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.test.ts @@ -0,0 +1,137 @@ +/* + * 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 { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { updateNotifications } from './update_notifications'; +import { readNotifications } from './read_notifications'; +import { createNotifications } from './create_notifications'; +import { getNotificationResult } from '../routes/__mocks__/request_responses'; +jest.mock('./read_notifications'); +jest.mock('./create_notifications'); + +describe('updateNotifications', () => { + const notification = getNotificationResult(); + let alertsClient: ReturnType; + + beforeEach(() => { + alertsClient = alertsClientMock.create(); + }); + + it('should update the existing notification if interval provided', async () => { + (readNotifications as jest.Mock).mockResolvedValue(notification); + + await updateNotifications({ + alertsClient, + actions: [], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '10m', + name: '', + tags: [], + }); + + expect(alertsClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: notification.id, + data: expect.objectContaining({ + params: expect.objectContaining({ + ruleAlertId: 'new-rule-id', + }), + }), + }) + ); + }); + + it('should create a new notification if did not exist', async () => { + (readNotifications as jest.Mock).mockResolvedValue(null); + + const params = { + alertsClient, + actions: [], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '10m', + name: '', + tags: [], + }; + + await updateNotifications(params); + + expect(createNotifications).toHaveBeenCalledWith(expect.objectContaining(params)); + }); + + it('should delete notification if notification was found and interval is null', async () => { + (readNotifications as jest.Mock).mockResolvedValue(notification); + + await updateNotifications({ + alertsClient, + actions: [], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: null, + name: '', + tags: [], + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notification.id, + }) + ); + }); + + it('should call the alertsClient with transformed actions', async () => { + (readNotifications as jest.Mock).mockResolvedValue(notification); + const action = { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'Rule generated {{state.signals_count}} signals' }, + action_type_id: '.slack', + }; + await updateNotifications({ + alertsClient, + actions: [action], + ruleAlertId: 'new-rule-id', + enabled: true, + interval: '10m', + name: '', + tags: [], + }); + + expect(alertsClient.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + actions: expect.arrayContaining([ + { + group: action.group, + id: action.id, + params: action.params, + actionTypeId: '.slack', + }, + ]), + }), + }) + ); + }); + + it('returns null if notification was not found and interval was null', async () => { + (readNotifications as jest.Mock).mockResolvedValue(null); + const ruleAlertId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + const result = await updateNotifications({ + alertsClient, + actions: [], + enabled: true, + id: notification.id, + ruleAlertId, + name: notification.name, + tags: notification.tags, + interval: null, + }); + + expect(result).toEqual(null); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.ts new file mode 100644 index 0000000000000..3197d21c0e95a --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/update_notifications.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 { PartialAlert } from '../../../../../../../plugins/alerting/server'; +import { readNotifications } from './read_notifications'; +import { UpdateNotificationParams } from './types'; +import { addTags } from './add_tags'; +import { createNotifications } from './create_notifications'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; + +export const updateNotifications = async ({ + alertsClient, + actions, + enabled, + id, + ruleAlertId, + name, + tags, + interval, +}: UpdateNotificationParams): Promise => { + const notification = await readNotifications({ alertsClient, id, ruleAlertId }); + + if (interval && notification) { + const result = await alertsClient.update({ + id: notification.id, + data: { + tags: addTags(tags, ruleAlertId), + name, + schedule: { + interval, + }, + actions: actions?.map(transformRuleToAlertAction), + params: { + ruleAlertId, + }, + throttle: null, + }, + }); + return result; + } + + if (interval && !notification) { + const result = await createNotifications({ + alertsClient, + enabled, + tags, + name, + interval, + actions, + ruleAlertId, + }); + return result; + } + + if (!interval && notification) { + await alertsClient.delete({ id: notification.id }); + return null; + } + + return null; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.ts new file mode 100644 index 0000000000000..0d363e1f6f3c2 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.test.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 { getNotificationResultsLink } from './utils'; + +describe('utils', () => { + it('getNotificationResultsLink', () => { + const resultLink = getNotificationResultsLink({ + kibanaSiemAppUrl: 'http://localhost:5601/app/siem', + id: 'notification-id', + from: '00000', + to: '1111', + }); + expect(resultLink).toEqual( + `http://localhost:5601/app/siem#/detections/rules/id/notification-id?timerange=(global:(linkTo:!(timeline),timerange:(from:00000,kind:absolute,to:1111)),timeline:(linkTo:!(global),timerange:(from:00000,kind:absolute,to:1111)))` + ); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts new file mode 100644 index 0000000000000..b8a3c4199c4f0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/notifications/utils.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const getNotificationResultsLink = ({ + kibanaSiemAppUrl, + id, + from, + to, +}: { + kibanaSiemAppUrl: string; + id: string; + from: string; + to: string; +}) => + `${kibanaSiemAppUrl}#/detections/rules/id/${id}?timerange=(global:(linkTo:!(timeline),timerange:(from:${from},kind:absolute,to:${to})),timeline:(linkTo:!(global),timerange:(from:${from},kind:absolute,to:${to})))`; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_context.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_context.ts index ebf6b3ae79ea8..2e5c29bc0221a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_context.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_context.ts @@ -12,11 +12,13 @@ import { } from '../../../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../../../../plugins/alerting/server/mocks'; import { actionsClientMock } from '../../../../../../../../plugins/actions/server/mocks'; +import { licensingMock } from '../../../../../../../../plugins/licensing/server/mocks'; const createMockClients = () => ({ actionsClient: actionsClientMock.create(), alertsClient: alertsClientMock.create(), clusterClient: elasticsearchServiceMock.createScopedClusterClient(), + licensing: { license: licensingMock.createLicenseMock() }, savedObjectsClient: savedObjectsClientMock.create(), siemClient: { signalsIndex: 'mockSignalsIndex' }, }); @@ -33,6 +35,7 @@ const createRequestContextMock = ( elasticsearch: { ...coreContext.elasticsearch, dataClient: clients.clusterClient }, savedObjects: { client: clients.savedObjectsClient }, }, + licensing: clients.licensing, siem: { getSiemClient: jest.fn(() => clients.siemClient) }, } as unknown) as RequestHandlerContext; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 0e0ab58a7a199..24f50c5ce87a0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -28,6 +28,7 @@ import { } from '../../rules/types'; import { RuleAlertParamsRest, PrepackagedRules } from '../../types'; import { requestMock } from './request'; +import { RuleNotificationAlertType } from '../../notifications/types'; export const mockPrepackagedRule = (): PrepackagedRules => ({ rule_id: 'rule-1', @@ -204,11 +205,11 @@ export const getPrepackagedRulesStatusRequest = () => path: `${DETECTION_ENGINE_PREPACKAGED_URL}/_status`, }); -export interface FindHit { +export interface FindHit { page: number; perPage: number; total: number; - data: RuleAlertType[]; + data: T[]; } export const getEmptyFindResult = (): FindHit => ({ @@ -294,17 +295,50 @@ export const getCreateRequest = () => body: typicalPayload(), }); -export const createMlRuleRequest = () => { +export const typicalMlRulePayload = () => { const { query, language, index, ...mlParams } = typicalPayload(); + return { + ...mlParams, + type: 'machine_learning', + anomaly_threshold: 58, + machine_learning_job_id: 'typical-ml-job-id', + }; +}; + +export const createMlRuleRequest = () => { + return requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_RULES_URL, + body: typicalMlRulePayload(), + }); +}; + +export const createBulkMlRuleRequest = () => { + return requestMock.create({ + method: 'post', + path: DETECTION_ENGINE_RULES_URL, + body: [typicalMlRulePayload()], + }); +}; + +export const createRuleWithActionsRequest = () => { + const payload = typicalPayload(); + return requestMock.create({ method: 'post', path: DETECTION_ENGINE_RULES_URL, body: { - ...mlParams, - type: 'machine_learning', - anomaly_threshold: 50, - machine_learning_job_id: 'some-uuid', + ...payload, + throttle: '5m', + actions: [ + { + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + params: { message: 'Rule generated {{state.signals_count}} signals' }, + action_type_id: '.slack', + }, + ], }, }); }; @@ -558,6 +592,10 @@ export const getFindResultStatus = (): SavedObjectsFindResponse } => ({ export const getNonEmptyIndex = (): { _shards: Partial } => ({ _shards: { total: 1 }, }); + +export const getNotificationResult = (): RuleNotificationAlertType => ({ + id: '200dbf2f-b269-4bf9-aa85-11ba32ba73ba', + name: 'Notification for Rule Test', + tags: ['__internal_rule_alert_id:85b64e8a-2e40-4096-86af-5ac172c10825'], + alertTypeId: 'siem.notifications', + consumer: 'siem', + params: { + ruleAlertId: '85b64e8a-2e40-4096-86af-5ac172c10825', + }, + schedule: { + interval: '5m', + }, + enabled: true, + actions: [ + { + actionTypeId: '.slack', + params: { + message: + 'Rule generated {{state.signals_count}} signals\n\n{{context.rule.name}}\n{{{context.results_link}}}', + }, + group: 'default', + id: '99403909-ca9b-49ba-9d7a-7e5320e68d05', + }, + ], + throttle: null, + apiKey: null, + apiKeyOwner: 'elastic', + createdBy: 'elastic', + updatedBy: 'elastic', + createdAt: new Date('2020-03-21T11:15:13.530Z'), + muteAll: false, + mutedInstanceIds: [], + scheduledTaskId: '62b3a130-6b70-11ea-9ce9-6b9818c4cbd7', + updatedAt: new Date('2020-03-21T12:37:08.730Z'), +}); + +export const getFindNotificationsResultWithSingleHit = (): FindHit => ({ + page: 1, + perPage: 1, + total: 1, + data: [getNotificationResult()], +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts index 13d75cc44992c..a2485ec477453 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/utils.ts @@ -23,6 +23,21 @@ export const getSimpleRule = (ruleId = 'rule-1'): Partial = query: 'user.name: root or user.name: admin', }); +/** + * This is a typical ML rule for testing + * @param ruleId + */ +export const getSimpleMlRule = (ruleId = 'rule-1'): Partial => ({ + name: 'Simple Rule Query', + description: 'Simple Rule Query', + risk_score: 1, + rule_id: ruleId, + severity: 'high', + type: 'machine_learning', + anomaly_threshold: 44, + machine_learning_job_id: 'some_job_id', +}); + /** * This is a typical simple rule for testing that is easy for most basic testing * @param ruleId diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index e2af678c828e6..32b8eca298229 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -13,6 +13,7 @@ import { getFindResultWithSingleHit, getEmptyFindResult, getResult, + createBulkMlRuleRequest, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesBulkRoute } from './create_rules_bulk_route'; @@ -56,6 +57,22 @@ describe('create_rules_bulk', () => { }); describe('unhappy paths', () => { + it('returns an error object if creating an ML rule with an insufficient license', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + + const response = await server.inject(createBulkMlRuleRequest(), context); + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + error: { + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }, + rule_id: 'rule-1', + }, + ]); + }); + it('returns an error object if the index does not exist', async () => { clients.clusterClient.callAsCurrentUser.mockResolvedValue(getEmptyIndex()); const response = await server.inject(getReadBulkRequest(), context); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts index 4ffa29c385f28..1ca9f7ef9075e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts @@ -19,6 +19,7 @@ import { createBulkErrorObject, buildRouteValidation, buildSiemResponse, + validateLicenseForRuleType, } from '../utils'; import { createRulesBulkSchema } from '../schemas/create_rules_bulk_schema'; import { rulesBulkSchema } from '../schemas/response/rules_bulk_schema'; @@ -90,6 +91,8 @@ export const createRulesBulkRoute = (router: IRouter) => { } = payloadRule; const ruleIdOrUuid = ruleId ?? uuid.v4(); try { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + const finalIndex = outputIndex ?? siemClient.signalsIndex; const indexExists = await getIndexExists(clusterClient.callAsCurrentUser, finalIndex); if (!indexExists) { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index 1a4e19c2047b5..4da879d12f809 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -15,10 +15,13 @@ import { getEmptyIndex, getFindResultWithSingleHit, createMlRuleRequest, + createRuleWithActionsRequest, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesRoute } from './create_rules_route'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; +import { createNotifications } from '../../notifications/create_notifications'; +jest.mock('../../notifications/create_notifications'); describe('create_rules', () => { let server: ReturnType; @@ -56,6 +59,13 @@ describe('create_rules', () => { expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); }); + + it('returns 200 if license is not platinum', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + + const response = await server.inject(getCreateRequest(), context); + expect(response.status).toEqual(200); + }); }); describe('creating an ML Rule', () => { @@ -63,6 +73,29 @@ describe('create_rules', () => { const response = await server.inject(createMlRuleRequest(), context); expect(response.status).toEqual(200); }); + + it('rejects the request if licensing is not platinum', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + + const response = await server.inject(createMlRuleRequest(), context); + expect(response.status).toEqual(400); + expect(response.body).toEqual({ + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }); + }); + }); + + describe('creating a Notification if throttle and actions were provided ', () => { + it('is successful', async () => { + const response = await server.inject(createRuleWithActionsRequest(), context); + expect(response.status).toEqual(200); + expect(createNotifications).toHaveBeenCalledWith( + expect.objectContaining({ + ruleAlertId: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); }); describe('unhappy paths', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts index cee9054cf922e..edf37bcb8dbe7 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -16,7 +16,13 @@ import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { transformValidate } from './validate'; import { getIndexExists } from '../../index/get_index_exists'; import { createRulesSchema } from '../schemas/create_rules_schema'; -import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; +import { + buildRouteValidation, + transformError, + buildSiemResponse, + validateLicenseForRuleType, +} from '../utils'; +import { createNotifications } from '../../notifications/create_notifications'; export const createRulesRoute = (router: IRouter): void => { router.post( @@ -65,6 +71,7 @@ export const createRulesRoute = (router: IRouter): void => { const siemResponse = buildSiemResponse(response); try { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); if (!context.alerting || !context.actions) { return siemResponse.error({ statusCode: 404 }); } @@ -131,6 +138,18 @@ export const createRulesRoute = (router: IRouter): void => { version: 1, lists, }); + + if (throttle && actions.length) { + await createNotifications({ + alertsClient, + enabled, + name, + interval, + actions, + ruleAlertId: createdRule.id, + }); + } + const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts index c56f34588cbc6..85cfeefdceead 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts @@ -16,6 +16,7 @@ import { DeleteRulesRequestParams, } from '../../rules/types'; import { deleteRules } from '../../rules/delete_rules'; +import { deleteNotifications } from '../../notifications/delete_notifications'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; type Config = RouteConfig; @@ -57,6 +58,7 @@ export const deleteRulesBulkRoute = (router: IRouter) => { ruleId, }); if (rule != null) { + await deleteNotifications({ alertsClient, ruleAlertId: rule.id }); const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts index 753b281dbc09e..6fd50abd9364a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts @@ -16,6 +16,7 @@ import { IRuleSavedAttributesSavedObjectAttributes, } from '../../rules/types'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; +import { deleteNotifications } from '../../notifications/delete_notifications'; export const deleteRulesRoute = (router: IRouter) => { router.delete( @@ -52,6 +53,7 @@ export const deleteRulesRoute = (router: IRouter) => { ruleId, }); if (rule != null) { + await deleteNotifications({ alertsClient, ruleAlertId: rule.id }); const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index f6e1cf6e2420c..aacf83b9ec58a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -9,6 +9,8 @@ import { ruleIdsToNdJsonString, rulesToNdJsonString, getSimpleRuleWithId, + getSimpleRule, + getSimpleMlRule, } from '../__mocks__/utils'; import { getImportRulesRequest, @@ -102,6 +104,30 @@ describe('import_rules_route', () => { }); describe('unhappy paths', () => { + it('returns an error object if creating an ML rule with an insufficient license', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + const rules = [getSimpleRule(), getSimpleMlRule('rule-2')]; + const hapiStreamWithMlRule = buildHapiStream(rulesToNdJsonString(rules)); + request = getImportRulesRequest(hapiStreamWithMlRule); + + const response = await server.inject(request, context); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + errors: [ + { + error: { + message: + 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }, + rule_id: 'rule-2', + }, + ], + success: false, + success_count: 1, + }); + }); + test('returns error if createPromiseFromStreams throws error', async () => { jest .spyOn(createRulesStreamFromNdJson, 'createRulesStreamFromNdJson') diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts index 4a5ea33025d49..2e6c72a87ec7f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -24,6 +24,7 @@ import { isImportRegular, transformError, buildSiemResponse, + validateLicenseForRuleType, } from '../utils'; import { createRulesStreamFromNdJson } from '../../rules/create_rules_stream_from_ndjson'; import { ImportRuleAlertRest } from '../../types'; @@ -146,6 +147,11 @@ export const importRulesRoute = (router: IRouter, config: LegacyServices['config } = parsedRule; try { + validateLicenseForRuleType({ + license: context.licensing.license, + ruleType: type, + }); + const signalsIndex = siemClient.signalsIndex; const indexExists = await getIndexExists( clusterClient.callAsCurrentUser, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts index 4c00cfa51c8ee..a1f39936dd674 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts @@ -11,6 +11,7 @@ import { getFindResultWithSingleHit, getPatchBulkRequest, getResult, + typicalMlRulePayload, } from '../__mocks__/request_responses'; import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { patchRulesBulkRoute } from './patch_rules_bulk_route'; @@ -88,6 +89,27 @@ describe('patch_rules_bulk', () => { expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); }); + + it('rejects patching of an ML rule with an insufficient license', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + const request = requestMock.create({ + method: 'patch', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + body: [typicalMlRulePayload()], + }); + + const response = await server.inject(request, context); + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + error: { + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }, + rule_id: 'rule-1', + }, + ]); + }); }); describe('request validation', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts index a80f3fee6b433..645dbdadf8cab 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -10,7 +10,12 @@ import { IRuleSavedAttributesSavedObjectAttributes, PatchRuleAlertParamsRest, } from '../../rules/types'; -import { transformBulkError, buildRouteValidation, buildSiemResponse } from '../utils'; +import { + transformBulkError, + buildRouteValidation, + buildSiemResponse, + validateLicenseForRuleType, +} from '../utils'; import { getIdBulkError } from './utils'; import { transformValidateBulkError, validate } from './validate'; import { patchRulesBulkSchema } from '../schemas/patch_rules_bulk_schema'; @@ -80,6 +85,10 @@ export const patchRulesBulkRoute = (router: IRouter) => { } = payloadRule; const idOrRuleIdOrUnknown = id ?? ruleId ?? '(unknown id)'; try { + if (type) { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + } + const rule = await patchRules({ alertsClient, actionsClient, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts index 07519733db291..1e344d8ea7e31 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts @@ -13,6 +13,7 @@ import { typicalPayload, getFindResultWithSingleHit, nonRuleFindResult, + typicalMlRulePayload, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { patchRulesRoute } from './patch_rules_route'; @@ -109,6 +110,22 @@ describe('patch_rules', () => { }) ); }); + + it('rejects patching a rule to ML if licensing is not platinum', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_URL, + body: typicalMlRulePayload(), + }); + const response = await server.inject(request, context); + + expect(response.status).toEqual(400); + expect(response.body).toEqual({ + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }); + }); }); describe('request validation', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts index c5ecb109f4595..620bcd8fc17b0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -12,7 +12,12 @@ import { IRuleSavedAttributesSavedObjectAttributes, } from '../../rules/types'; import { patchRulesSchema } from '../schemas/patch_rules_schema'; -import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; +import { + buildRouteValidation, + transformError, + buildSiemResponse, + validateLicenseForRuleType, +} from '../utils'; import { getIdError } from './utils'; import { transformValidate } from './validate'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; @@ -65,6 +70,10 @@ export const patchRulesRoute = (router: IRouter) => { const siemResponse = buildSiemResponse(response); try { + if (type) { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + } + if (!context.alerting || !context.actions) { return siemResponse.error({ statusCode: 404 }); } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts index d530866edaf0d..611b38ccbae8b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts @@ -11,6 +11,7 @@ import { getFindResultWithSingleHit, getUpdateBulkRequest, getFindResultStatus, + typicalMlRulePayload, } from '../__mocks__/request_responses'; import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { updateRulesBulkRoute } from './update_rules_bulk_route'; @@ -83,6 +84,27 @@ describe('update_rules_bulk', () => { expect(response.status).toEqual(200); expect(response.body).toEqual(expected); }); + + it('returns an error object if creating an ML rule with an insufficient license', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + const request = requestMock.create({ + method: 'put', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + body: [typicalMlRulePayload()], + }); + + const response = await server.inject(request, context); + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + error: { + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }, + rule_id: 'rule-1', + }, + ]); + }); }); describe('request validation', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index 6c3c8dffa3dfa..4abeb840c8c0a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -12,7 +12,12 @@ import { } from '../../rules/types'; import { getIdBulkError } from './utils'; import { transformValidateBulkError, validate } from './validate'; -import { buildRouteValidation, transformBulkError, buildSiemResponse } from '../utils'; +import { + buildRouteValidation, + transformBulkError, + buildSiemResponse, + validateLicenseForRuleType, +} from '../utils'; import { updateRulesBulkSchema } from '../schemas/update_rules_bulk_schema'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { updateRules } from '../../rules/update_rules'; @@ -83,6 +88,8 @@ export const updateRulesBulkRoute = (router: IRouter) => { const finalIndex = outputIndex ?? siemClient.signalsIndex; const idOrRuleIdOrUnknown = id ?? ruleId ?? '(unknown id)'; try { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + const rule = await updateRules({ alertsClient, actionsClient, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts index a15f1ca9b044e..717f2cc4a52fe 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts @@ -13,6 +13,7 @@ import { getFindResultWithSingleHit, getFindResultStatusEmpty, nonRuleFindResult, + typicalMlRulePayload, } from '../__mocks__/request_responses'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; @@ -88,6 +89,22 @@ describe('update_rules', () => { status_code: 500, }); }); + + it('rejects the request if licensing is not adequate', async () => { + (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + const request = requestMock.create({ + method: 'put', + path: DETECTION_ENGINE_RULES_URL, + body: typicalMlRulePayload(), + }); + + const response = await server.inject(request, context); + expect(response.status).toEqual(400); + expect(response.body).toEqual({ + message: 'Your license does not support machine learning. Please upgrade your license.', + status_code: 400, + }); + }); }); describe('request validation', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts index 7e56c32ade92a..f0d5f08c5f636 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -11,11 +11,17 @@ import { IRuleSavedAttributesSavedObjectAttributes, } from '../../rules/types'; import { updateRulesSchema } from '../schemas/update_rules_schema'; -import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; +import { + buildRouteValidation, + transformError, + buildSiemResponse, + validateLicenseForRuleType, +} from '../utils'; import { getIdError } from './utils'; import { transformValidate } from './validate'; import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { updateRules } from '../../rules/update_rules'; +import { updateNotifications } from '../../notifications/update_notifications'; export const updateRulesRoute = (router: IRouter) => { router.put( @@ -66,6 +72,8 @@ export const updateRulesRoute = (router: IRouter) => { const siemResponse = buildSiemResponse(response); try { + validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + if (!context.alerting || !context.actions) { return siemResponse.error({ statusCode: 404 }); } @@ -117,7 +125,17 @@ export const updateRulesRoute = (router: IRouter) => { version, lists, }); + if (rule != null) { + await updateNotifications({ + alertsClient, + actions, + enabled, + ruleAlertId: rule.id, + interval: throttle, + name, + }); + const ruleStatuses = await savedObjectsClient.find< IRuleSavedAttributesSavedObjectAttributes >({ diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index e0ecbdedaac7c..ca0d133627210 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -19,7 +19,12 @@ import { isRuleStatusFindTypes, isRuleStatusSavedObjectType, } from '../../rules/types'; -import { OutputRuleAlertRest, ImportRuleAlertRest, RuleAlertParamsRest } from '../../types'; +import { + OutputRuleAlertRest, + ImportRuleAlertRest, + RuleAlertParamsRest, + RuleType, +} from '../../types'; import { createBulkErrorObject, BulkError, @@ -29,7 +34,7 @@ import { OutputError, } from '../utils'; import { hasListsFeature } from '../../feature_flags'; -import { transformAlertToRuleAction } from '../../rules/transform_actions'; +import { transformAlertToRuleAction } from '../../../../../common/detection_engine/transform_actions'; type PromiseFromStreams = ImportRuleAlertRest | Error; @@ -295,3 +300,5 @@ export const getTupleDuplicateErrorsAndUniqueRules = ( return [Array.from(errors.values()), Array.from(rulesAcc.values())]; }; + +export const isMlRule = (ruleType: RuleType) => ruleType === 'machine_learning'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts index 2b18e1b9bf52c..b10627d151fa2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/add_prepackaged_rules_schema.test.ts @@ -5,7 +5,8 @@ */ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; -import { ThreatParams, PrepackagedRules, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, PrepackagedRules } from '../../types'; import { addPrepackagedRulesSchema } from './add_prepackaged_rules_schema'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts index d9c3055512815..08bd01ee9a1a0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/create_rules_schema.test.ts @@ -7,7 +7,8 @@ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { createRulesSchema } from './create_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertParamsRest, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, RuleAlertParamsRest } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create rules schema', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts index ffb49896ef7c7..c8e5bb981f921 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/import_rules_schema.test.ts @@ -10,7 +10,8 @@ import { importRulesQuerySchema, importRulesPayloadSchema, } from './import_rules_schema'; -import { ThreatParams, ImportRuleAlertRest, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, ImportRuleAlertRest } from '../../types'; import { ImportRulesRequestParams } from '../../rules/types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts index 42945e0970cba..45b5028f392b9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/patch_rules_schema.test.ts @@ -7,7 +7,8 @@ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { patchRulesSchema } from './patch_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('patch rules schema', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts index db3709cd6b126..6f6beea7fa5fb 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/update_rules_schema.test.ts @@ -7,7 +7,8 @@ import { AlertAction } from '../../../../../../../../plugins/alerting/common'; import { updateRulesSchema } from './update_rules_schema'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { ThreatParams, RuleAlertParamsRest, RuleAlertAction } from '../../types'; +import { RuleAlertAction } from '../../../../../common/detection_engine/types'; +import { ThreatParams, RuleAlertParamsRest } from '../../types'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; describe('create rules schema', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts index fdb1cd148c7fa..9efe4e491968b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.test.ts @@ -19,9 +19,11 @@ import { transformImportError, convertToSnakeCase, SiemResponseFactory, + validateLicenseForRuleType, } from './utils'; import { responseMock } from './__mocks__'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../feature_flags'; +import { licensingMock } from '../../../../../../../plugins/licensing/server/mocks'; describe('utils', () => { beforeAll(() => { @@ -359,4 +361,36 @@ describe('utils', () => { ); }); }); + + describe('validateLicenseForRuleType', () => { + let licenseMock: ReturnType; + + beforeEach(() => { + licenseMock = licensingMock.createLicenseMock(); + }); + + it('throws a BadRequestError if operating on an ML Rule with an insufficient license', () => { + licenseMock.hasAtLeast.mockReturnValue(false); + + expect(() => + validateLicenseForRuleType({ license: licenseMock, ruleType: 'machine_learning' }) + ).toThrowError(BadRequestError); + }); + + it('does not throw if operating on an ML Rule with a sufficient license', () => { + licenseMock.hasAtLeast.mockReturnValue(true); + + expect(() => + validateLicenseForRuleType({ license: licenseMock, ruleType: 'machine_learning' }) + ).not.toThrowError(BadRequestError); + }); + + it('does not throw if operating on a query rule', () => { + licenseMock.hasAtLeast.mockReturnValue(false); + + expect(() => + validateLicenseForRuleType({ license: licenseMock, ruleType: 'query' }) + ).not.toThrowError(BadRequestError); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts index 79c2f47658f7e..90c7d4a07ddf8 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/utils.ts @@ -7,13 +7,18 @@ import Boom from 'boom'; import Joi from 'joi'; import { has, snakeCase } from 'lodash/fp'; +import { i18n } from '@kbn/i18n'; import { RouteValidationFunction, KibanaResponseFactory, CustomHttpResponseOptions, } from '../../../../../../../../src/core/server'; +import { ILicense } from '../../../../../../../plugins/licensing/server'; +import { MINIMUM_ML_LICENSE } from '../../../../common/constants'; import { BadRequestError } from '../errors/bad_request_error'; +import { RuleType } from '../types'; +import { isMlRule } from './rules/utils'; export interface OutputError { message: string; @@ -289,3 +294,28 @@ export const convertToSnakeCase = >( return { ...acc, [newKey]: obj[item] }; }, {}); }; + +/** + * Checks the current Kibana License against the rule under operation. + * + * @param license ILicense representing the user license + * @param ruleType the type of the current rule + * + * @throws BadRequestError if rule and license are incompatible + */ +export const validateLicenseForRuleType = ({ + license, + ruleType, +}: { + license: ILicense; + ruleType: RuleType; +}) => { + if (isMlRule(ruleType) && !license.hasAtLeast(MINIMUM_ML_LICENSE)) { + const message = i18n.translate('xpack.siem.licensing.unsupportedMachineLearningMessage', { + defaultMessage: + 'Your license does not support machine learning. Please upgrade your license.', + }); + + throw new BadRequestError(message); + } +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts index db70b90d5a17c..a45b28ba3e105 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/create_rules.ts @@ -6,12 +6,12 @@ import { Alert } from '../../../../../../../plugins/alerting/common'; import { APP_ID, SIGNALS_ID } from '../../../../common/constants'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { CreateRuleParams } from './types'; import { addTags } from './add_tags'; import { hasListsFeature } from '../feature_flags'; -import { transformRuleToAlertAction } from './transform_actions'; -export const createRules = ({ +export const createRules = async ({ alertsClient, actionsClient, // TODO: Use this actionsClient once we have actions such as email, etc... actions, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts new file mode 100644 index 0000000000000..38fc1dc5d1930 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/delete_rules.test.ts @@ -0,0 +1,150 @@ +/* + * 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 { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; +import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; +import { deleteRules } from './delete_rules'; +import { readRules } from './read_rules'; +jest.mock('./read_rules'); + +describe('deleteRules', () => { + let actionsClient: ReturnType; + let alertsClient: ReturnType; + const notificationId = 'notification-52128c15-0d1b-4716-a4c5-46997ac7f3bd'; + const ruleId = 'rule-04128c15-0d1b-4716-a4c5-46997ac7f3bd'; + + beforeEach(() => { + actionsClient = actionsClientMock.create(); + alertsClient = alertsClientMock.create(); + }); + + it('should return null if notification was not found', async () => { + (readRules as jest.Mock).mockResolvedValue(null); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId, + }); + + expect(result).toBe(null); + }); + + it('should call alertsClient.delete if notification was found', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: notificationId, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: notificationId }); + }); + + it('should call alertsClient.delete if ruleId was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId: null, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual({ id: null }); + }); + + it('should return null if alertsClient.delete rejects with 404 if ruleId was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + alertsClient.delete.mockRejectedValue({ + output: { + statusCode: 404, + }, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId: null, + }); + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(result).toEqual(null); + }); + + it('should return error object if alertsClient.delete rejects with status different than 404 and if ruleId was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + const errorObject = { + output: { + statusCode: 500, + }, + }; + + alertsClient.delete.mockRejectedValue(errorObject); + + let errorResult; + try { + await deleteRules({ + alertsClient, + actionsClient, + id: notificationId, + ruleId: null, + }); + } catch (error) { + errorResult = error; + } + + expect(alertsClient.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: notificationId, + }) + ); + expect(errorResult).toEqual(errorObject); + }); + + it('should return null if ruleId and id was null', async () => { + (readRules as jest.Mock).mockResolvedValue({ + id: null, + }); + + const result = await deleteRules({ + alertsClient, + actionsClient, + id: undefined, + ruleId: null, + }); + + expect(result).toEqual(null); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts index b424d2912ebc8..cd18bee6f606f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts @@ -7,7 +7,7 @@ import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; -import { getMlResult } from '../routes/__mocks__/request_responses'; +import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; import { patchRules } from './patch_rules'; describe('patchRules', () => { @@ -21,6 +21,59 @@ describe('patchRules', () => { savedObjectsClient = savedObjectsClientMock.create(); }); + it('should call alertsClient.disable is the rule was enabled and enabled is false', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue(getResult()); + + await patchRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: false, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.disable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + + it('should call alertsClient.enable is the rule was disabled and enabled is true', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue({ + ...getResult(), + enabled: false, + }); + + await patchRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: true, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.enable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + it('calls the alertsClient with ML params', async () => { alertsClient.get.mockResolvedValue(getMlResult()); const params = { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts index 5b6fd08a9ea89..5394af526c917 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts @@ -6,12 +6,12 @@ import { defaults } from 'lodash/fp'; import { PartialAlert } from '../../../../../../../plugins/alerting/server'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { readRules } from './read_rules'; import { PatchRuleParams, IRuleSavedAttributesSavedObjectAttributes } from './types'; import { addTags } from './add_tags'; import { ruleStatusSavedObjectType } from './saved_object_mappings'; import { calculateVersion, calculateName, calculateInterval } from './utils'; -import { transformRuleToAlertAction } from './transform_actions'; export const patchRules = async ({ alertsClient, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts index 862ea9d2dcbe5..38a883329318b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/read_rules.test.ts @@ -8,7 +8,7 @@ import { readRules } from './read_rules'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; import { getResult, getFindResultWithSingleHit } from '../routes/__mocks__/request_responses'; -class TestError extends Error { +export class TestError extends Error { constructor() { // Pass remaining arguments (including vendor specific ones) to parent constructor super(); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/saved_object_mappings.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/saved_object_mappings.ts index fd3d35e9f6785..1d91def5fa6cc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/saved_object_mappings.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/saved_object_mappings.ts @@ -4,14 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchMappingOf } from '../../../utils/typed_elasticsearch_mappings'; -import { IRuleStatusAttributes } from './types'; - export const ruleStatusSavedObjectType = 'siem-detection-engine-rule-status'; -export const ruleStatusSavedObjectMappings: { - [ruleStatusSavedObjectType]: ElasticsearchMappingOf; -} = { +export const ruleStatusSavedObjectMappings = { [ruleStatusSavedObjectType]: { properties: { alertId: { @@ -35,6 +30,18 @@ export const ruleStatusSavedObjectMappings: { lastSuccessMessage: { type: 'text', }, + lastLookBackDate: { + type: 'date', + }, + gap: { + type: 'text', + }, + bulkCreateTimeDurations: { + type: 'float', + }, + searchAfterTimeDurations: { + type: 'float', + }, }, }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts index 1efa46c6b8b57..ada11174c5340 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts @@ -60,6 +60,10 @@ export interface IRuleStatusAttributes extends Record { lastSuccessAt: string | null | undefined; lastSuccessMessage: string | null | undefined; status: RuleStatusString | null | undefined; + lastLookBackDate: string | null | undefined; + gap: string | null | undefined; + bulkCreateTimeDurations: string[] | null | undefined; + searchAfterTimeDurations: string[] | null | undefined; } export interface RuleStatusResponse { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts index 967a32df20c3b..af00816abfc3d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.test.ts @@ -7,7 +7,7 @@ import { savedObjectsClientMock } from '../../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../../../plugins/alerting/server/mocks'; import { actionsClientMock } from '../../../../../../../plugins/actions/server/mocks'; -import { getMlResult } from '../routes/__mocks__/request_responses'; +import { getResult, getMlResult } from '../routes/__mocks__/request_responses'; import { updateRules } from './update_rules'; describe('updateRules', () => { @@ -21,6 +21,59 @@ describe('updateRules', () => { savedObjectsClient = savedObjectsClientMock.create(); }); + it('should call alertsClient.disable is the rule was enabled and enabled is false', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue(getResult()); + + await updateRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: false, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.disable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + + it('should call alertsClient.enable is the rule was disabled and enabled is true', async () => { + const rule = getResult(); + alertsClient.get.mockResolvedValue({ + ...getResult(), + enabled: false, + }); + + await updateRules({ + alertsClient, + actionsClient, + actions: [], + savedObjectsClient, + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + ...rule.params, + enabled: true, + throttle: null, + interval: '', + name: '', + tags: [], + }); + + expect(alertsClient.enable).toHaveBeenCalledWith( + expect.objectContaining({ + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + }) + ); + }); + it('calls the alertsClient with ML params', async () => { alertsClient.get.mockResolvedValue(getMlResult()); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts index a80f986482010..72cbc959c0105 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/update_rules.ts @@ -5,13 +5,13 @@ */ import { PartialAlert } from '../../../../../../../plugins/alerting/server'; +import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { readRules } from './read_rules'; import { IRuleSavedAttributesSavedObjectAttributes, UpdateRuleParams } from './types'; import { addTags } from './add_tags'; import { ruleStatusSavedObjectType } from './saved_object_mappings'; import { calculateVersion } from './utils'; import { hasListsFeature } from '../feature_flags'; -import { transformRuleToAlertAction } from './transform_actions'; export const updateRules = async ({ alertsClient, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts index adbd5f81d372a..f485769dffabc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_bulk_body.ts @@ -8,7 +8,8 @@ import { SignalSourceHit, SignalHit } from './types'; import { buildRule } from './build_rule'; import { buildSignal } from './build_signal'; import { buildEventTypeSignal } from './build_event_type_signal'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; interface BuildBulkBodyParams { doc: SignalSourceHit; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts index e94ca18b186e4..1de80ca0b7eaf 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/build_rule.ts @@ -5,7 +5,8 @@ */ import { pickBy } from 'lodash/fp'; -import { RuleTypeParams, OutputRuleAlertRest, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams, OutputRuleAlertRest } from '../types'; interface BuildRuleParams { ruleParams: RuleTypeParams; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts index 95adb90172404..c1b61ef24462d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/bulk_create_ml_signals.ts @@ -9,8 +9,9 @@ import { SearchResponse } from 'elasticsearch'; import { Logger } from '../../../../../../../../src/core/server'; import { AlertServices } from '../../../../../../../plugins/alerting/server'; -import { RuleTypeParams, RuleAlertAction } from '../types'; -import { singleBulkCreate } from './single_bulk_create'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; +import { singleBulkCreate, SingleBulkCreateResponse } from './single_bulk_create'; import { AnomalyResults, Anomaly } from '../../machine_learning'; interface BulkCreateMlSignalsParams { @@ -74,7 +75,9 @@ const transformAnomalyResultsToEcs = (results: AnomalyResults): SearchResponse { +export const bulkCreateMlSignals = async ( + params: BulkCreateMlSignalsParams +): Promise => { const anomalyResults = params.someResult; const ecsResults = transformAnomalyResultsToEcs(anomalyResults); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_current_status_saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_current_status_saved_object.ts index e5057b6b68997..1fee8bcd6c2f0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_current_status_saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_current_status_saved_object.ts @@ -36,6 +36,10 @@ export const getCurrentStatusSavedObject = async ({ lastSuccessAt: null, lastFailureMessage: null, lastSuccessMessage: null, + gap: null, + bulkCreateTimeDurations: [], + searchAfterTimeDurations: [], + lastLookBackDate: null, }); return currentStatusSavedObject; } else { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts index b49f43ce9e7ac..86d1278031695 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/get_filter.test.ts @@ -510,6 +510,20 @@ describe('get_filter', () => { ).rejects.toThrow('savedId parameter should be defined'); }); + test('throws on machine learning query', async () => { + await expect( + getFilter({ + type: 'machine_learning', + filters: undefined, + language: undefined, + query: undefined, + savedId: 'some-id', + services: servicesMock, + index: undefined, + }) + ).rejects.toThrow('Unsupported Rule of type "machine_learning" supplied to getFilter'); + }); + test('it works with references and does not add indexes', () => { const esQuery = getQueryFilter( '(event.module:suricata and event.kind:alert) and suricata.eve.alert.signature_id: (2610182 or 2610183 or 2610184 or 2610185 or 2610186 or 2610187)', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 315a5dd88d94e..b12c21b7a5b56 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -34,7 +34,7 @@ describe('searchAfterAndBulkCreate', () => { test('if successful with empty search results', async () => { const sampleParams = sampleRuleAlertParams(); - const result = await searchAfterAndBulkCreate({ + const { success } = await searchAfterAndBulkCreate({ someResult: sampleEmptyDocSearchResults(), ruleParams: sampleParams, services: mockService, @@ -56,7 +56,7 @@ describe('searchAfterAndBulkCreate', () => { throttle: null, }); expect(mockService.callCluster).toHaveBeenCalledTimes(0); - expect(result).toEqual(true); + expect(success).toEqual(true); }); test('if successful iteration of while loop with maxDocs', async () => { @@ -92,7 +92,7 @@ describe('searchAfterAndBulkCreate', () => { }, ], }); - const result = await searchAfterAndBulkCreate({ + const { success } = await searchAfterAndBulkCreate({ someResult: repeatedSearchResultsWithSortId(3, 1, someGuids.slice(6, 9)), ruleParams: sampleParams, services: mockService, @@ -114,14 +114,14 @@ describe('searchAfterAndBulkCreate', () => { throttle: null, }); expect(mockService.callCluster).toHaveBeenCalledTimes(5); - expect(result).toEqual(true); + expect(success).toEqual(true); }); test('if unsuccessful first bulk create', async () => { const someGuids = Array.from({ length: 4 }).map(x => uuid.v4()); const sampleParams = sampleRuleAlertParams(10); mockService.callCluster.mockReturnValue(sampleBulkCreateDuplicateResult); - const result = await searchAfterAndBulkCreate({ + const { success } = await searchAfterAndBulkCreate({ someResult: repeatedSearchResultsWithSortId(4, 1, someGuids), ruleParams: sampleParams, services: mockService, @@ -143,7 +143,7 @@ describe('searchAfterAndBulkCreate', () => { throttle: null, }); expect(mockLogger.error).toHaveBeenCalled(); - expect(result).toEqual(false); + expect(success).toEqual(false); }); test('if unsuccessful iteration of searchAfterAndBulkCreate due to empty sort ids', async () => { @@ -157,7 +157,7 @@ describe('searchAfterAndBulkCreate', () => { }, ], }); - const result = await searchAfterAndBulkCreate({ + const { success } = await searchAfterAndBulkCreate({ someResult: sampleDocSearchResultsNoSortId(), ruleParams: sampleParams, services: mockService, @@ -179,7 +179,7 @@ describe('searchAfterAndBulkCreate', () => { throttle: null, }); expect(mockLogger.error).toHaveBeenCalled(); - expect(result).toEqual(false); + expect(success).toEqual(false); }); test('if unsuccessful iteration of searchAfterAndBulkCreate due to empty sort ids and 0 total hits', async () => { @@ -193,7 +193,7 @@ describe('searchAfterAndBulkCreate', () => { }, ], }); - const result = await searchAfterAndBulkCreate({ + const { success } = await searchAfterAndBulkCreate({ someResult: sampleDocSearchResultsNoSortIdNoHits(), ruleParams: sampleParams, services: mockService, @@ -214,7 +214,7 @@ describe('searchAfterAndBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: null, }); - expect(result).toEqual(true); + expect(success).toEqual(true); }); test('if successful iteration of while loop with maxDocs and search after returns results with no sort ids', async () => { @@ -231,7 +231,7 @@ describe('searchAfterAndBulkCreate', () => { ], }) .mockReturnValueOnce(sampleDocSearchResultsNoSortId()); - const result = await searchAfterAndBulkCreate({ + const { success } = await searchAfterAndBulkCreate({ someResult: repeatedSearchResultsWithSortId(4, 1, someGuids), ruleParams: sampleParams, services: mockService, @@ -252,7 +252,7 @@ describe('searchAfterAndBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: null, }); - expect(result).toEqual(true); + expect(success).toEqual(true); }); test('if successful iteration of while loop with maxDocs and search after returns empty results with no sort ids', async () => { @@ -269,7 +269,7 @@ describe('searchAfterAndBulkCreate', () => { ], }) .mockReturnValueOnce(sampleEmptyDocSearchResults()); - const result = await searchAfterAndBulkCreate({ + const { success } = await searchAfterAndBulkCreate({ someResult: repeatedSearchResultsWithSortId(4, 1, someGuids), ruleParams: sampleParams, services: mockService, @@ -290,7 +290,7 @@ describe('searchAfterAndBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: null, }); - expect(result).toEqual(true); + expect(success).toEqual(true); }); test('if returns false when singleSearchAfter throws an exception', async () => { @@ -309,7 +309,7 @@ describe('searchAfterAndBulkCreate', () => { .mockImplementation(() => { throw Error('Fake Error'); }); - const result = await searchAfterAndBulkCreate({ + const { success } = await searchAfterAndBulkCreate({ someResult: repeatedSearchResultsWithSortId(4, 1, someGuids), ruleParams: sampleParams, services: mockService, @@ -330,6 +330,6 @@ describe('searchAfterAndBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: null, }); - expect(result).toEqual(false); + expect(success).toEqual(false); }); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts index a12778d5b8f16..ff263333fb798 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -5,7 +5,8 @@ */ import { AlertServices } from '../../../../../../../plugins/alerting/server'; -import { RuleTypeParams, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; import { Logger } from '../../../../../../../../src/core/server'; import { singleSearchAfter } from './single_search_after'; import { singleBulkCreate } from './single_bulk_create'; @@ -33,6 +34,13 @@ interface SearchAfterAndBulkCreateParams { throttle: string | null; } +export interface SearchAfterAndBulkCreateReturnType { + success: boolean; + searchAfterTimes: string[]; + bulkCreateTimes: string[]; + lastLookBackDate: Date | null | undefined; +} + // search_after through documents and re-index using bulk endpoint. export const searchAfterAndBulkCreate = async ({ someResult, @@ -54,13 +62,20 @@ export const searchAfterAndBulkCreate = async ({ pageSize, tags, throttle, -}: SearchAfterAndBulkCreateParams): Promise => { +}: SearchAfterAndBulkCreateParams): Promise => { + const toReturn: SearchAfterAndBulkCreateReturnType = { + success: false, + searchAfterTimes: [], + bulkCreateTimes: [], + lastLookBackDate: null, + }; if (someResult.hits.hits.length === 0) { - return true; + toReturn.success = true; + return toReturn; } logger.debug('[+] starting bulk insertion'); - await singleBulkCreate({ + const { bulkCreateDuration } = await singleBulkCreate({ someResult, ruleParams, services, @@ -78,6 +93,13 @@ export const searchAfterAndBulkCreate = async ({ tags, throttle, }); + toReturn.lastLookBackDate = + someResult.hits.hits.length > 0 + ? new Date(someResult.hits.hits[someResult.hits.hits.length - 1]?._source['@timestamp']) + : null; + 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 @@ -93,9 +115,11 @@ export const searchAfterAndBulkCreate = async ({ let sortIds = someResult.hits.hits[0].sort; if (sortIds == null && totalHits > 0) { logger.error('sortIds was empty on first search but expected more'); - return false; + toReturn.success = false; + return toReturn; } else if (sortIds == null && totalHits === 0) { - return true; + toReturn.success = true; + return toReturn; } let sortId; if (sortIds != null) { @@ -104,7 +128,10 @@ export const searchAfterAndBulkCreate = async ({ while (hitsSize < maxTotalHitsSize && hitsSize !== 0) { try { logger.debug(`sortIds: ${sortIds}`); - const searchAfterResult: SignalSearchResponse = await singleSearchAfter({ + const { + searchResult, + searchDuration, + }: { searchResult: SignalSearchResponse; searchDuration: string } = await singleSearchAfter({ searchAfterSortId: sortId, index: inputIndexPattern, from: ruleParams.from, @@ -114,20 +141,23 @@ export const searchAfterAndBulkCreate = async ({ filter, pageSize, // maximum number of docs to receive per search result. }); - if (searchAfterResult.hits.hits.length === 0) { - return true; + toReturn.searchAfterTimes.push(searchDuration); + if (searchResult.hits.hits.length === 0) { + toReturn.success = true; + return toReturn; } - hitsSize += searchAfterResult.hits.hits.length; + hitsSize += searchResult.hits.hits.length; logger.debug(`size adjusted: ${hitsSize}`); - sortIds = searchAfterResult.hits.hits[0].sort; + sortIds = searchResult.hits.hits[0].sort; if (sortIds == null) { logger.debug('sortIds was empty on search'); - return true; // no more search results + toReturn.success = true; + return toReturn; // no more search results } sortId = sortIds[0]; logger.debug('next bulk index'); - await singleBulkCreate({ - someResult: searchAfterResult, + const { bulkCreateDuration: bulkDuration } = await singleBulkCreate({ + someResult: searchResult, ruleParams, services, logger, @@ -145,11 +175,16 @@ export const searchAfterAndBulkCreate = async ({ throttle, }); logger.debug('finished next bulk index'); + if (bulkDuration) { + toReturn.bulkCreateTimes.push(bulkDuration); + } } catch (exc) { logger.error(`[-] search_after and bulk threw an error ${exc}`); - return false; + toReturn.success = false; + return toReturn; } } logger.debug(`[+] completed bulk index of ${maxTotalHitsSize}`); - return true; + toReturn.success = true; + return toReturn; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 89dcd3274ebed..ab9def14bef65 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -4,15 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ +import { performance } from 'perf_hooks'; import { Logger } from 'src/core/server'; -import { SIGNALS_ID, DEFAULT_SEARCH_AFTER_PAGE_SIZE } from '../../../../common/constants'; +import { + SIGNALS_ID, + DEFAULT_SEARCH_AFTER_PAGE_SIZE, + NOTIFICATION_THROTTLE_RULE, +} from '../../../../common/constants'; import { buildEventsSearchQuery } from './build_events_query'; import { getInputIndex } from './get_input_output_index'; -import { searchAfterAndBulkCreate } from './search_after_bulk_create'; +import { + searchAfterAndBulkCreate, + SearchAfterAndBulkCreateReturnType, +} from './search_after_bulk_create'; import { getFilter } from './get_filter'; -import { SignalRuleAlertTypeDefinition, AlertAttributes } from './types'; -import { getGapBetweenRuns } from './utils'; +import { SignalRuleAlertTypeDefinition, RuleAlertAttributes } from './types'; +import { getGapBetweenRuns, makeFloatString } from './utils'; import { writeSignalRuleExceptionToSavedObject } from './write_signal_rule_exception_to_saved_object'; import { signalParamsSchema } from './signal_params_schema'; import { siemRuleActionGroups } from './siem_rule_action_groups'; @@ -22,6 +30,8 @@ import { getCurrentStatusSavedObject } from './get_current_status_saved_object'; import { writeCurrentStatusSucceeded } from './write_current_status_succeeded'; import { findMlSignals } from './find_ml_signals'; import { bulkCreateMlSignals } from './bulk_create_ml_signals'; +import { getSignalsCount } from '../notifications/get_signals_count'; +import { scheduleNotificationActions } from '../notifications/schedule_notification_actions'; export const signalRulesAlertType = ({ logger, @@ -46,6 +56,7 @@ export const signalRulesAlertType = ({ index, filters, language, + meta, machineLearningJobId, outputIndex, savedId, @@ -53,7 +64,10 @@ export const signalRulesAlertType = ({ to, type, } = params; - const savedObject = await services.savedObjectsClient.get('alert', alertId); + const savedObject = await services.savedObjectsClient.get( + 'alert', + alertId + ); const ruleStatusSavedObjects = await getRuleStatusSavedObjects({ alertId, @@ -76,12 +90,12 @@ export const signalRulesAlertType = ({ enabled, schedule: { interval }, throttle, + params: ruleParams, } = savedObject.attributes; const updatedAt = savedObject.updated_at ?? ''; const gap = getGapBetweenRuns({ previousStartedAt, interval, from, to }); - await writeGapErrorToSavedObject({ alertId, logger, @@ -94,7 +108,12 @@ export const signalRulesAlertType = ({ }); const searchAfterSize = Math.min(params.maxSignals, DEFAULT_SEARCH_AFTER_PAGE_SIZE); - let creationSucceeded = false; + let creationSucceeded: SearchAfterAndBulkCreateReturnType = { + success: false, + bulkCreateTimes: [], + searchAfterTimes: [], + lastLookBackDate: null, + }; try { if (type === 'machine_learning') { @@ -119,7 +138,7 @@ export const signalRulesAlertType = ({ ); } - creationSucceeded = await bulkCreateMlSignals({ + const { success, bulkCreateDuration } = await bulkCreateMlSignals({ actions, throttle, someResult: anomalyResults, @@ -137,6 +156,10 @@ export const signalRulesAlertType = ({ enabled, tags, }); + creationSucceeded.success = success; + if (bulkCreateDuration) { + creationSucceeded.bulkCreateTimes.push(bulkCreateDuration); + } } else { const inputIndex = await getInputIndex(services, version, index); const esFilter = await getFilter({ @@ -164,7 +187,10 @@ export const signalRulesAlertType = ({ logger.debug( `[+] Initial search call of signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` ); + const start = performance.now(); const noReIndexResult = await services.callCluster('search', noReIndex); + const end = performance.now(); + if (noReIndexResult.hits.total.value !== 0) { logger.info( `Found ${ @@ -196,15 +222,50 @@ export const signalRulesAlertType = ({ tags, throttle, }); + creationSucceeded.searchAfterTimes.push(makeFloatString(end - start)); } - if (creationSucceeded) { + if (creationSucceeded.success) { + if (meta?.throttle === NOTIFICATION_THROTTLE_RULE && actions.length) { + const notificationRuleParams = { + ...ruleParams, + name, + id: savedObject.id, + }; + const { signalsCount, resultsLink } = await getSignalsCount({ + from: `now-${interval}`, + to: 'now', + index: ruleParams.outputIndex, + ruleId: ruleParams.ruleId!, + kibanaSiemAppUrl: meta.kibanaSiemAppUrl as string, + ruleAlertId: savedObject.id, + callCluster: services.callCluster, + }); + + logger.info( + `Found ${signalsCount} signals using signal rule name: "${notificationRuleParams.name}", id: "${notificationRuleParams.ruleId}", rule_id: "${notificationRuleParams.ruleId}" in "${notificationRuleParams.outputIndex}" index` + ); + + if (signalsCount) { + const alertInstance = services.alertInstanceFactory(alertId); + scheduleNotificationActions({ + alertInstance, + signalsCount, + resultsLink, + ruleParams: notificationRuleParams, + }); + } + } + logger.debug( - `Finished signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}", output_index: "${outputIndex}"` + `Finished signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` ); await writeCurrentStatusSucceeded({ services, currentStatusSavedObject, + bulkCreateTimes: creationSucceeded.bulkCreateTimes, + searchAfterTimes: creationSucceeded.searchAfterTimes, + lastLookBackDate: creationSucceeded.lastLookBackDate?.toISOString() ?? null, }); } else { await writeSignalRuleExceptionToSavedObject({ @@ -212,22 +273,28 @@ export const signalRulesAlertType = ({ alertId, currentStatusSavedObject, logger, - message: `Bulk Indexing signals failed. Check logs for further details Rule name: "${name}" id: "${alertId}" rule_id: "${ruleId}" output_index: "${outputIndex}"`, + message: `Bulk Indexing signals failed. Check logs for further details \nRule name: "${name}"\nid: "${alertId}"\nrule_id: "${ruleId}"\n`, services, ruleStatusSavedObjects, ruleId: ruleId ?? '(unknown rule id)', + bulkCreateTimes: creationSucceeded.bulkCreateTimes, + searchAfterTimes: creationSucceeded.searchAfterTimes, + lastLookBackDate: creationSucceeded.lastLookBackDate?.toISOString() ?? null, }); } - } catch (error) { + } catch (err) { await writeSignalRuleExceptionToSavedObject({ name, alertId, currentStatusSavedObject, logger, - message: error?.message ?? '(no error message given)', + message: `Bulk Indexing signals failed. Check logs for further details \nRule name: "${name}"\nid: "${alertId}"\nrule_id: "${ruleId}"\n`, services, ruleStatusSavedObjects, ruleId: ruleId ?? '(unknown rule id)', + bulkCreateTimes: creationSucceeded.bulkCreateTimes, + searchAfterTimes: creationSucceeded.searchAfterTimes, + lastLookBackDate: creationSucceeded.lastLookBackDate?.toISOString() ?? null, }); } }, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts index afabd4c44de7d..93f9c24a057f2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.test.ts @@ -144,7 +144,7 @@ describe('singleBulkCreate', () => { }, ], }); - const successfulsingleBulkCreate = await singleBulkCreate({ + const { success } = await singleBulkCreate({ someResult: sampleDocSearchResultsNoSortId(), ruleParams: sampleParams, services: mockService, @@ -162,7 +162,7 @@ describe('singleBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: null, }); - expect(successfulsingleBulkCreate).toEqual(true); + expect(success).toEqual(true); }); test('create successful bulk create with docs with no versioning', async () => { @@ -176,7 +176,7 @@ describe('singleBulkCreate', () => { }, ], }); - const successfulsingleBulkCreate = await singleBulkCreate({ + const { success } = await singleBulkCreate({ someResult: sampleDocSearchResultsNoSortIdNoVersion(), ruleParams: sampleParams, services: mockService, @@ -194,13 +194,13 @@ describe('singleBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: null, }); - expect(successfulsingleBulkCreate).toEqual(true); + expect(success).toEqual(true); }); test('create unsuccessful bulk create due to empty search results', async () => { const sampleParams = sampleRuleAlertParams(); mockService.callCluster.mockReturnValue(false); - const successfulsingleBulkCreate = await singleBulkCreate({ + const { success } = await singleBulkCreate({ someResult: sampleEmptyDocSearchResults(), ruleParams: sampleParams, services: mockService, @@ -218,14 +218,14 @@ describe('singleBulkCreate', () => { tags: ['some fake tag 1', 'some fake tag 2'], throttle: null, }); - expect(successfulsingleBulkCreate).toEqual(true); + expect(success).toEqual(true); }); test('create successful bulk create when bulk create has duplicate errors', async () => { const sampleParams = sampleRuleAlertParams(); const sampleSearchResult = sampleDocSearchResultsNoSortId; mockService.callCluster.mockReturnValue(sampleBulkCreateDuplicateResult); - const successfulsingleBulkCreate = await singleBulkCreate({ + const { success } = await singleBulkCreate({ someResult: sampleSearchResult(), ruleParams: sampleParams, services: mockService, @@ -245,14 +245,14 @@ describe('singleBulkCreate', () => { }); expect(mockLogger.error).not.toHaveBeenCalled(); - expect(successfulsingleBulkCreate).toEqual(true); + expect(success).toEqual(true); }); test('create successful bulk create when bulk create has multiple error statuses', async () => { const sampleParams = sampleRuleAlertParams(); const sampleSearchResult = sampleDocSearchResultsNoSortId; mockService.callCluster.mockReturnValue(sampleBulkCreateErrorResult); - const successfulsingleBulkCreate = await singleBulkCreate({ + const { success } = await singleBulkCreate({ someResult: sampleSearchResult(), ruleParams: sampleParams, services: mockService, @@ -272,7 +272,7 @@ describe('singleBulkCreate', () => { }); expect(mockLogger.error).toHaveBeenCalled(); - expect(successfulsingleBulkCreate).toEqual(true); + expect(success).toEqual(true); }); test('filter duplicate rules will return an empty array given an empty array', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts index 333a938e09d45..0192ff76efa54 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_bulk_create.ts @@ -8,8 +8,9 @@ import { countBy, isEmpty } from 'lodash'; import { performance } from 'perf_hooks'; import { AlertServices } from '../../../../../../../plugins/alerting/server'; import { SignalSearchResponse, BulkResponse } from './types'; -import { RuleTypeParams, RuleAlertAction } from '../types'; -import { generateId } from './utils'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams } from '../types'; +import { generateId, makeFloatString } from './utils'; import { buildBulkBody } from './build_bulk_body'; import { Logger } from '../../../../../../../../src/core/server'; @@ -54,6 +55,11 @@ export const filterDuplicateRules = ( }); }; +export interface SingleBulkCreateResponse { + success: boolean; + bulkCreateDuration?: string; +} + // Bulk Index documents. export const singleBulkCreate = async ({ someResult, @@ -72,11 +78,10 @@ export const singleBulkCreate = async ({ enabled, tags, throttle, -}: SingleBulkCreateParams): Promise => { +}: SingleBulkCreateParams): Promise => { someResult.hits.hits = filterDuplicateRules(id, someResult); - if (someResult.hits.hits.length === 0) { - return true; + return { success: true }; } // index documents after creating an ID based on the // source documents' originating index, and the original @@ -122,7 +127,7 @@ export const singleBulkCreate = async ({ body: bulkBody, }); const end = performance.now(); - logger.debug(`individual bulk process time took: ${Number(end - start).toFixed(2)} milliseconds`); + logger.debug(`individual bulk process time took: ${makeFloatString(end - start)} milliseconds`); logger.debug(`took property says bulk took: ${response.took} milliseconds`); if (response.errors) { @@ -140,5 +145,5 @@ export const singleBulkCreate = async ({ ); } } - return true; + return { success: true, bulkCreateDuration: makeFloatString(end - start) }; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts index 1685c6518def3..9b726c38d3d96 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.test.ts @@ -42,7 +42,7 @@ describe('singleSearchAfter', () => { test('if singleSearchAfter works with a given sort id', async () => { const searchAfterSortId = '1234567891111'; mockService.callCluster.mockReturnValue(sampleDocSearchResultsWithSortId); - const searchAfterResult = await singleSearchAfter({ + const { searchResult } = await singleSearchAfter({ searchAfterSortId, index: [], from: 'now-360s', @@ -52,7 +52,7 @@ describe('singleSearchAfter', () => { pageSize: 1, filter: undefined, }); - expect(searchAfterResult).toEqual(sampleDocSearchResultsWithSortId); + expect(searchResult).toEqual(sampleDocSearchResultsWithSortId); }); test('if singleSearchAfter throws error', async () => { const searchAfterSortId = '1234567891111'; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.ts index bb12b5a802f8f..6fc8fe4bd24d9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/single_search_after.ts @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { performance } from 'perf_hooks'; import { AlertServices } from '../../../../../../../plugins/alerting/server'; import { Logger } from '../../../../../../../../src/core/server'; import { SignalSearchResponse } from './types'; import { buildEventsSearchQuery } from './build_events_query'; +import { makeFloatString } from './utils'; interface SingleSearchAfterParams { searchAfterSortId: string | undefined; @@ -30,7 +32,10 @@ export const singleSearchAfter = async ({ filter, logger, pageSize, -}: SingleSearchAfterParams): Promise => { +}: SingleSearchAfterParams): Promise<{ + searchResult: SignalSearchResponse; + searchDuration: string; +}> => { if (searchAfterSortId == null) { throw Error('Attempted to search after with empty sort id'); } @@ -43,11 +48,13 @@ export const singleSearchAfter = async ({ size: pageSize, searchAfterSortId, }); + const start = performance.now(); const nextSearchAfterResult: SignalSearchResponse = await services.callCluster( 'search', searchAfterQuery ); - return nextSearchAfterResult; + const end = performance.now(); + return { searchResult: nextSearchAfterResult, searchDuration: makeFloatString(end - start) }; } catch (exc) { logger.error(`[-] nextSearchAfter threw an error ${exc}`); throw exc; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts index 06acff825f68e..93c48ed38c7c4 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/types.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RuleAlertParams, OutputRuleAlertRest, RuleAlertAction } from '../types'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleAlertParams, OutputRuleAlertRest } from '../types'; import { SearchResponse } from '../../types'; import { AlertType, @@ -159,3 +160,7 @@ export interface AlertAttributes { }; throttle: string | null; } + +export interface RuleAlertAttributes extends AlertAttributes { + params: RuleAlertParams; +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts index 8e7fb9c38d658..49af310db559f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts @@ -89,3 +89,5 @@ export const getGapBetweenRuns = ({ const drift = diff.subtract(intervalDuration); return drift.subtract(driftTolerance); }; + +export const makeFloatString = (num: number): string => Number(num).toFixed(2); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_current_status_succeeded.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_current_status_succeeded.ts index 6b06235b29063..50136790c3479 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_current_status_succeeded.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_current_status_succeeded.ts @@ -13,17 +13,32 @@ import { IRuleSavedAttributesSavedObjectAttributes } from '../rules/types'; interface GetRuleStatusSavedObject { services: AlertServices; currentStatusSavedObject: SavedObject; + lastLookBackDate: string | null | undefined; + bulkCreateTimes: string[] | null | undefined; + searchAfterTimes: string[] | null | undefined; } export const writeCurrentStatusSucceeded = async ({ services, currentStatusSavedObject, + lastLookBackDate, + bulkCreateTimes, + searchAfterTimes, }: GetRuleStatusSavedObject): Promise => { const sDate = new Date().toISOString(); currentStatusSavedObject.attributes.status = 'succeeded'; currentStatusSavedObject.attributes.statusDate = sDate; currentStatusSavedObject.attributes.lastSuccessAt = sDate; currentStatusSavedObject.attributes.lastSuccessMessage = 'succeeded'; + if (lastLookBackDate != null) { + currentStatusSavedObject.attributes.lastLookBackDate = lastLookBackDate; + } + if (bulkCreateTimes != null) { + currentStatusSavedObject.attributes.bulkCreateTimeDurations = bulkCreateTimes; + } + if (searchAfterTimes != null) { + currentStatusSavedObject.attributes.searchAfterTimeDurations = searchAfterTimes; + } await services.savedObjectsClient.update(ruleStatusSavedObjectType, currentStatusSavedObject.id, { ...currentStatusSavedObject.attributes, }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_gap_error_to_saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_gap_error_to_saved_object.ts index 3650548c80ad5..e47e5388527da 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_gap_error_to_saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_gap_error_to_saved_object.ts @@ -48,6 +48,7 @@ export const writeGapErrorToSavedObject = async ({ lastSuccessAt: currentStatusSavedObject.attributes.lastSuccessAt, lastFailureMessage: `Signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}" has a time gap of ${gap.humanize()} (${gap.asMilliseconds()}ms), and could be missing signals within that time. Consider increasing your look behind time or adding more Kibana instances.`, lastSuccessMessage: currentStatusSavedObject.attributes.lastSuccessMessage, + gap: gap.humanize(), }); if (ruleStatusSavedObjects.saved_objects.length >= 6) { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_signal_rule_exception_to_saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_signal_rule_exception_to_saved_object.ts index 5ca0808902a52..2a14184859591 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_signal_rule_exception_to_saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/write_signal_rule_exception_to_saved_object.ts @@ -19,6 +19,9 @@ interface SignalRuleExceptionParams { message: string; services: AlertServices; name: string; + lastLookBackDate?: string | null | undefined; + bulkCreateTimes?: string[] | null | undefined; + searchAfterTimes?: string[] | null | undefined; } export const writeSignalRuleExceptionToSavedObject = async ({ @@ -30,6 +33,9 @@ export const writeSignalRuleExceptionToSavedObject = async ({ ruleStatusSavedObjects, ruleId, name, + lastLookBackDate, + bulkCreateTimes, + searchAfterTimes, }: SignalRuleExceptionParams): Promise => { logger.error( `Error from signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}" message: ${message}` @@ -39,6 +45,15 @@ export const writeSignalRuleExceptionToSavedObject = async ({ currentStatusSavedObject.attributes.statusDate = sDate; currentStatusSavedObject.attributes.lastFailureAt = sDate; currentStatusSavedObject.attributes.lastFailureMessage = message; + if (lastLookBackDate) { + currentStatusSavedObject.attributes.lastLookBackDate = lastLookBackDate; + } + if (bulkCreateTimes) { + currentStatusSavedObject.attributes.bulkCreateTimeDurations = bulkCreateTimes; + } + if (searchAfterTimes) { + currentStatusSavedObject.attributes.searchAfterTimeDurations = searchAfterTimes; + } // current status is failing await services.savedObjectsClient.update(ruleStatusSavedObjectType, currentStatusSavedObject.id, { ...currentStatusSavedObject.attributes, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts index 2cbdc7db3ba64..aae8763a7ea39 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertAction } from '../../../../../../plugins/alerting/common'; import { CallAPIOptions } from '../../../../../../../src/core/server'; import { Filter } from '../../../../../../../src/plugins/data/server'; import { IRuleStatusAttributes } from './rules/types'; import { ListsDefaultArraySchema } from './routes/schemas/types/lists_default_array'; +import { RuleAlertAction } from '../../../common/detection_engine/types'; export type PartialFilter = Partial; @@ -24,10 +24,6 @@ export interface ThreatParams { technique: IMitreAttack[]; } -export type RuleAlertAction = Omit & { - action_type_id: string; -}; - // Notice below we are using lists: ListsDefaultArraySchema[]; which is coming directly from the response output section. // TODO: Eventually this whole RuleAlertParams will be replaced with io-ts. For now we can slowly strangle it out and reduce duplicate types // We don't have the input types defined through io-ts just yet but as we being introducing types from there we will more and more remove @@ -56,7 +52,7 @@ export interface RuleAlertParams { query: string | undefined | null; references: string[]; savedId?: string | undefined | null; - meta: Record | undefined | null; + meta: Record | undefined | null; severity: string; tags: string[]; to: string; @@ -123,6 +119,7 @@ export type OutputRuleAlertRest = RuleAlertParamsRest & { created_by: string | undefined | null; updated_by: string | undefined | null; immutable: boolean; + throttle: string | undefined | null; }; export type ImportRuleAlertRest = Omit & { diff --git a/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts index b6a43fc523adb..23162f38bffba 100644 --- a/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts @@ -141,6 +141,8 @@ export class Note { } // Update new note + + const existingNote = await this.getSavedNote(request, noteId); return { code: 200, message: 'success', @@ -150,7 +152,7 @@ export class Note { noteId, pickSavedNote(noteId, note, request.user), { - version: version || undefined, + version: existingNote.version || undefined, } ) ), diff --git a/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts index 9ea950e8a443b..a95c1da197f57 100644 --- a/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts @@ -89,7 +89,7 @@ export class PinnedEvent { public async persistPinnedEventOnTimeline( request: FrameworkRequest, - pinnedEventId: string | null, + pinnedEventId: string | null, // pinned event saved object id eventId: string, timelineId: string | null ): Promise { @@ -116,6 +116,7 @@ export class PinnedEvent { const isPinnedAlreadyExisting = allPinnedEventId.filter( pinnedEvent => pinnedEvent.eventId === eventId ); + if (isPinnedAlreadyExisting.length === 0) { const savedPinnedEvent: SavedPinnedEvent = { eventId, @@ -204,7 +205,7 @@ export const convertSavedObjectToSavedPinnedEvent = ( // then this interface does not allow types without index signature // this is limiting us with our type for now so the easy way was to use any -const pickSavedPinnedEvent = ( +export const pickSavedPinnedEvent = ( pinnedEventId: string | null, savedPinnedEvent: SavedPinnedEvent, userInfo: AuthenticatedUser | null diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/create_timelines_stream_from_ndjson.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/create_timelines_stream_from_ndjson.ts new file mode 100644 index 0000000000000..5373570a4f8cc --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/create_timelines_stream_from_ndjson.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Transform } from 'stream'; +import { + createConcatStream, + createSplitStream, + createMapStream, +} from '../../../../../../../src/legacy/utils'; +import { + parseNdjsonStrings, + filterExportedCounts, + createLimitStream, +} from '../detection_engine/rules/create_rules_stream_from_ndjson'; +import { importTimelinesSchema } from './routes/schemas/import_timelines_schema'; +import { BadRequestError } from '../detection_engine/errors/bad_request_error'; +import { ImportTimelineResponse } from './routes/utils/import_timelines'; + +export const validateTimelines = (): Transform => { + return createMapStream((obj: ImportTimelineResponse) => { + if (!(obj instanceof Error)) { + const validated = importTimelinesSchema.validate(obj); + if (validated.error != null) { + return new BadRequestError(validated.error.message); + } else { + return validated.value; + } + } else { + return obj; + } + }); +}; + +export const createTimelinesStreamFromNdJson = (ruleLimit: number) => { + return [ + createSplitStream('\n'), + parseNdjsonStrings(), + filterExportedCounts(), + validateTimelines(), + createLimitStream(ruleLimit), + createConcatStream([]), + ]; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/import_timelines.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/import_timelines.ts new file mode 100644 index 0000000000000..74d3744e29299 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/import_timelines.ts @@ -0,0 +1,177 @@ +/* + * 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 { omit } from 'lodash/fp'; + +export const mockDuplicateIdErrors = []; + +export const mockParsedObjects = [ + { + savedObjectId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + version: 'WzEyMjUsMV0=', + columns: [], + dataProviders: [], + description: 'description', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: [Object] }, + title: 'My duplicate timeline', + dateRange: { start: 1584523907294, end: 1584610307294 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1584828930463, + createdBy: 'angela', + updated: 1584868346013, + updatedBy: 'angela', + eventNotes: [ + { + noteId: '73ac2370-6bc2-11ea-a90b-f5341fb7a189', + version: 'WzExMjgsMV0=', + eventId: 'ZaAi8nAB5OldxqFfdhke', + note: 'event note2', + timelineId: 'da49a0e0-6bc1-11ea-a90b-f5341fb7a189', + created: 1584829349563, + createdBy: 'angela', + updated: 1584829349563, + updatedBy: 'angela', + }, + { + noteId: 'f7b71620-6bc2-11ea-a0b6-33c7b2a78885', + version: 'WzExMzUsMV0=', + eventId: 'ZaAi8nAB5OldxqFfdhke', + note: 'event note2', + timelineId: 'da49a0e0-6bc1-11ea-a90b-f5341fb7a189', + created: 1584829571092, + createdBy: 'angela', + updated: 1584829571092, + updatedBy: 'angela', + }, + ], + globalNotes: [ + { + noteId: 'd2649d40-6bc5-11ea-86f0-5db0048c6086', + version: 'WzExNjQsMV0=', + note: 'global', + timelineId: 'd123dfe0-6bc5-11ea-86f0-5db0048c6086', + created: 1584830796969, + createdBy: 'angela', + updated: 1584830796969, + updatedBy: 'angela', + }, + ], + pinnedEventIds: ['k-gi8nABm-sIqJ_scOoS'], + }, +]; + +export const mockUniqueParsedObjects = [ + { + savedObjectId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + version: 'WzEyMjUsMV0=', + columns: [], + dataProviders: [], + description: 'description', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: [] }, + title: 'My duplicate timeline', + dateRange: { start: 1584523907294, end: 1584610307294 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1584828930463, + createdBy: 'angela', + updated: 1584868346013, + updatedBy: 'angela', + eventNotes: [ + { + noteId: '73ac2370-6bc2-11ea-a90b-f5341fb7a189', + version: 'WzExMjgsMV0=', + eventId: 'ZaAi8nAB5OldxqFfdhke', + note: 'event note1', + timelineId: 'da49a0e0-6bc1-11ea-a90b-f5341fb7a189', + created: 1584829349563, + createdBy: 'angela', + updated: 1584829349563, + updatedBy: 'angela', + }, + { + noteId: 'f7b71620-6bc2-11ea-a0b6-33c7b2a78885', + version: 'WzExMzUsMV0=', + eventId: 'ZaAi8nAB5OldxqFfdhke', + note: 'event note2', + timelineId: 'da49a0e0-6bc1-11ea-a90b-f5341fb7a189', + created: 1584829571092, + createdBy: 'angela', + updated: 1584829571092, + updatedBy: 'angela', + }, + ], + globalNotes: [ + { + noteId: 'd2649d40-6bc5-11ea-86f0-5db0048c6086', + version: 'WzExNjQsMV0=', + note: 'global', + timelineId: 'd123dfe0-6bc5-11ea-86f0-5db0048c6086', + created: 1584830796969, + createdBy: 'angela', + updated: 1584830796969, + updatedBy: 'angela', + }, + ], + pinnedEventIds: ['k-gi8nABm-sIqJ_scOoS'], + }, +]; + +export const mockGetTimelineValue = { + savedObjectId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + version: 'WzEyMjUsMV0=', + columns: [], + dataProviders: [], + description: 'description', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: [] }, + title: 'My duplicate timeline', + dateRange: { start: 1584523907294, end: 1584610307294 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1584828930463, + createdBy: 'angela', + updated: 1584868346013, + updatedBy: 'angela', + noteIds: [], + pinnedEventIds: ['k-gi8nABm-sIqJ_scOoS'], +}; + +export const mockParsedTimelineObject = omit( + [ + 'globalNotes', + 'eventNotes', + 'pinnedEventIds', + 'version', + 'savedObjectId', + 'created', + 'createdBy', + 'updated', + 'updatedBy', + ], + mockUniqueParsedObjects[0] +); + +export const mockConfig = { + get: () => { + return 100000000; + }, + has: jest.fn(), +}; + +export const mockGetCurrentUser = { + user: { + username: 'mockUser', + }, +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts index eae1ece7e789d..0e73e4bdd6c97 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TIMELINE_EXPORT_URL } from '../../../../../common/constants'; +import { TIMELINE_EXPORT_URL, TIMELINE_IMPORT_URL } from '../../../../../common/constants'; import { requestMock } from '../../../detection_engine/routes/__mocks__'; export const getExportTimelinesRequest = () => @@ -16,6 +16,26 @@ export const getExportTimelinesRequest = () => }, }); +export const getImportTimelinesRequest = (filename?: string) => + requestMock.create({ + method: 'post', + path: TIMELINE_IMPORT_URL, + query: { overwrite: false }, + body: { + file: { hapi: { filename: filename ?? 'filename.ndjson' } }, + }, + }); + +export const getImportTimelinesRequestEnableOverwrite = (filename?: string) => + requestMock.create({ + method: 'post', + path: TIMELINE_IMPORT_URL, + query: { overwrite: true }, + body: { + file: { hapi: { filename: filename ?? 'filename.ndjson' } }, + }, + }); + export const mockTimelinesSavedObjects = () => ({ saved_objects: [ { diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts index 3ded959aced36..b8e7be13fff34 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts @@ -21,7 +21,7 @@ import { exportTimelinesQuerySchema, } from './schemas/export_timelines_schema'; -import { getExportTimelineByObjectIds } from './utils'; +import { getExportTimelineByObjectIds } from './utils/export_timelines'; export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['config']) => { router.post( diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts new file mode 100644 index 0000000000000..e89aef4c70ecb --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.test.ts @@ -0,0 +1,341 @@ +/* + * 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 { getImportTimelinesRequest } from './__mocks__/request_responses'; +import { + serverMock, + requestContextMock, + requestMock, +} from '../../detection_engine/routes/__mocks__'; +import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; +import { SecurityPluginSetup } from '../../../../../../../plugins/security/server'; + +import { + mockConfig, + mockUniqueParsedObjects, + mockParsedObjects, + mockDuplicateIdErrors, + mockGetCurrentUser, + mockGetTimelineValue, + mockParsedTimelineObject, +} from './__mocks__/import_timelines'; + +describe('import timelines', () => { + let config: jest.Mock; + let server: ReturnType; + let request: ReturnType; + let securitySetup: SecurityPluginSetup; + let { context } = requestContextMock.createTools(); + let mockGetTimeline: jest.Mock; + let mockPersistTimeline: jest.Mock; + let mockPersistPinnedEventOnTimeline: jest.Mock; + let mockPersistNote: jest.Mock; + const newTimelineSavedObjectId = '79deb4c0-6bc1-11ea-9999-f5341fb7a189'; + const newTimelineVersion = '9999'; + beforeEach(() => { + jest.resetModules(); + jest.resetAllMocks(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + + server = serverMock.create(); + context = requestContextMock.createTools().context; + config = jest.fn().mockImplementation(() => { + return mockConfig; + }); + + securitySetup = ({ + authc: { + getCurrentUser: jest.fn().mockReturnValue(mockGetCurrentUser), + }, + authz: {}, + } as unknown) as SecurityPluginSetup; + + mockGetTimeline = jest.fn(); + mockPersistTimeline = jest.fn(); + mockPersistPinnedEventOnTimeline = jest.fn(); + mockPersistNote = jest.fn(); + + jest.doMock('../create_timelines_stream_from_ndjson', () => { + return { + createTimelinesStreamFromNdJson: jest.fn().mockReturnValue(mockParsedObjects), + }; + }); + + jest.doMock('../../../../../../../../src/legacy/utils', () => { + return { + createPromiseFromStreams: jest.fn().mockReturnValue(mockParsedObjects), + }; + }); + + jest.doMock('./utils/import_timelines', () => { + const originalModule = jest.requireActual('./utils/import_timelines'); + return { + ...originalModule, + getTupleDuplicateErrorsAndUniqueTimeline: jest + .fn() + .mockReturnValue([mockDuplicateIdErrors, mockUniqueParsedObjects]), + }; + }); + }); + + describe('Import a new timeline', () => { + beforeEach(() => { + jest.doMock('../saved_object', () => { + return { + Timeline: jest.fn().mockImplementation(() => { + return { + getTimeline: mockGetTimeline.mockReturnValue(null), + persistTimeline: mockPersistTimeline.mockReturnValue({ + timeline: { savedObjectId: newTimelineSavedObjectId, version: newTimelineVersion }, + }), + }; + }), + }; + }); + + jest.doMock('../../pinned_event/saved_object', () => { + return { + PinnedEvent: jest.fn().mockImplementation(() => { + return { + persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline, + }; + }), + }; + }); + + jest.doMock('../../note/saved_object', () => { + return { + Note: jest.fn().mockImplementation(() => { + return { + persistNote: mockPersistNote, + }; + }), + }; + }); + + const importTimelinesRoute = jest.requireActual('./import_timelines_route') + .importTimelinesRoute; + importTimelinesRoute(server.router, config, securitySetup); + }); + + test('should use given timelineId to check if the timeline savedObject already exist', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockGetTimeline.mock.calls[0][1]).toEqual(mockUniqueParsedObjects[0].savedObjectId); + }); + + test('should Create a new timeline savedObject', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline).toHaveBeenCalled(); + }); + + test('should Create a new timeline savedObject without timelineId', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][1]).toBeNull(); + }); + + test('should Create a new timeline savedObject without timeline version', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][2]).toBeNull(); + }); + + test('should Create a new timeline savedObject witn given timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][3]).toEqual(mockParsedTimelineObject); + }); + + test('should Create new pinned events', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistPinnedEventOnTimeline).toHaveBeenCalled(); + }); + + test('should Create a new pinned event without pinnedEventSavedObjectId', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistPinnedEventOnTimeline.mock.calls[0][1]).toBeNull(); + }); + + test('should Create a new pinned event with pinnedEventId', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistPinnedEventOnTimeline.mock.calls[0][2]).toEqual( + mockUniqueParsedObjects[0].pinnedEventIds[0] + ); + }); + + test('should Create a new pinned event with new timelineSavedObjectId', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistPinnedEventOnTimeline.mock.calls[0][3]).toEqual(newTimelineSavedObjectId); + }); + + test('should Create notes', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote).toHaveBeenCalled(); + }); + + test('should provide no noteSavedObjectId when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][1]).toBeNull(); + }); + + test('should provide new timeline version when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][1]).toBeNull(); + }); + + test('should provide note content when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][2]).toEqual(newTimelineVersion); + }); + + test('should provide new notes when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][3]).toEqual({ + eventId: undefined, + note: mockUniqueParsedObjects[0].globalNotes[0].note, + timelineId: newTimelineSavedObjectId, + }); + expect(mockPersistNote.mock.calls[1][3]).toEqual({ + eventId: mockUniqueParsedObjects[0].eventNotes[0].eventId, + note: mockUniqueParsedObjects[0].eventNotes[0].note, + timelineId: newTimelineSavedObjectId, + }); + expect(mockPersistNote.mock.calls[2][3]).toEqual({ + eventId: mockUniqueParsedObjects[0].eventNotes[1].eventId, + note: mockUniqueParsedObjects[0].eventNotes[1].note, + timelineId: newTimelineSavedObjectId, + }); + }); + + test('returns 200 when import timeline successfully', async () => { + const response = await server.inject(getImportTimelinesRequest(), context); + expect(response.status).toEqual(200); + }); + }); + + describe('Import a timeline already exist but overwrite is not allowed', () => { + beforeEach(() => { + jest.doMock('../saved_object', () => { + return { + Timeline: jest.fn().mockImplementation(() => { + return { + getTimeline: mockGetTimeline.mockReturnValue(mockGetTimelineValue), + persistTimeline: mockPersistTimeline, + }; + }), + }; + }); + + jest.doMock('../../pinned_event/saved_object', () => { + return { + PinnedEvent: jest.fn().mockImplementation(() => { + return { + persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline, + }; + }), + }; + }); + + jest.doMock('../../note/saved_object', () => { + return { + Note: jest.fn().mockImplementation(() => { + return { + persistNote: mockPersistNote, + }; + }), + }; + }); + + const importTimelinesRoute = jest.requireActual('./import_timelines_route') + .importTimelinesRoute; + importTimelinesRoute(server.router, config, securitySetup); + }); + + test('returns error message', async () => { + const response = await server.inject(getImportTimelinesRequest(), context); + expect(response.body).toEqual({ + success: false, + success_count: 0, + errors: [ + { + id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + error: { + status_code: 409, + message: `timeline_id: "79deb4c0-6bc1-11ea-a90b-f5341fb7a189" already exists`, + }, + }, + ], + }); + }); + }); + + describe('request validation', () => { + beforeEach(() => { + jest.doMock('../saved_object', () => { + return { + Timeline: jest.fn().mockImplementation(() => { + return { + getTimeline: mockGetTimeline.mockReturnValue(null), + persistTimeline: mockPersistTimeline.mockReturnValue({ + timeline: { savedObjectId: '79deb4c0-6bc1-11ea-9999-f5341fb7a189' }, + }), + }; + }), + }; + }); + + jest.doMock('../../pinned_event/saved_object', () => { + return { + PinnedEvent: jest.fn().mockImplementation(() => { + return { + persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline.mockReturnValue( + new Error('Test error') + ), + }; + }), + }; + }); + + jest.doMock('../../note/saved_object', () => { + return { + Note: jest.fn().mockImplementation(() => { + return { + persistNote: mockPersistNote, + }; + }), + }; + }); + }); + test('disallows invalid query', async () => { + request = requestMock.create({ + method: 'post', + path: TIMELINE_EXPORT_URL, + body: { id: 'someId' }, + }); + const importTimelinesRoute = jest.requireActual('./import_timelines_route') + .importTimelinesRoute; + + importTimelinesRoute(server.router, config, securitySetup); + const result = server.validate(request); + + expect(result.badRequest).toHaveBeenCalledWith( + 'child "file" fails because ["file" is required]' + ); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts new file mode 100644 index 0000000000000..fefe31b2f36d0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/import_timelines_route.ts @@ -0,0 +1,226 @@ +/* + * 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 { extname } from 'path'; +import { chunk, omit, set } from 'lodash/fp'; +import { + buildRouteValidation, + buildSiemResponse, + createBulkErrorObject, + BulkError, + transformError, +} from '../../detection_engine/routes/utils'; + +import { createTimelinesStreamFromNdJson } from '../create_timelines_stream_from_ndjson'; +import { createPromiseFromStreams } from '../../../../../../../../src/legacy/utils'; + +import { + createTimelines, + getTupleDuplicateErrorsAndUniqueTimeline, + isBulkError, + isImportRegular, + ImportTimelineResponse, + ImportTimelinesRequestParams, + ImportTimelinesSchema, + PromiseFromStreams, +} from './utils/import_timelines'; + +import { IRouter } from '../../../../../../../../src/core/server'; +import { TIMELINE_IMPORT_URL } from '../../../../common/constants'; +import { importTimelinesPayloadSchema } from './schemas/import_timelines_schema'; +import { importRulesSchema } from '../../detection_engine/routes/schemas/response/import_rules_schema'; +import { LegacyServices } from '../../../types'; + +import { Timeline } from '../saved_object'; +import { validate } from '../../detection_engine/routes/rules/validate'; +import { FrameworkRequest } from '../../framework'; +import { SecurityPluginSetup } from '../../../../../../../plugins/security/server'; + +const CHUNK_PARSED_OBJECT_SIZE = 10; + +const timelineLib = new Timeline(); + +export const importTimelinesRoute = ( + router: IRouter, + config: LegacyServices['config'], + securityPluginSetup: SecurityPluginSetup +) => { + router.post( + { + path: `${TIMELINE_IMPORT_URL}`, + validate: { + body: buildRouteValidation( + importTimelinesPayloadSchema + ), + }, + options: { + tags: ['access:siem'], + body: { + maxBytes: config().get('savedObjects.maxImportPayloadBytes'), + output: 'stream', + }, + }, + }, + async (context, request, response) => { + const siemResponse = buildSiemResponse(response); + const savedObjectsClient = context.core.savedObjects.client; + if (!savedObjectsClient) { + return siemResponse.error({ statusCode: 404 }); + } + const { filename } = request.body.file.hapi; + + const fileExtension = extname(filename).toLowerCase(); + + if (fileExtension !== '.ndjson') { + return siemResponse.error({ + statusCode: 400, + body: `Invalid file extension ${fileExtension}`, + }); + } + + const objectLimit = config().get('savedObjects.maxImportExportSize'); + + try { + const readStream = createTimelinesStreamFromNdJson(objectLimit); + const parsedObjects = await createPromiseFromStreams([ + request.body.file, + ...readStream, + ]); + const [duplicateIdErrors, uniqueParsedObjects] = getTupleDuplicateErrorsAndUniqueTimeline( + parsedObjects, + false + ); + const chunkParseObjects = chunk(CHUNK_PARSED_OBJECT_SIZE, uniqueParsedObjects); + let importTimelineResponse: ImportTimelineResponse[] = []; + + const user = await securityPluginSetup.authc.getCurrentUser(request); + let frameworkRequest = set('context.core.savedObjects.client', savedObjectsClient, request); + frameworkRequest = set('user', user, frameworkRequest); + + while (chunkParseObjects.length) { + const batchParseObjects = chunkParseObjects.shift() ?? []; + const newImportTimelineResponse = await Promise.all( + batchParseObjects.reduce>>( + (accum, parsedTimeline) => { + const importsWorkerPromise = new Promise( + async (resolve, reject) => { + if (parsedTimeline instanceof Error) { + // If the JSON object had a validation or parse error then we return + // early with the error and an (unknown) for the ruleId + resolve( + createBulkErrorObject({ + statusCode: 400, + message: parsedTimeline.message, + }) + ); + + return null; + } + const { + savedObjectId, + pinnedEventIds, + globalNotes, + eventNotes, + } = parsedTimeline; + const parsedTimelineObject = omit( + [ + 'globalNotes', + 'eventNotes', + 'pinnedEventIds', + 'version', + 'savedObjectId', + 'created', + 'createdBy', + 'updated', + 'updatedBy', + ], + parsedTimeline + ); + try { + let timeline = null; + try { + timeline = await timelineLib.getTimeline( + (frameworkRequest as unknown) as FrameworkRequest, + savedObjectId + ); + // eslint-disable-next-line no-empty + } catch (e) {} + + if (timeline == null) { + const newSavedObjectId = await createTimelines( + (frameworkRequest as unknown) as FrameworkRequest, + parsedTimelineObject, + null, // timelineSavedObjectId + null, // timelineVersion + pinnedEventIds, + [...globalNotes, ...eventNotes], + [] // existing note ids + ); + + resolve({ timeline_id: newSavedObjectId, status_code: 200 }); + } else { + resolve( + createBulkErrorObject({ + id: savedObjectId, + statusCode: 409, + message: `timeline_id: "${savedObjectId}" already exists`, + }) + ); + } + } catch (err) { + resolve( + createBulkErrorObject({ + id: savedObjectId, + statusCode: 400, + message: err.message, + }) + ); + } + } + ); + return [...accum, importsWorkerPromise]; + }, + [] + ) + ); + importTimelineResponse = [ + ...duplicateIdErrors, + ...importTimelineResponse, + ...newImportTimelineResponse, + ]; + } + + const errorsResp = importTimelineResponse.filter(resp => isBulkError(resp)) as BulkError[]; + const successes = importTimelineResponse.filter(resp => { + if (isImportRegular(resp)) { + return resp.status_code === 200; + } else { + return false; + } + }); + const importTimelines: ImportTimelinesSchema = { + success: errorsResp.length === 0, + success_count: successes.length, + errors: errorsResp, + }; + const [validated, errors] = validate(importTimelines, importRulesSchema); + + if (errors != null) { + return siemResponse.error({ statusCode: 500, body: errors }); + } 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/legacy/plugins/siem/server/lib/timeline/routes/schemas/import_timelines_schema.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/import_timelines_schema.ts new file mode 100644 index 0000000000000..61ffa9681c53a --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/import_timelines_schema.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import Joi from 'joi'; +import { + columns, + created, + createdBy, + dataProviders, + dateRange, + description, + eventNotes, + eventType, + favorite, + filters, + globalNotes, + kqlMode, + kqlQuery, + savedObjectId, + savedQueryId, + sort, + title, + updated, + updatedBy, + version, + pinnedEventIds, +} from './schemas'; + +export const importTimelinesPayloadSchema = Joi.object({ + file: Joi.object().required(), +}); + +export const importTimelinesSchema = Joi.object({ + columns, + created, + createdBy, + dataProviders, + dateRange, + description, + eventNotes, + eventType, + filters, + favorite, + globalNotes, + kqlMode, + kqlQuery, + savedObjectId, + savedQueryId, + sort, + title, + updated, + updatedBy, + version, + pinnedEventIds, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts index 67697c347634e..63aee97729141 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts @@ -5,9 +5,162 @@ */ import Joi from 'joi'; +const allowEmptyString = Joi.string().allow([null, '']); +const columnHeaderType = Joi.string(); +export const created = Joi.number().allow(null); +export const createdBy = Joi.string(); + +export const description = allowEmptyString; +export const end = Joi.number(); +export const eventId = allowEmptyString; +export const eventType = Joi.string(); + +export const filters = Joi.array() + .items( + Joi.object({ + meta: Joi.object({ + alias: allowEmptyString, + controlledBy: allowEmptyString, + disabled: Joi.boolean().allow(null), + field: allowEmptyString, + formattedValue: allowEmptyString, + index: { + type: 'keyword', + }, + key: { + type: 'keyword', + }, + negate: { + type: 'boolean', + }, + params: allowEmptyString, + type: { + type: 'keyword', + }, + value: allowEmptyString, + }), + exists: allowEmptyString, + match_all: allowEmptyString, + missing: allowEmptyString, + query: allowEmptyString, + range: allowEmptyString, + script: allowEmptyString, + }) + ) + .allow(null); + +const name = allowEmptyString; + +export const noteId = allowEmptyString; +export const note = allowEmptyString; + +export const start = Joi.number(); +export const savedQueryId = allowEmptyString; +export const savedObjectId = allowEmptyString; + +export const timelineId = allowEmptyString; +export const title = allowEmptyString; + +export const updated = Joi.number().allow(null); +export const updatedBy = allowEmptyString; +export const version = allowEmptyString; + +export const columns = Joi.array().items( + Joi.object({ + aggregatable: Joi.boolean().allow(null), + category: Joi.string(), + columnHeaderType, + description, + example: allowEmptyString, + indexes: allowEmptyString, + id: Joi.string(), + name, + placeholder: allowEmptyString, + searchable: Joi.boolean().allow(null), + type: Joi.string(), + }).required() +); +export const dataProviders = Joi.array() + .items( + Joi.object({ + id: Joi.string(), + name: allowEmptyString, + enabled: Joi.boolean().allow(null), + excluded: Joi.boolean().allow(null), + kqlQuery: allowEmptyString, + queryMatch: Joi.object({ + field: allowEmptyString, + displayField: allowEmptyString, + value: allowEmptyString, + displayValue: allowEmptyString, + operator: allowEmptyString, + }), + and: Joi.array() + .items( + Joi.object({ + id: Joi.string(), + name, + enabled: Joi.boolean().allow(null), + excluded: Joi.boolean().allow(null), + kqlQuery: allowEmptyString, + queryMatch: Joi.object({ + field: allowEmptyString, + displayField: allowEmptyString, + value: allowEmptyString, + displayValue: allowEmptyString, + operator: allowEmptyString, + }).allow(null), + }) + ) + .allow(null), + }) + ) + .allow(null); +export const dateRange = Joi.object({ + start, + end, +}); +export const favorite = Joi.array().items( + Joi.object({ + keySearch: Joi.string(), + fullName: Joi.string(), + userName: Joi.string(), + favoriteDate: Joi.number(), + }).allow(null) +); +const noteItem = Joi.object({ + noteId, + version, + eventId, + note, + timelineId, + created, + createdBy, + updated, + updatedBy, +}); +export const eventNotes = Joi.array().items(noteItem); +export const globalNotes = Joi.array().items(noteItem); +export const kqlMode = Joi.string(); +export const kqlQuery = Joi.object({ + filterQuery: Joi.object({ + kuery: Joi.object({ + kind: Joi.string(), + expression: allowEmptyString, + }), + serializedQuery: allowEmptyString, + }), +}); +export const pinnedEventIds = Joi.array() + .items(Joi.string()) + .allow(null); +export const sort = Joi.object({ + columnId: Joi.string(), + sortDirection: Joi.string(), +}); /* eslint-disable @typescript-eslint/camelcase */ export const ids = Joi.array().items(Joi.string()); export const exclude_export_details = Joi.boolean(); -export const file_name = Joi.string(); +export const file_name = allowEmptyString; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/export_timelines.ts similarity index 85% rename from x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts rename to x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/export_timelines.ts index 066862e025833..8a28100fbae82 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/export_timelines.ts @@ -3,37 +3,53 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { set as _set } from 'lodash/fp'; import { + noteSavedObjectType, + pinnedEventSavedObjectType, + timelineSavedObjectType, +} from '../../../../saved_objects'; +import { NoteSavedObject } from '../../../note/types'; +import { PinnedEventSavedObject } from '../../../pinned_event/types'; +import { convertSavedObjectToSavedTimeline } from '../../convert_saved_object_to_savedtimeline'; + +import { convertSavedObjectToSavedPinnedEvent } from '../../../pinned_event/saved_object'; +import { convertSavedObjectToSavedNote } from '../../../note/saved_object'; + +import { + SavedObjectsClient, SavedObjectsFindOptions, SavedObjectsFindResponse, -} from '../../../../../../../../src/core/server'; +} from '../../../../../../../../../src/core/server'; import { + ExportedTimelines, ExportTimelineSavedObjectsClient, ExportTimelineRequest, ExportedNotes, TimelineSavedObject, - ExportedTimelines, -} from '../types'; -import { - timelineSavedObjectType, - noteSavedObjectType, - pinnedEventSavedObjectType, -} from '../../../saved_objects'; - -import { convertSavedObjectToSavedNote } from '../../note/saved_object'; -import { convertSavedObjectToSavedPinnedEvent } from '../../pinned_event/saved_object'; -import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; -import { transformDataToNdjson } from '../../detection_engine/routes/rules/utils'; -import { NoteSavedObject } from '../../note/types'; -import { PinnedEventSavedObject } from '../../pinned_event/types'; +} from '../../types'; + +import { transformDataToNdjson } from '../../../detection_engine/routes/rules/utils'; +export type TimelineSavedObjectsClient = Pick< + SavedObjectsClient, + | 'get' + | 'errors' + | 'create' + | 'bulkCreate' + | 'delete' + | 'find' + | 'bulkGet' + | 'update' + | 'bulkUpdate' +>; const getAllSavedPinnedEvents = ( pinnedEventsSavedObjects: SavedObjectsFindResponse ): PinnedEventSavedObject[] => { return pinnedEventsSavedObjects != null - ? pinnedEventsSavedObjects.saved_objects.map(savedObject => + ? (pinnedEventsSavedObjects?.saved_objects ?? []).map(savedObject => convertSavedObjectToSavedPinnedEvent(savedObject) ) : []; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts new file mode 100644 index 0000000000000..5596d0c70f5ea --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils/import_timelines.ts @@ -0,0 +1,191 @@ +/* + * 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 { has } from 'lodash/fp'; +import { createBulkErrorObject, BulkError } from '../../../detection_engine/routes/utils'; +import { PinnedEvent } from '../../../pinned_event/saved_object'; +import { Note } from '../../../note/saved_object'; + +import { Timeline } from '../../saved_object'; +import { SavedTimeline } from '../../types'; +import { FrameworkRequest } from '../../../framework'; +import { SavedNote } from '../../../note/types'; +import { NoteResult } from '../../../../graphql/types'; +import { HapiReadableStream } from '../../../detection_engine/rules/types'; + +const pinnedEventLib = new PinnedEvent(); +const timelineLib = new Timeline(); +const noteLib = new Note(); + +export interface ImportTimelinesSchema { + success: boolean; + success_count: number; + errors: BulkError[]; +} + +export type ImportedTimeline = SavedTimeline & { + savedObjectId: string; + pinnedEventIds: string[]; + globalNotes: NoteResult[]; + eventNotes: NoteResult[]; +}; + +interface ImportRegular { + timeline_id: string; + status_code: number; + message?: string; +} + +export type ImportTimelineResponse = ImportRegular | BulkError; +export type PromiseFromStreams = ImportedTimeline; +export interface ImportTimelinesRequestParams { + body: { file: HapiReadableStream }; +} + +export const getTupleDuplicateErrorsAndUniqueTimeline = ( + timelines: PromiseFromStreams[], + isOverwrite: boolean +): [BulkError[], PromiseFromStreams[]] => { + const { errors, timelinesAcc } = timelines.reduce( + (acc, parsedTimeline) => { + if (parsedTimeline instanceof Error) { + acc.timelinesAcc.set(uuid.v4(), parsedTimeline); + } else { + const { savedObjectId } = parsedTimeline; + if (savedObjectId != null) { + if (acc.timelinesAcc.has(savedObjectId) && !isOverwrite) { + acc.errors.set( + uuid.v4(), + createBulkErrorObject({ + id: savedObjectId, + statusCode: 400, + message: `More than one timeline with savedObjectId: "${savedObjectId}" found`, + }) + ); + } + acc.timelinesAcc.set(savedObjectId, parsedTimeline); + } else { + acc.timelinesAcc.set(uuid.v4(), parsedTimeline); + } + } + + return acc; + }, // using map (preserves ordering) + { + errors: new Map(), + timelinesAcc: new Map(), + } + ); + + return [Array.from(errors.values()), Array.from(timelinesAcc.values())]; +}; + +export const saveTimelines = async ( + frameworkRequest: FrameworkRequest, + timeline: SavedTimeline, + timelineSavedObjectId?: string | null, + timelineVersion?: string | null +) => { + const newTimelineRes = await timelineLib.persistTimeline( + frameworkRequest, + timelineSavedObjectId ?? null, + timelineVersion ?? null, + timeline + ); + + return { + newTimelineSavedObjectId: newTimelineRes?.timeline?.savedObjectId ?? null, + newTimelineVersion: newTimelineRes?.timeline?.version ?? null, + }; +}; + +export const savePinnedEvents = ( + frameworkRequest: FrameworkRequest, + timelineSavedObjectId: string, + pinnedEventIds?: string[] | null +) => { + return ( + pinnedEventIds?.map(eventId => { + return pinnedEventLib.persistPinnedEventOnTimeline( + frameworkRequest, + null, // pinnedEventSavedObjectId + eventId, + timelineSavedObjectId + ); + }) ?? [] + ); +}; + +export const saveNotes = ( + frameworkRequest: FrameworkRequest, + timelineSavedObjectId: string, + timelineVersion?: string | null, + existingNoteIds?: string[], + newNotes?: NoteResult[] +) => { + return ( + newNotes?.map(note => { + const newNote: SavedNote = { + eventId: note.eventId, + note: note.note, + timelineId: timelineSavedObjectId, + }; + + return noteLib.persistNote( + frameworkRequest, + existingNoteIds?.find(nId => nId === note.noteId) ?? null, + timelineVersion ?? null, + newNote + ); + }) ?? [] + ); +}; + +export const createTimelines = async ( + frameworkRequest: FrameworkRequest, + timeline: SavedTimeline, + timelineSavedObjectId?: string | null, + timelineVersion?: string | null, + pinnedEventIds?: string[] | null, + notes?: NoteResult[], + existingNoteIds?: string[] +) => { + const { newTimelineSavedObjectId, newTimelineVersion } = await saveTimelines( + frameworkRequest, + timeline, + timelineSavedObjectId, + timelineVersion + ); + await Promise.all([ + savePinnedEvents( + frameworkRequest, + timelineSavedObjectId ?? newTimelineSavedObjectId, + pinnedEventIds + ), + saveNotes( + frameworkRequest, + timelineSavedObjectId ?? newTimelineSavedObjectId, + newTimelineVersion, + existingNoteIds, + notes + ), + ]); + + return newTimelineSavedObjectId; +}; + +export const isImportRegular = ( + importTimelineResponse: ImportTimelineResponse +): importTimelineResponse is ImportRegular => { + return !has('error', importTimelineResponse) && has('status_code', importTimelineResponse); +}; + +export const isBulkError = ( + importRuleResponse: ImportTimelineResponse +): importRuleResponse is BulkError => { + return has('error', importRuleResponse); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts index 88d7fcdb68164..bc6975331ad9b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts @@ -138,19 +138,19 @@ export class Timeline { timeline: SavedTimeline ): Promise { const savedObjectsClient = request.context.core.savedObjects.client; - try { if (timelineId == null) { // Create new timeline + const newTimeline = convertSavedObjectToSavedTimeline( + await savedObjectsClient.create( + timelineSavedObjectType, + pickSavedTimeline(timelineId, timeline, request.user) + ) + ); return { code: 200, message: 'success', - timeline: convertSavedObjectToSavedTimeline( - await savedObjectsClient.create( - timelineSavedObjectType, - pickSavedTimeline(timelineId, timeline, request.user) - ) - ), + timeline: newTimeline, }; } // Update Timeline @@ -162,6 +162,7 @@ export class Timeline { version: version || undefined, } ); + return { code: 200, message: 'success', diff --git a/x-pack/legacy/plugins/siem/server/plugin.ts b/x-pack/legacy/plugins/siem/server/plugin.ts index c505edc79bc76..2bce9b6a7e1aa 100644 --- a/x-pack/legacy/plugins/siem/server/plugin.ts +++ b/x-pack/legacy/plugins/siem/server/plugin.ts @@ -21,12 +21,15 @@ import { PluginSetupContract as FeaturesSetup } from '../../../../plugins/featur import { EncryptedSavedObjectsPluginSetup as EncryptedSavedObjectsSetup } from '../../../../plugins/encrypted_saved_objects/server'; import { SpacesPluginSetup as SpacesSetup } from '../../../../plugins/spaces/server'; import { PluginStartContract as ActionsStart } from '../../../../plugins/actions/server'; +import { LicensingPluginSetup } from '../../../../plugins/licensing/server'; import { LegacyServices } from './types'; import { initServer } from './init_server'; import { compose } from './lib/compose/kibana'; import { initRoutes } from './routes'; import { isAlertExecutor } from './lib/detection_engine/signals/types'; import { signalRulesAlertType } from './lib/detection_engine/signals/signal_rule_alert_type'; +import { rulesNotificationAlertType } from './lib/detection_engine/notifications/rules_notification_alert_type'; +import { isNotificationAlertExecutor } from './lib/detection_engine/notifications/types'; import { noteSavedObjectType, pinnedEventSavedObjectType, @@ -39,11 +42,12 @@ import { hasListsFeature, listsEnvFeatureFlagName } from './lib/detection_engine export { CoreSetup, CoreStart }; export interface SetupPlugins { + alerting: AlertingSetup; encryptedSavedObjects: EncryptedSavedObjectsSetup; features: FeaturesSetup; + licensing: LicensingPluginSetup; security: SecuritySetup; spaces?: SpacesSetup; - alerting: AlertingSetup; } export interface StartPlugins { @@ -87,7 +91,8 @@ export class Plugin { initRoutes( router, __legacy.config, - plugins.encryptedSavedObjects?.usingEphemeralEncryptionKey ?? false + plugins.encryptedSavedObjects?.usingEphemeralEncryptionKey ?? false, + plugins.security ); plugins.features.registerFeature({ @@ -95,12 +100,15 @@ export class Plugin { name: i18n.translate('xpack.siem.featureRegistry.linkSiemTitle', { defaultMessage: 'SIEM', }), + order: 1100, icon: 'securityAnalyticsApp', navLinkId: 'siem', app: ['siem', 'kibana'], catalogue: ['siem'], privileges: { all: { + app: ['siem', 'kibana'], + catalogue: ['siem'], api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'], savedObject: { all: [ @@ -126,6 +134,8 @@ export class Plugin { ], }, read: { + app: ['siem', 'kibana'], + catalogue: ['siem'], api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'], savedObject: { all: ['alert', 'action', 'action_task_params'], @@ -151,12 +161,20 @@ export class Plugin { }); if (plugins.alerting != null) { - const type = signalRulesAlertType({ + const signalRuleType = signalRulesAlertType({ logger: this.logger, version: this.context.env.packageInfo.version, }); - if (isAlertExecutor(type)) { - plugins.alerting.registerType(type); + const ruleNotificationType = rulesNotificationAlertType({ + logger: this.logger, + }); + + if (isAlertExecutor(signalRuleType)) { + plugins.alerting.registerType(signalRuleType); + } + + if (isNotificationAlertExecutor(ruleNotificationType)) { + plugins.alerting.registerType(ruleNotificationType); } } diff --git a/x-pack/legacy/plugins/siem/server/routes/index.ts b/x-pack/legacy/plugins/siem/server/routes/index.ts index 08ff9208ce20b..29c21ad157235 100644 --- a/x-pack/legacy/plugins/siem/server/routes/index.ts +++ b/x-pack/legacy/plugins/siem/server/routes/index.ts @@ -29,12 +29,15 @@ import { importRulesRoute } from '../lib/detection_engine/routes/rules/import_ru import { exportRulesRoute } from '../lib/detection_engine/routes/rules/export_rules_route'; import { findRulesStatusesRoute } from '../lib/detection_engine/routes/rules/find_rules_status_route'; import { getPrepackagedRulesStatusRoute } from '../lib/detection_engine/routes/rules/get_prepackaged_rules_status_route'; +import { importTimelinesRoute } from '../lib/timeline/routes/import_timelines_route'; import { exportTimelinesRoute } from '../lib/timeline/routes/export_timelines_route'; +import { SecurityPluginSetup } from '../../../../../plugins/security/server/'; export const initRoutes = ( router: IRouter, config: LegacyServices['config'], - usingEphemeralEncryptionKey: boolean + usingEphemeralEncryptionKey: boolean, + security: SecurityPluginSetup ) => { // Detection Engine Rule routes that have the REST endpoints of /api/detection_engine/rules // All REST rule creation, deletion, updating, etc...... @@ -55,6 +58,7 @@ export const initRoutes = ( importRulesRoute(router, config); exportRulesRoute(router, config); + importTimelinesRoute(router, config, security); exportTimelinesRoute(router, config); findRulesStatusesRoute(router); diff --git a/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts b/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts index a1a3e86e6a97e..7fafe6584d831 100644 --- a/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts +++ b/x-pack/legacy/plugins/uptime/common/constants/rest_api.ts @@ -16,6 +16,7 @@ export enum API_URLS { PING_HISTOGRAM = `/api/uptime/ping/histogram`, SNAPSHOT_COUNT = `/api/uptime/snapshot/count`, FILTERS = `/api/uptime/filters`, + logPageView = `/api/uptime/logPageView`, ML_MODULE_JOBS = `/api/ml/modules/jobs_exist/`, ML_SETUP_MODULE = '/api/ml/modules/setup/', diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx index 99853a9f775ec..8093dd30604e4 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/alerts/toggle_alert_flyout_button.tsx @@ -48,7 +48,7 @@ export const ToggleAlertFlyoutButtonComponent = ({ setAlertFlyoutVisible }: Prop })} data-test-subj="xpack.uptime.toggleAlertFlyout" key="create-alert" - icon="alert" + icon="bell" onClick={() => setAlertFlyoutVisible(true)} > { notifications, triggers_actions_ui: { actionTypeRegistry, alertTypeRegistry }, uiSettings, + docLinks, }, } = useKibana(); @@ -26,6 +27,7 @@ export const UptimeAlertsContextProvider: React.FC = ({ children }) => { actionTypeRegistry, alertTypeRegistry, charts, + docLinks, dataFieldsFormats: fieldFormats, http, toastNotifications: notifications?.toasts, diff --git a/x-pack/legacy/plugins/uptime/public/hooks/use_telemetry.ts b/x-pack/legacy/plugins/uptime/public/hooks/use_telemetry.ts index 7eb18404decfd..fc0e0ce1c3e88 100644 --- a/x-pack/legacy/plugins/uptime/public/hooks/use_telemetry.ts +++ b/x-pack/legacy/plugins/uptime/public/hooks/use_telemetry.ts @@ -5,33 +5,31 @@ */ import { useEffect } from 'react'; -import { HttpHandler } from 'kibana/public'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { useUrlParams } from './use_url_params'; +import { apiService } from '../state/api/utils'; +import { API_URLS } from '../../common/constants'; export enum UptimePage { - Overview = '/api/uptime/logOverview', - Monitor = '/api/uptime/logMonitor', + Overview = 'Overview', + Monitor = 'Monitor', + Settings = 'Settings', NotFound = '__not-found__', } -const getApiPath = (page?: UptimePage) => { - if (!page) throw new Error('Telemetry logging for this page not yet implemented'); - if (page === '__not-found__') - throw new Error('Telemetry logging for 404 page not yet implemented'); - return page.valueOf(); -}; - -const logPageLoad = async (fetch: HttpHandler, page?: UptimePage) => { - await fetch(getApiPath(page), { - method: 'POST', - }); -}; - export const useUptimeTelemetry = (page?: UptimePage) => { - const kibana = useKibana(); - const fetch = kibana.services.http?.fetch; + const [getUrlParams] = useUrlParams(); + const { dateRangeStart, dateRangeEnd, autorefreshInterval, autorefreshIsPaused } = getUrlParams(); + useEffect(() => { - if (!fetch) throw new Error('Core http services are not defined'); - logPageLoad(fetch, page); - }, [fetch, page]); + if (!apiService.http) throw new Error('Core http services are not defined'); + + const params = { + page, + autorefreshInterval: autorefreshInterval / 1000, // divide by 1000 to keep it in secs + dateStart: dateRangeStart, + dateEnd: dateRangeEnd, + autoRefreshEnabled: !autorefreshIsPaused, + }; + apiService.post(API_URLS.logPageView, params); + }, [autorefreshInterval, autorefreshIsPaused, dateRangeEnd, dateRangeStart, page]); }; diff --git a/x-pack/legacy/plugins/uptime/public/pages/settings.tsx b/x-pack/legacy/plugins/uptime/public/pages/settings.tsx index 679a61686e435..e78c3e0f7de09 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/settings.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/settings.tsx @@ -6,19 +6,19 @@ import React, { useEffect, useState } from 'react'; import { - EuiForm, - EuiTitle, - EuiSpacer, + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiCode, EuiDescribedFormGroup, EuiFieldText, - EuiFormRow, - EuiCode, - EuiPanel, EuiFlexGroup, EuiFlexItem, - EuiButton, - EuiButtonEmpty, - EuiCallOut, + EuiForm, + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiTitle, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { connect } from 'react-redux'; @@ -29,10 +29,11 @@ import { AppState } from '../state'; import { selectDynamicSettings } from '../state/selectors'; import { DynamicSettingsState } from '../state/reducers/dynamic_settings'; import { getDynamicSettings, setDynamicSettings } from '../state/actions/dynamic_settings'; -import { DynamicSettings, defaultDynamicSettings } from '../../common/runtime_types'; +import { defaultDynamicSettings, DynamicSettings } from '../../common/runtime_types'; import { useBreadcrumbs } from '../hooks/use_breadcrumbs'; import { OVERVIEW_ROUTE } from '../../common/constants'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { UptimePage, useUptimeTelemetry } from '../hooks'; interface Props { dynamicSettingsState: DynamicSettingsState; @@ -53,6 +54,8 @@ export const SettingsPageComponent = ({ }); useBreadcrumbs([{ text: settingsBreadcrumbText }]); + useUptimeTelemetry(UptimePage.Settings); + useEffect(() => { dispatchGetDynamicSettings({}); }, [dispatchGetDynamicSettings]); diff --git a/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts b/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts index a9abc733775d2..7b5dc19760627 100644 --- a/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts +++ b/x-pack/legacy/plugins/xpack_main/server/xpack_main.d.ts @@ -5,12 +5,12 @@ */ import KbnServer from 'src/legacy/server/kbn_server'; -import { Feature, FeatureWithAllOrReadPrivileges } from '../../../../plugins/features/server'; +import { Feature, FeatureConfig } from '../../../../plugins/features/server'; import { XPackInfo, XPackInfoOptions } from './lib/xpack_info'; export { XPackFeature } from './lib/xpack_info'; export interface XPackMainPlugin { info: XPackInfo; getFeatures(): Feature[]; - registerFeature(feature: FeatureWithAllOrReadPrivileges): void; + registerFeature(feature: FeatureConfig): void; } diff --git a/x-pack/package.json b/x-pack/package.json index fdd2ef3719959..fcc7e70d3e417 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -59,6 +59,7 @@ "@types/elasticsearch": "^5.0.33", "@types/fancy-log": "^1.3.1", "@types/file-saver": "^2.0.0", + "@types/geojson": "7946.0.7", "@types/getos": "^3.0.0", "@types/git-url-parse": "^9.0.0", "@types/glob": "^7.1.1", @@ -75,7 +76,7 @@ "@types/json-stable-stringify": "^1.0.32", "@types/jsonwebtoken": "^7.2.8", "@types/lodash": "^3.10.1", - "@types/mapbox-gl": "^0.54.1", + "@types/mapbox-gl": "^1.8.1", "@types/memoize-one": "^4.1.0", "@types/mime": "^2.0.1", "@types/mocha": "^7.0.2", @@ -180,7 +181,7 @@ "@babel/runtime": "^7.5.5", "@elastic/apm-rum-react": "^0.3.2", "@elastic/datemath": "5.0.2", - "@elastic/ems-client": "7.7.0", + "@elastic/ems-client": "7.7.1", "@elastic/eui": "21.0.1", "@elastic/filesaver": "1.1.2", "@elastic/maki": "6.2.0", @@ -192,8 +193,8 @@ "@kbn/interpreter": "1.0.0", "@kbn/storybook": "1.0.0", "@kbn/ui-framework": "1.0.0", - "@mapbox/mapbox-gl-draw": "^1.1.1", - "@mapbox/mapbox-gl-rtl-text": "0.2.3", + "@mapbox/mapbox-gl-draw": "^1.1.2", + "@mapbox/mapbox-gl-rtl-text": "^0.2.3", "@scant/router": "^0.1.0", "@slack/webhook": "^5.0.0", "@turf/boolean-contains": "6.0.1", @@ -272,7 +273,7 @@ "lodash.topath": "^4.5.2", "lodash.uniqby": "^4.7.0", "lz-string": "^1.4.4", - "mapbox-gl": "1.3.1", + "mapbox-gl": "^1.9.0", "mapbox-gl-draw-rectangle-mode": "^1.0.4", "markdown-it": "^10.0.0", "memoize-one": "^5.0.0", diff --git a/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts index 7eded9bb40964..ec495aed7675a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts @@ -52,6 +52,7 @@ describe('config validation', () => { ...config, index: 'testing-123', refresh: false, + executionTimeField: null, }); config.executionTimeField = 'field-123'; @@ -62,6 +63,14 @@ describe('config validation', () => { executionTimeField: 'field-123', }); + config.executionTimeField = null; + expect(validateConfig(actionType, config)).toEqual({ + ...config, + index: 'testing-123', + refresh: false, + executionTimeField: null, + }); + delete config.index; expect(() => { @@ -73,9 +82,11 @@ describe('config validation', () => { expect(() => { validateConfig(actionType, { index: 'testing-123', executionTimeField: true }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type config: [executionTimeField]: expected value of type [string] but got [boolean]"` - ); + }).toThrowErrorMatchingInlineSnapshot(` +"error validating action type config: [executionTimeField]: types that failed validation: +- [executionTimeField.0]: expected value of type [string] but got [boolean] +- [executionTimeField.1]: expected value to equal [null]" +`); delete config.refresh; expect(() => { @@ -138,12 +149,12 @@ describe('params validation', () => { describe('execute()', () => { test('ensure parameters are as expected', async () => { const secrets = {}; - let config: ActionTypeConfigType; + let config: Partial; let params: ActionParamsType; let executorOptions: ActionTypeExecutorOptions; // minimal params - config = { index: 'index-value', refresh: false, executionTimeField: undefined }; + config = { index: 'index-value', refresh: false }; params = { documents: [{ jim: 'bob' }], }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/es_index.ts b/x-pack/plugins/actions/server/builtin_action_types/es_index.ts index b86f0029b5383..ff7b27b3f51fc 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/es_index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/es_index.ts @@ -18,7 +18,7 @@ export type ActionTypeConfigType = TypeOf; const ConfigSchema = schema.object({ index: schema.string(), refresh: schema.boolean({ defaultValue: false }), - executionTimeField: schema.maybe(schema.string()), + executionTimeField: schema.nullable(schema.string()), }); // params definition @@ -63,8 +63,9 @@ async function executor( const bulkBody = []; for (const document of params.documents) { - if (config.executionTimeField != null) { - document[config.executionTimeField] = new Date(); + const timeField = config.executionTimeField == null ? '' : config.executionTimeField.trim(); + if (timeField !== '') { + document[timeField] = new Date(); } bulkBody.push({ index: {} }); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss index 87ec3f8fc7ec1..2ba6f9baca90d 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss @@ -1,3 +1,8 @@ +.auaActionWizard__selectedActionFactoryContainer { + background-color: $euiColorLightestShade; + padding: $euiSize; +} + .auaActionWizard__actionFactoryItem { .euiKeyPadMenuItem__label { height: #{$euiSizeXL}; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx index 9c73f07289dc9..62f16890cade2 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx @@ -6,26 +6,28 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; -import { Demo, dashboardFactory, urlFactory } from './test_data'; +import { dashboardDrilldownActionFactory, Demo, urlDrilldownActionFactory } from './test_data'; storiesOf('components/ActionWizard', module) - .add('default', () => ) + .add('default', () => ( + + )) .add('Only one factory is available', () => ( // to make sure layout doesn't break - + )) .add('Long list of action factories', () => ( // to make sure layout doesn't break )); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx index cc56714fcb2f8..aea47be693b8f 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx @@ -8,14 +8,21 @@ import React from 'react'; import { cleanup, fireEvent, render } from '@testing-library/react/pure'; import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global import { TEST_SUBJ_ACTION_FACTORY_ITEM, TEST_SUBJ_SELECTED_ACTION_FACTORY } from './action_wizard'; -import { dashboardFactory, dashboards, Demo, urlFactory } from './test_data'; +import { + dashboardDrilldownActionFactory, + dashboards, + Demo, + urlDrilldownActionFactory, +} from './test_data'; // TODO: afterEach is not available for it globally during setup // https://github.com/elastic/kibana/issues/59469 afterEach(cleanup); test('Pick and configure action', () => { - const screen = render(); + const screen = render( + + ); // check that all factories are displayed to pick expect(screen.getAllByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).toHaveLength(2); @@ -40,7 +47,7 @@ test('Pick and configure action', () => { }); test('If only one actions factory is available then actionFactory selection is emitted without user input', () => { - const screen = render(); + const screen = render(); // check that no factories are displayed to pick from expect(screen.queryByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).not.toBeInTheDocument(); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx index 846f6d41eb30d..41ef863c00e44 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx @@ -16,23 +16,40 @@ import { } from '@elastic/eui'; import { txtChangeButton } from './i18n'; import './action_wizard.scss'; -import { ActionFactory } from '../../services'; -type ActionBaseConfig = object; -type ActionFactoryBaseContext = object; +// TODO: this interface is temporary for just moving forward with the component +// and it will be imported from the ../ui_actions when implemented properly +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type ActionBaseConfig = {}; +export interface ActionFactory { + type: string; // TODO: type should be tied to Action and ActionByType + displayName: string; + iconType?: string; + wizard: React.FC>; + createConfig: () => Config; + isValid: (config: Config) => boolean; +} + +export interface ActionFactoryWizardProps { + config?: Config; + + /** + * Callback called when user updates the config in UI. + */ + onConfig: (config: Config) => void; +} export interface ActionWizardProps { /** * List of available action factories */ - actionFactories: ActionFactory[]; + actionFactories: Array>; // any here to be able to pass array of ActionFactory with different configs /** * Currently selected action factory * undefined - is allowed and means that non is selected */ currentActionFactory?: ActionFactory; - /** * Action factory selected changed * null - means user click "change" and removed action factory selection @@ -48,11 +65,6 @@ export interface ActionWizardProps { * config changed */ onConfigChange: (config: ActionBaseConfig) => void; - - /** - * Context will be passed into ActionFactory's methods - */ - context: ActionFactoryBaseContext; } export const ActionWizard: React.FC = ({ @@ -61,7 +73,6 @@ export const ActionWizard: React.FC = ({ onActionFactoryChange, onConfigChange, config, - context, }) => { // auto pick action factory if there is only 1 available if (!currentActionFactory && actionFactories.length === 1) { @@ -76,7 +87,6 @@ export const ActionWizard: React.FC = ({ onDeselect={() => { onActionFactoryChange(null); }} - context={context} config={config} onConfigChange={newConfig => { onConfigChange(newConfig); @@ -87,7 +97,6 @@ export const ActionWizard: React.FC = ({ return ( { onActionFactoryChange(actionFactory); @@ -96,11 +105,10 @@ export const ActionWizard: React.FC = ({ ); }; -interface SelectedActionFactoryProps { - actionFactory: ActionFactory; - config: ActionBaseConfig; - context: ActionFactoryBaseContext; - onConfigChange: (config: ActionBaseConfig) => void; +interface SelectedActionFactoryProps { + actionFactory: ActionFactory; + config: Config; + onConfigChange: (config: Config) => void; showDeselect: boolean; onDeselect: () => void; } @@ -113,28 +121,28 @@ const SelectedActionFactory: React.FC = ({ showDeselect, onConfigChange, config, - context, }) => { return (
- {actionFactory.getIconType(context) && ( + {actionFactory.iconType && ( - + )} -

{actionFactory.getDisplayName(context)}

+

{actionFactory.displayName}

{showDeselect && ( - onDeselect()}> + onDeselect()}> {txtChangeButton} @@ -143,11 +151,10 @@ const SelectedActionFactory: React.FC = ({
- + {actionFactory.wizard({ + config, + onConfig: onConfigChange, + })}
); @@ -155,7 +162,6 @@ const SelectedActionFactory: React.FC = ({ interface ActionFactorySelectorProps { actionFactories: ActionFactory[]; - context: ActionFactoryBaseContext; onActionFactorySelected: (actionFactory: ActionFactory) => void; } @@ -164,7 +170,6 @@ export const TEST_SUBJ_ACTION_FACTORY_ITEM = 'action-factory-item'; const ActionFactorySelector: React.FC = ({ actionFactories, onActionFactorySelected, - context, }) => { if (actionFactories.length === 0) { // this is not user facing, as it would be impossible to get into this state @@ -173,23 +178,19 @@ const ActionFactorySelector: React.FC = ({ } return ( - - {[...actionFactories] - .sort((f1, f2) => f1.order - f2.order) - .map(actionFactory => ( - - onActionFactorySelected(actionFactory)} - > - {actionFactory.getIconType(context) && ( - - )} - - - ))} + + {actionFactories.map(actionFactory => ( + onActionFactorySelected(actionFactory)} + > + {actionFactory.iconType && } + + ))} ); }; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts index a315184bf68ef..641f25176264a 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts @@ -9,6 +9,6 @@ import { i18n } from '@kbn/i18n'; export const txtChangeButton = i18n.translate( 'xpack.advancedUiActions.components.actionWizard.changeButton', { - defaultMessage: 'Change', + defaultMessage: 'change', } ); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts index a189afbf956ee..ed224248ec4cd 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ActionWizard } from './action_wizard'; +export { ActionFactory, ActionWizard } from './action_wizard'; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx index 167cb130fdb4a..8ecdde681069e 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx @@ -6,161 +6,124 @@ import React, { useState } from 'react'; import { EuiFieldText, EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui'; -import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; -import { ActionWizard } from './action_wizard'; -import { ActionFactoryDefinition, ActionFactory } from '../../services'; -import { CollectConfigProps } from '../../util'; - -type ActionBaseConfig = object; +import { ActionFactory, ActionBaseConfig, ActionWizard } from './action_wizard'; export const dashboards = [ { id: 'dashboard1', title: 'Dashboard 1' }, { id: 'dashboard2', title: 'Dashboard 2' }, ]; -interface DashboardDrilldownConfig { +export const dashboardDrilldownActionFactory: ActionFactory<{ dashboardId?: string; - useCurrentFilters: boolean; - useCurrentDateRange: boolean; -} - -function DashboardDrilldownCollectConfig(props: CollectConfigProps) { - const config = props.config ?? { - dashboardId: undefined, - useCurrentFilters: true, - useCurrentDateRange: true, - }; - return ( - <> - - ({ value: id, text: title }))} - value={config.dashboardId} - onChange={e => { - props.onConfig({ ...config, dashboardId: e.target.value }); - }} - /> - - - - props.onConfig({ - ...config, - useCurrentFilters: !config.useCurrentFilters, - }) - } - /> - - - - props.onConfig({ - ...config, - useCurrentDateRange: !config.useCurrentDateRange, - }) - } - /> - - - ); -} - -export const dashboardDrilldownActionFactory: ActionFactoryDefinition< - DashboardDrilldownConfig, - any, - any -> = { - id: 'Dashboard', - getDisplayName: () => 'Go to Dashboard', - getIconType: () => 'dashboardApp', + useCurrentDashboardFilters: boolean; + useCurrentDashboardDataRange: boolean; +}> = { + type: 'Dashboard', + displayName: 'Go to Dashboard', + iconType: 'dashboardApp', createConfig: () => { return { dashboardId: undefined, - useCurrentFilters: true, - useCurrentDateRange: true, + useCurrentDashboardDataRange: true, + useCurrentDashboardFilters: true, }; }, - isConfigValid: (config: DashboardDrilldownConfig): config is DashboardDrilldownConfig => { + isValid: config => { if (!config.dashboardId) return false; return true; }, - CollectConfig: reactToUiComponent(DashboardDrilldownCollectConfig), - - isCompatible(context?: object): Promise { - return Promise.resolve(true); + wizard: props => { + const config = props.config ?? { + dashboardId: undefined, + useCurrentDashboardDataRange: true, + useCurrentDashboardFilters: true, + }; + return ( + <> + + ({ value: id, text: title }))} + value={config.dashboardId} + onChange={e => { + props.onConfig({ ...config, dashboardId: e.target.value }); + }} + /> + + + + props.onConfig({ + ...config, + useCurrentDashboardFilters: !config.useCurrentDashboardFilters, + }) + } + /> + + + + props.onConfig({ + ...config, + useCurrentDashboardDataRange: !config.useCurrentDashboardDataRange, + }) + } + /> + + + ); }, - order: 0, - create: () => ({ - id: 'test', - execute: async () => alert('Navigate to dashboard!'), - }), }; -export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory); - -interface UrlDrilldownConfig { - url: string; - openInNewTab: boolean; -} -function UrlDrilldownCollectConfig(props: CollectConfigProps) { - const config = props.config ?? { - url: '', - openInNewTab: false, - }; - return ( - <> - - props.onConfig({ ...config, url: event.target.value })} - /> - - - props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} - /> - - - ); -} -export const urlDrilldownActionFactory: ActionFactoryDefinition = { - id: 'Url', - getDisplayName: () => 'Go to URL', - getIconType: () => 'link', +export const urlDrilldownActionFactory: ActionFactory<{ url: string; openInNewTab: boolean }> = { + type: 'Url', + displayName: 'Go to URL', + iconType: 'link', createConfig: () => { return { url: '', openInNewTab: false, }; }, - isConfigValid: (config: UrlDrilldownConfig): config is UrlDrilldownConfig => { + isValid: config => { if (!config.url) return false; return true; }, - CollectConfig: reactToUiComponent(UrlDrilldownCollectConfig), - - order: 10, - isCompatible(context?: object): Promise { - return Promise.resolve(true); + wizard: props => { + const config = props.config ?? { + url: '', + openInNewTab: false, + }; + return ( + <> + + props.onConfig({ ...config, url: event.target.value })} + /> + + + props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} + /> + + + ); }, - create: () => null as any, }; -export const urlFactory = new ActionFactory(urlDrilldownActionFactory); - export function Demo({ actionFactories }: { actionFactories: Array> }) { const [state, setState] = useState<{ currentActionFactory?: ActionFactory; @@ -194,15 +157,14 @@ export function Demo({ actionFactories }: { actionFactories: Array

-
Action Factory Id: {state.currentActionFactory?.id}
+
Action Factory Type: {state.currentActionFactory?.type}
Action Factory Config: {JSON.stringify(state.config)}
Is config valid:{' '} - {JSON.stringify(state.currentActionFactory?.isConfigValid(state.config!) ?? false)} + {JSON.stringify(state.currentActionFactory?.isValid(state.config!) ?? false)}
); diff --git a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx index c0cd8d5540db2..325a5ddc10179 100644 --- a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx @@ -44,7 +44,7 @@ export class CustomTimeRangeAction implements ActionByType { + implements Plugin { constructor(initializerContext: PluginInitializerContext) {} - public setup(core: CoreSetup, { uiActions }: SetupDependencies): SetupContract { - return { - ...uiActions, - }; - } + public setup(core: CoreSetup, { uiActions }: SetupDependencies): Setup {} - public start(core: CoreStart, { uiActions }: StartDependencies): StartContract { + public start(core: CoreStart, { uiActions }: StartDependencies): Start { const dateFormat = core.uiSettings.get('dateFormat') as string; const commonlyUsedRanges = core.uiSettings.get('timepicker:quickRanges') as CommonlyUsedRange[]; const { openModal } = createReactOverlays(core); @@ -72,18 +66,16 @@ export class AdvancedUiActionsPublicPlugin dateFormat, commonlyUsedRanges, }); - uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, timeRangeAction); + uiActions.registerAction(timeRangeAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, timeRangeAction); const timeRangeBadge = new CustomTimeRangeBadge({ openModal, dateFormat, commonlyUsedRanges, }); - uiActions.addTriggerAction(PANEL_BADGE_TRIGGER, timeRangeBadge); - - return { - ...uiActions, - }; + uiActions.registerAction(timeRangeBadge); + uiActions.attachAction(PANEL_BADGE_TRIGGER, timeRangeBadge); } public stop() {} diff --git a/x-pack/plugins/advanced_ui_actions/public/util/index.ts b/x-pack/plugins/advanced_ui_actions/public/util/index.ts deleted file mode 100644 index fd3ab89973348..0000000000000 --- a/x-pack/plugins/advanced_ui_actions/public/util/index.ts +++ /dev/null @@ -1,10 +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. - */ - -export { - UiActionsConfigurable as Configurable, - UiActionsCollectConfigProps as CollectConfigProps, -} from '../../../../../src/plugins/ui_actions/public'; diff --git a/x-pack/plugins/alerting/common/index.ts b/x-pack/plugins/alerting/common/index.ts index b705a334bc2b5..9d4ea69a63609 100644 --- a/x-pack/plugins/alerting/common/index.ts +++ b/x-pack/plugins/alerting/common/index.ts @@ -10,4 +10,13 @@ export * from './alert_instance'; export * from './alert_task_instance'; export * from './alert_navigation'; +export interface ActionGroup { + id: string; + name: string; +} + +export interface AlertingFrameworkHealth { + isSufficientlySecure: boolean; +} + export const BASE_ALERT_API_PATH = '/api/alert'; diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index b33192ecc83b2..cae3424f8fac5 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -46,6 +46,7 @@ import { unmuteAllAlertRoute, muteAlertInstanceRoute, unmuteAlertInstanceRoute, + healthRoute, } from './routes'; import { LicensingPluginSetup } from '../../licensing/server'; import { @@ -171,6 +172,7 @@ export class AlertingPlugin { unmuteAllAlertRoute(router, this.licenseState); muteAlertInstanceRoute(router, this.licenseState); unmuteAlertInstanceRoute(router, this.licenseState); + healthRoute(router, this.licenseState); return { registerType: alertTypeRegistry.register.bind(alertTypeRegistry), diff --git a/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts b/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts index 9815ad5194af7..5a1d680eb06f3 100644 --- a/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts +++ b/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts @@ -10,13 +10,14 @@ import { httpServerMock } from '../../../../../src/core/server/mocks'; import { alertsClientMock } from '../alerts_client.mock'; export function mockHandlerArguments( - { alertsClient, listTypes: listTypesRes = [] }: any, + { alertsClient, listTypes: listTypesRes = [], elasticsearch }: any, req: any, res?: Array> ): [RequestHandlerContext, KibanaRequest, KibanaResponseFactory] { const listTypes = jest.fn(() => listTypesRes); return [ ({ + core: { elasticsearch }, alerting: { listTypes, getAlertsClient() { diff --git a/x-pack/plugins/alerting/server/routes/create.ts b/x-pack/plugins/alerting/server/routes/create.ts index 7e17a66e84547..f08460ffcb453 100644 --- a/x-pack/plugins/alerting/server/routes/create.ts +++ b/x-pack/plugins/alerting/server/routes/create.ts @@ -15,6 +15,7 @@ import { import { LicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { validateDurationSchema } from '../lib'; +import { handleDisabledApiKeysError } from './lib/error_handler'; import { Alert, BASE_ALERT_API_PATH } from '../types'; export const bodySchema = schema.object({ @@ -50,22 +51,24 @@ export const createAlertRoute = (router: IRouter, licenseState: LicenseState) => tags: ['access:alerting-all'], }, }, - router.handleLegacyErrors(async function( - context: RequestHandlerContext, - req: KibanaRequest, any>, - res: KibanaResponseFactory - ): Promise> { - verifyApiAccess(licenseState); + handleDisabledApiKeysError( + router.handleLegacyErrors(async function( + context: RequestHandlerContext, + req: KibanaRequest, any>, + res: KibanaResponseFactory + ): Promise> { + verifyApiAccess(licenseState); - if (!context.alerting) { - return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); - } - const alertsClient = context.alerting.getAlertsClient(); - const alert = req.body; - const alertRes: Alert = await alertsClient.create({ data: alert }); - return res.ok({ - body: alertRes, - }); - }) + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } + const alertsClient = context.alerting.getAlertsClient(); + const alert = req.body; + const alertRes: Alert = await alertsClient.create({ data: alert }); + return res.ok({ + body: alertRes, + }); + }) + ) ); }; diff --git a/x-pack/plugins/alerting/server/routes/enable.ts b/x-pack/plugins/alerting/server/routes/enable.ts index 9fb837e5074e8..2283ae4a4c765 100644 --- a/x-pack/plugins/alerting/server/routes/enable.ts +++ b/x-pack/plugins/alerting/server/routes/enable.ts @@ -15,6 +15,7 @@ import { import { LicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; +import { handleDisabledApiKeysError } from './lib/error_handler'; const paramSchema = schema.object({ id: schema.string(), @@ -31,19 +32,21 @@ export const enableAlertRoute = (router: IRouter, licenseState: LicenseState) => tags: ['access:alerting-all'], }, }, - router.handleLegacyErrors(async function( - context: RequestHandlerContext, - req: KibanaRequest, any, any, any>, - res: KibanaResponseFactory - ): Promise> { - verifyApiAccess(licenseState); - if (!context.alerting) { - return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); - } - const alertsClient = context.alerting.getAlertsClient(); - const { id } = req.params; - await alertsClient.enable({ id }); - return res.noContent(); - }) + handleDisabledApiKeysError( + router.handleLegacyErrors(async function( + context: RequestHandlerContext, + req: KibanaRequest, any, any, any>, + res: KibanaResponseFactory + ): Promise> { + verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } + const alertsClient = context.alerting.getAlertsClient(); + const { id } = req.params; + await alertsClient.enable({ id }); + return res.noContent(); + }) + ) ); }; diff --git a/x-pack/plugins/alerting/server/routes/health.test.ts b/x-pack/plugins/alerting/server/routes/health.test.ts new file mode 100644 index 0000000000000..9efe020bc10c4 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/health.test.ts @@ -0,0 +1,171 @@ +/* + * 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 { healthRoute } from './health'; +import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock'; +import { mockHandlerArguments } from './_mock_handler_arguments'; +import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; +import { verifyApiAccess } from '../lib/license_api_access'; +import { mockLicenseState } from '../lib/license_state.mock'; + +jest.mock('../lib/license_api_access.ts', () => ({ + verifyApiAccess: jest.fn(), +})); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('healthRoute', () => { + it('registers the route', async () => { + const router: RouterMock = mockRouter.create(); + + const licenseState = mockLicenseState(); + healthRoute(router, licenseState); + + const [config] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/api/alert/_health"`); + }); + + it('queries the usage api', async () => { + const router: RouterMock = mockRouter.create(); + + const licenseState = mockLicenseState(); + healthRoute(router, licenseState); + const [, handler] = router.get.mock.calls[0]; + + const elasticsearch = elasticsearchServiceMock.createSetup(); + elasticsearch.adminClient.callAsInternalUser.mockReturnValue(Promise.resolve({})); + + const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']); + + await handler(context, req, res); + + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + + expect(elasticsearch.adminClient.callAsInternalUser.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "transport.request", + Object { + "method": "GET", + "path": "/_xpack/usage", + }, + ] + `); + }); + + it('evaluates missing security info from the usage api to mean that the security plugin is disbled', async () => { + const router: RouterMock = mockRouter.create(); + + const licenseState = mockLicenseState(); + healthRoute(router, licenseState); + const [, handler] = router.get.mock.calls[0]; + + const elasticsearch = elasticsearchServiceMock.createSetup(); + elasticsearch.adminClient.callAsInternalUser.mockReturnValue(Promise.resolve({})); + + const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Object { + "isSufficientlySecure": true, + }, + } + `); + }); + + it('evaluates missing security http info from the usage api to mean that the security plugin is disbled', async () => { + const router: RouterMock = mockRouter.create(); + + const licenseState = mockLicenseState(); + healthRoute(router, licenseState); + const [, handler] = router.get.mock.calls[0]; + + const elasticsearch = elasticsearchServiceMock.createSetup(); + elasticsearch.adminClient.callAsInternalUser.mockReturnValue(Promise.resolve({ security: {} })); + + const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Object { + "isSufficientlySecure": true, + }, + } + `); + }); + + it('evaluates security enabled, and missing ssl info from the usage api to mean that the user cannot generate keys', async () => { + const router: RouterMock = mockRouter.create(); + + const licenseState = mockLicenseState(); + healthRoute(router, licenseState); + const [, handler] = router.get.mock.calls[0]; + + const elasticsearch = elasticsearchServiceMock.createSetup(); + elasticsearch.adminClient.callAsInternalUser.mockReturnValue( + Promise.resolve({ security: { enabled: true } }) + ); + + const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Object { + "isSufficientlySecure": false, + }, + } + `); + }); + + it('evaluates security enabled, SSL info present but missing http info from the usage api to mean that the user cannot generate keys', async () => { + const router: RouterMock = mockRouter.create(); + + const licenseState = mockLicenseState(); + healthRoute(router, licenseState); + const [, handler] = router.get.mock.calls[0]; + + const elasticsearch = elasticsearchServiceMock.createSetup(); + elasticsearch.adminClient.callAsInternalUser.mockReturnValue( + Promise.resolve({ security: { enabled: true, ssl: {} } }) + ); + + const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Object { + "isSufficientlySecure": false, + }, + } + `); + }); + + it('evaluates security and tls enabled to mean that the user can generate keys', async () => { + const router: RouterMock = mockRouter.create(); + + const licenseState = mockLicenseState(); + healthRoute(router, licenseState); + const [, handler] = router.get.mock.calls[0]; + + const elasticsearch = elasticsearchServiceMock.createSetup(); + elasticsearch.adminClient.callAsInternalUser.mockReturnValue( + Promise.resolve({ security: { enabled: true, ssl: { http: { enabled: true } } } }) + ); + + const [context, req, res] = mockHandlerArguments({ elasticsearch }, {}, ['ok']); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Object { + "isSufficientlySecure": true, + }, + } + `); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/health.ts b/x-pack/plugins/alerting/server/routes/health.ts new file mode 100644 index 0000000000000..29c2f3c5730f4 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/health.ts @@ -0,0 +1,67 @@ +/* + * 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, + RequestHandlerContext, + KibanaRequest, + IKibanaResponse, + KibanaResponseFactory, +} from 'kibana/server'; +import { LicenseState } from '../lib/license_state'; +import { verifyApiAccess } from '../lib/license_api_access'; +import { AlertingFrameworkHealth } from '../types'; + +interface XPackUsageSecurity { + security?: { + enabled?: boolean; + ssl?: { + http?: { + enabled?: boolean; + }; + }; + }; +} + +export function healthRoute(router: IRouter, licenseState: LicenseState) { + router.get( + { + path: '/api/alert/_health', + validate: false, + }, + router.handleLegacyErrors(async function( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + verifyApiAccess(licenseState); + try { + const { + security: { + enabled: isSecurityEnabled = false, + ssl: { http: { enabled: isTLSEnabled = false } = {} } = {}, + } = {}, + }: XPackUsageSecurity = await context.core.elasticsearch.adminClient + // `transport.request` is potentially unsafe when combined with untrusted user input. + // Do not augment with such input. + .callAsInternalUser('transport.request', { + method: 'GET', + path: '/_xpack/usage', + }); + + const frameworkHealth: AlertingFrameworkHealth = { + isSufficientlySecure: !isSecurityEnabled || (isSecurityEnabled && isTLSEnabled), + }; + + return res.ok({ + body: frameworkHealth, + }); + } catch (error) { + return res.badRequest({ body: error }); + } + }) + ); +} diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts index 7ec901ae685c4..f833a29c67bb9 100644 --- a/x-pack/plugins/alerting/server/routes/index.ts +++ b/x-pack/plugins/alerting/server/routes/index.ts @@ -18,3 +18,4 @@ export { muteAlertInstanceRoute } from './mute_instance'; export { unmuteAlertInstanceRoute } from './unmute_instance'; export { muteAllAlertRoute } from './mute_all'; export { unmuteAllAlertRoute } from './unmute_all'; +export { healthRoute } from './health'; diff --git a/x-pack/plugins/alerting/server/routes/lib/error_handler.ts b/x-pack/plugins/alerting/server/routes/lib/error_handler.ts new file mode 100644 index 0000000000000..b3cf48c52fe17 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/lib/error_handler.ts @@ -0,0 +1,47 @@ +/* + * 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 { + RequestHandler, + KibanaRequest, + KibanaResponseFactory, + RequestHandlerContext, + RouteMethod, +} from 'kibana/server'; + +export function handleDisabledApiKeysError( + handler: RequestHandler +): RequestHandler { + return async ( + context: RequestHandlerContext, + request: KibanaRequest, + response: KibanaResponseFactory + ) => { + try { + return await handler(context, request, response); + } catch (e) { + if (isApiKeyDisabledError(e)) { + return response.badRequest({ + body: new Error( + i18n.translate('xpack.alerting.api.error.disabledApiKeys', { + defaultMessage: 'Alerting relies upon API keys which appear to be disabled', + }) + ), + }); + } + throw e; + } + }; +} + +export function isApiKeyDisabledError(e: Error) { + return e?.message?.includes('api keys are not enabled') ?? false; +} + +export function isSecurityPluginDisabledError(e: Error) { + return e?.message?.includes('no handler found') ?? false; +} diff --git a/x-pack/plugins/alerting/server/routes/update.ts b/x-pack/plugins/alerting/server/routes/update.ts index 26a8320fffebb..45f7b26b521d4 100644 --- a/x-pack/plugins/alerting/server/routes/update.ts +++ b/x-pack/plugins/alerting/server/routes/update.ts @@ -15,6 +15,7 @@ import { import { LicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { validateDurationSchema } from '../lib'; +import { handleDisabledApiKeysError } from './lib/error_handler'; import { BASE_ALERT_API_PATH } from '../../common'; const paramSchema = schema.object({ @@ -52,24 +53,26 @@ export const updateAlertRoute = (router: IRouter, licenseState: LicenseState) => tags: ['access:alerting-all'], }, }, - router.handleLegacyErrors(async function( - context: RequestHandlerContext, - req: KibanaRequest, any, TypeOf, any>, - res: KibanaResponseFactory - ): Promise> { - verifyApiAccess(licenseState); - if (!context.alerting) { - return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); - } - const alertsClient = context.alerting.getAlertsClient(); - const { id } = req.params; - const { name, actions, params, schedule, tags, throttle } = req.body; - return res.ok({ - body: await alertsClient.update({ - id, - data: { name, actions, params, schedule, tags, throttle }, - }), - }); - }) + handleDisabledApiKeysError( + router.handleLegacyErrors(async function( + context: RequestHandlerContext, + req: KibanaRequest, any, TypeOf, any>, + res: KibanaResponseFactory + ): Promise> { + verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } + const alertsClient = context.alerting.getAlertsClient(); + const { id } = req.params; + const { name, actions, params, schedule, tags, throttle } = req.body; + return res.ok({ + body: await alertsClient.update({ + id, + data: { name, actions, params, schedule, tags, throttle }, + }), + }); + }) + ) ); }; diff --git a/x-pack/plugins/alerting/server/routes/update_api_key.ts b/x-pack/plugins/alerting/server/routes/update_api_key.ts index 62c1b1510ddac..f70d30f0bb5da 100644 --- a/x-pack/plugins/alerting/server/routes/update_api_key.ts +++ b/x-pack/plugins/alerting/server/routes/update_api_key.ts @@ -15,6 +15,7 @@ import { import { LicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; +import { handleDisabledApiKeysError } from './lib/error_handler'; const paramSchema = schema.object({ id: schema.string(), @@ -31,19 +32,21 @@ export const updateApiKeyRoute = (router: IRouter, licenseState: LicenseState) = tags: ['access:alerting-all'], }, }, - router.handleLegacyErrors(async function( - context: RequestHandlerContext, - req: KibanaRequest, any, any, any>, - res: KibanaResponseFactory - ): Promise> { - verifyApiAccess(licenseState); - if (!context.alerting) { - return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); - } - const alertsClient = context.alerting.getAlertsClient(); - const { id } = req.params; - await alertsClient.updateApiKey({ id }); - return res.noContent(); - }) + handleDisabledApiKeysError( + router.handleLegacyErrors(async function( + context: RequestHandlerContext, + req: KibanaRequest, any, any, any>, + res: KibanaResponseFactory + ): Promise> { + verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } + const alertsClient = context.alerting.getAlertsClient(); + const { id } = req.params; + await alertsClient.updateApiKey({ id }); + return res.noContent(); + }) + ) ); }; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index b6eb40305dae7..b83c03c543295 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -64,24 +64,6 @@ export const generalSettings: RawSettingDefinition[] = [ excludeAgents: ['js-base', 'rum-js', 'dotnet'] }, - // Capture headers - { - key: 'capture_headers', - type: 'boolean', - defaultValue: 'true', - label: i18n.translate('xpack.apm.agentConfig.captureHeaders.label', { - defaultMessage: 'Capture Headers' - }), - description: i18n.translate( - 'xpack.apm.agentConfig.captureHeaders.description', - { - defaultMessage: - 'If set to `true`, the agent will capture request and response headers, including cookies.\n\nNOTE: Setting this to `false` reduces network bandwidth, disk space and object allocations.' - } - ), - excludeAgents: ['js-base', 'rum-js'] - }, - // Capture body { key: 'capture_body', @@ -104,7 +86,25 @@ export const generalSettings: RawSettingDefinition[] = [ { text: 'transactions' }, { text: 'all' } ], - excludeAgents: ['js-base', 'rum-js', 'dotnet'] + excludeAgents: ['js-base', 'rum-js'] + }, + + // Capture headers + { + key: 'capture_headers', + type: 'boolean', + defaultValue: 'true', + label: i18n.translate('xpack.apm.agentConfig.captureHeaders.label', { + defaultMessage: 'Capture Headers' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.captureHeaders.description', + { + defaultMessage: + 'If set to `true`, the agent will capture request and response headers, including cookies.\n\nNOTE: Setting this to `false` reduces network bandwidth, disk space and object allocations.' + } + ), + excludeAgents: ['js-base', 'rum-js'] }, // LOG_LEVEL @@ -175,23 +175,6 @@ export const generalSettings: RawSettingDefinition[] = [ includeAgents: ['nodejs', 'java', 'dotnet', 'go'] }, - // Transaction sample rate - { - key: 'transaction_sample_rate', - type: 'float', - defaultValue: '1.0', - label: i18n.translate('xpack.apm.agentConfig.transactionSampleRate.label', { - defaultMessage: 'Transaction sample rate' - }), - description: i18n.translate( - 'xpack.apm.agentConfig.transactionSampleRate.description', - { - defaultMessage: - 'By default, the agent will sample every transaction (e.g. request to your service). To reduce overhead and storage requirements, you can set the sample rate to a value between 0.0 and 1.0. We still record overall time and the result for unsampled transactions, but no context information, labels, or spans.' - } - ) - }, - // Transaction max spans { key: 'transaction_max_spans', @@ -215,5 +198,22 @@ export const generalSettings: RawSettingDefinition[] = [ min: 0, max: 32000, excludeAgents: ['js-base', 'rum-js'] + }, + + // Transaction sample rate + { + key: 'transaction_sample_rate', + type: 'float', + defaultValue: '1.0', + label: i18n.translate('xpack.apm.agentConfig.transactionSampleRate.label', { + defaultMessage: 'Transaction sample rate' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.transactionSampleRate.description', + { + defaultMessage: + 'By default, the agent will sample every transaction (e.g. request to your service). To reduce overhead and storage requirements, you can set the sample rate to a value between 0.0 and 1.0. We still record overall time and the result for unsampled transactions, but no context information, labels, or spans.' + } + ) } ]; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts index 0d1113d74c98b..fe55442324c92 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts @@ -127,6 +127,7 @@ describe('filterByAgent', () => { it('dotnet', () => { expect(getSettingKeysForAgent('dotnet')).toEqual([ + 'capture_body', 'capture_headers', 'log_level', 'span_frames_min_duration', @@ -152,6 +153,7 @@ describe('filterByAgent', () => { it('"All" services (no agent name)', () => { expect(getSettingKeysForAgent(undefined)).toEqual([ + 'capture_body', 'capture_headers', 'transaction_max_spans', 'transaction_sample_rate' diff --git a/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.ts b/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.ts index d256f657bb778..6a433367d8217 100644 --- a/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.ts +++ b/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.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 { isEqual, sortBy } from 'lodash'; +import { sortBy, pick, identity } from 'lodash'; import { ValuesType } from 'utility-types'; import { SERVICE_NAME, @@ -72,24 +72,35 @@ export function dedupeConnections(response: ServiceMapResponse) { return map; } - const service = - discoveredServices.find(({ from }) => { - if ('span.destination.service.resource' in node) { - return ( - node[SPAN_DESTINATION_SERVICE_RESOURCE] === - from[SPAN_DESTINATION_SERVICE_RESOURCE] - ); - } - return false; - })?.to ?? serviceNodes.find(serviceNode => serviceNode.id === node.id); - - if (service) { + const matchedService = discoveredServices.find(({ from }) => { + if ('span.destination.service.resource' in node) { + return ( + node[SPAN_DESTINATION_SERVICE_RESOURCE] === + from[SPAN_DESTINATION_SERVICE_RESOURCE] + ); + } + return false; + })?.to; + + let serviceName: string | undefined = matchedService?.[SERVICE_NAME]; + + if (!serviceName && 'service.name' in node) { + serviceName = node[SERVICE_NAME]; + } + + const matchedServiceNodes = services.filter( + serviceNode => serviceNode[SERVICE_NAME] === serviceName + ); + + if (matchedServiceNodes.length) { return { ...map, - [node.id]: { - id: service[SERVICE_NAME], - ...service - } + [node.id]: Object.assign( + { + id: matchedServiceNodes[0][SERVICE_NAME] + }, + ...matchedServiceNodes.map(serviceNode => pick(serviceNode, identity)) + ) }; } @@ -138,7 +149,7 @@ export function dedupeConnections(response: ServiceMapResponse) { const dedupedNodes: typeof nodes = []; nodes.forEach(node => { - if (!dedupedNodes.find(dedupedNode => isEqual(node, dedupedNode))) { + if (!dedupedNodes.find(dedupedNode => node.id === dedupedNode.id)) { dedupedNodes.push(node); } }); diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts index 1414f743e8a03..17b595385a84e 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts @@ -13,17 +13,13 @@ import { import { getServicesProjection } from '../../../common/projections/services'; import { mergeProjection } from '../../../common/projections/util/merge_projection'; import { PromiseReturnType } from '../../../typings/common'; -import { - Setup, - SetupTimeRange, - SetupUIFilters -} from '../helpers/setup_request'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { dedupeConnections } from './dedupe_connections'; import { getServiceMapFromTraceIds } from './get_service_map_from_trace_ids'; import { getTraceSampleIds } from './get_trace_sample_ids'; export interface IEnvOptions { - setup: Setup & SetupTimeRange & SetupUIFilters; + setup: Setup & SetupTimeRange; serviceName?: string; environment?: string; } @@ -77,7 +73,9 @@ async function getConnectionData({ async function getServicesData(options: IEnvOptions) { const { setup } = options; - const projection = getServicesProjection({ setup }); + const projection = getServicesProjection({ + setup: { ...setup, uiFiltersES: [] } + }); const { filter } = projection.body.query.bool; diff --git a/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts b/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts index 9cb1a61e1d76f..5e2ab82239d9f 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts @@ -4,11 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { uniq, take, sortBy } from 'lodash'; -import { - Setup, - SetupUIFilters, - SetupTimeRange -} from '../helpers/setup_request'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { rangeFilter } from '../helpers/range_filter'; import { ESFilter } from '../../../typings/elasticsearch'; import { @@ -28,7 +24,7 @@ export async function getTraceSampleIds({ }: { serviceName?: string; environment?: string; - setup: Setup & SetupTimeRange & SetupUIFilters; + setup: Setup & SetupTimeRange; }) { const { start, end, client, indices, config } = setup; diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index a61a61e3ccaac..6838717cbc6da 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -14,7 +14,7 @@ import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceMap } from '../lib/service_map/get_service_map'; import { getServiceMapServiceNodeInfo } from '../lib/service_map/get_service_map_service_node_info'; import { createRoute } from './create_route'; -import { rangeRt, uiFiltersRt } from './default_api_types'; +import { rangeRt } from './default_api_types'; export const serviceMapRoute = createRoute(() => ({ path: '/api/apm/service-map', @@ -24,7 +24,6 @@ export const serviceMapRoute = createRoute(() => ({ environment: t.string, serviceName: t.string }), - uiFiltersRt, rangeRt ]) }, diff --git a/x-pack/plugins/canvas/server/plugin.ts b/x-pack/plugins/canvas/server/plugin.ts index bfda7ef5885bc..0325de9cf29e2 100644 --- a/x-pack/plugins/canvas/server/plugin.ts +++ b/x-pack/plugins/canvas/server/plugin.ts @@ -32,12 +32,15 @@ export class CanvasPlugin implements Plugin { plugins.features.registerFeature({ id: 'canvas', name: 'Canvas', + order: 400, icon: 'canvasApp', navLinkId: 'canvas', app: ['canvas', 'kibana'], catalogue: ['canvas'], privileges: { all: { + app: ['canvas', 'kibana'], + catalogue: ['canvas'], savedObject: { all: ['canvas-workpad', 'canvas-element'], read: ['index-pattern'], @@ -45,6 +48,8 @@ export class CanvasPlugin implements Plugin { ui: ['save', 'show'], }, read: { + app: ['canvas', 'kibana'], + catalogue: ['canvas'], savedObject: { all: [], read: ['index-pattern', 'canvas-workpad', 'canvas-element'], diff --git a/x-pack/plugins/console_extensions/server/spec/generated/async_search.delete.json b/x-pack/plugins/console_extensions/server/spec/generated/async_search.delete.json new file mode 100644 index 0000000000000..a0be8f05e7722 --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/async_search.delete.json @@ -0,0 +1,11 @@ +{ + "async_search.delete": { + "methods": [ + "DELETE" + ], + "patterns": [ + "_async_search/{id}" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/async-search.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/async_search.get.json b/x-pack/plugins/console_extensions/server/spec/generated/async_search.get.json new file mode 100644 index 0000000000000..09f4520d580e3 --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/async_search.get.json @@ -0,0 +1,16 @@ +{ + "async_search.get": { + "url_params": { + "wait_for_completion": "", + "keep_alive": "", + "typed_keys": "__flag__" + }, + "methods": [ + "GET" + ], + "patterns": [ + "_async_search/{id}" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/async-search.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/async_search.submit.json b/x-pack/plugins/console_extensions/server/spec/generated/async_search.submit.json new file mode 100644 index 0000000000000..83fb7c0fe75ad --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/async_search.submit.json @@ -0,0 +1,70 @@ +{ + "async_search.submit": { + "url_params": { + "wait_for_completion": "", + "clean_on_completion": "__flag__", + "keep_alive": "", + "batched_reduce_size": "", + "request_cache": "__flag__", + "analyzer": "", + "analyze_wildcard": "__flag__", + "default_operator": [ + "AND", + "OR" + ], + "df": "", + "explain": "__flag__", + "stored_fields": [], + "docvalue_fields": [], + "from": "0", + "ignore_unavailable": "__flag__", + "ignore_throttled": "__flag__", + "allow_no_indices": "__flag__", + "expand_wildcards": [ + "open", + "closed", + "none", + "all" + ], + "lenient": "__flag__", + "preference": "random", + "q": "", + "routing": [], + "search_type": [ + "query_then_fetch", + "dfs_query_then_fetch" + ], + "size": "10", + "sort": [], + "_source": [], + "_source_excludes": [], + "_source_includes": [], + "terminate_after": "", + "stats": [], + "suggest_field": "", + "suggest_mode": [ + "missing", + "popular", + "always" + ], + "suggest_size": "", + "suggest_text": "", + "timeout": "", + "track_scores": "__flag__", + "track_total_hits": "__flag__", + "allow_partial_search_results": "__flag__", + "typed_keys": "__flag__", + "version": "__flag__", + "seq_no_primary_term": "__flag__", + "max_concurrent_shard_requests": "" + }, + "methods": [ + "POST" + ], + "patterns": [ + "_async_search", + "{indices}/_async_search" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/async-search.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/autoscaling.get_autoscaling_decision.json b/x-pack/plugins/console_extensions/server/spec/generated/autoscaling.get_autoscaling_decision.json new file mode 100644 index 0000000000000..241075f4ca538 --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/autoscaling.get_autoscaling_decision.json @@ -0,0 +1,11 @@ +{ + "autoscaling.get_autoscaling_decision": { + "methods": [ + "GET" + ], + "patterns": [ + "_autoscaling/decision" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/autoscaling-get-autoscaling-decision.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_data_frame_analytics.json b/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_data_frame_analytics.json new file mode 100644 index 0000000000000..e2ddaefd87dea --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_data_frame_analytics.json @@ -0,0 +1,42 @@ +{ + "cat.ml_data_frame_analytics": { + "url_params": { + "allow_no_match": "__flag__", + "bytes": [ + "b", + "k", + "kb", + "m", + "mb", + "g", + "gb", + "t", + "tb", + "p", + "pb" + ], + "format": "", + "h": [], + "help": "__flag__", + "s": [], + "time": [ + "d", + "h", + "m", + "s", + "ms", + "micros", + "nanos" + ], + "v": "__flag__" + }, + "methods": [ + "GET" + ], + "patterns": [ + "_cat/ml/data_frame/analytics", + "_cat/ml/data_frame/analytics/{id}" + ], + "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/cat-dfanalytics.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_datafeeds.json b/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_datafeeds.json new file mode 100644 index 0000000000000..04f4e45782e1f --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_datafeeds.json @@ -0,0 +1,29 @@ +{ + "cat.ml_datafeeds": { + "url_params": { + "allow_no_datafeeds": "__flag__", + "format": "", + "h": [], + "help": "__flag__", + "s": [], + "time": [ + "d", + "h", + "m", + "s", + "ms", + "micros", + "nanos" + ], + "v": "__flag__" + }, + "methods": [ + "GET" + ], + "patterns": [ + "_cat/ml/datafeeds", + "_cat/ml/datafeeds/{datafeed_id}" + ], + "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/cat-datafeeds.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_jobs.json b/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_jobs.json new file mode 100644 index 0000000000000..2f7e03e564b5d --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_jobs.json @@ -0,0 +1,42 @@ +{ + "cat.ml_jobs": { + "url_params": { + "allow_no_jobs": "__flag__", + "bytes": [ + "b", + "k", + "kb", + "m", + "mb", + "g", + "gb", + "t", + "tb", + "p", + "pb" + ], + "format": "", + "h": [], + "help": "__flag__", + "s": [], + "time": [ + "d", + "h", + "m", + "s", + "ms", + "micros", + "nanos" + ], + "v": "__flag__" + }, + "methods": [ + "GET" + ], + "patterns": [ + "_cat/ml/anomaly_detectors", + "_cat/ml/anomaly_detectors/{job_id}" + ], + "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/cat-anomaly-detectors.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_trained_models.json b/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_trained_models.json new file mode 100644 index 0000000000000..9ff12e8bf6c57 --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/cat.ml_trained_models.json @@ -0,0 +1,44 @@ +{ + "cat.ml_trained_models": { + "url_params": { + "allow_no_match": "__flag__", + "from": 0, + "size": 0, + "bytes": [ + "b", + "k", + "kb", + "m", + "mb", + "g", + "gb", + "t", + "tb", + "p", + "pb" + ], + "format": "", + "h": [], + "help": "__flag__", + "s": [], + "time": [ + "d", + "h", + "m", + "s", + "ms", + "micros", + "nanos" + ], + "v": "__flag__" + }, + "methods": [ + "GET" + ], + "patterns": [ + "_cat/ml/trained_models", + "_cat/ml/trained_models/{model_id}" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/cat-trained-model.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ccr.forget_follower.json b/x-pack/plugins/console_extensions/server/spec/generated/ccr.forget_follower.json index aa9a42c54dff4..f2aabe9ef4257 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ccr.forget_follower.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ccr.forget_follower.json @@ -6,6 +6,6 @@ "patterns": [ "{indices}/_ccr/forget_follower" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ccr-post-forget-follower.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ccr.unfollow.json b/x-pack/plugins/console_extensions/server/spec/generated/ccr.unfollow.json index 92759d8222c63..37530bf373c42 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ccr.unfollow.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ccr.unfollow.json @@ -6,6 +6,6 @@ "patterns": [ "{indices}/_ccr/unfollow" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ccr-post-unfollow.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/enrich.delete_policy.json b/x-pack/plugins/console_extensions/server/spec/generated/enrich.delete_policy.json index 3d3c40fa093a4..d7615779bc566 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/enrich.delete_policy.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/enrich.delete_policy.json @@ -5,6 +5,7 @@ ], "patterns": [ "_enrich/policy/{name}" - ] + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/delete-enrich-policy-api.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/enrich.execute_policy.json b/x-pack/plugins/console_extensions/server/spec/generated/enrich.execute_policy.json index 542b709c08ec6..a7d6d99753c2e 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/enrich.execute_policy.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/enrich.execute_policy.json @@ -8,6 +8,7 @@ ], "patterns": [ "_enrich/policy/{name}/_execute" - ] + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/execute-enrich-policy-api.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/enrich.get_policy.json b/x-pack/plugins/console_extensions/server/spec/generated/enrich.get_policy.json index b59bf72670b6d..9b91d899d099f 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/enrich.get_policy.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/enrich.get_policy.json @@ -6,6 +6,7 @@ "patterns": [ "_enrich/policy/{name}", "_enrich/policy" - ] + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/get-enrich-policy-api.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/enrich.put_policy.json b/x-pack/plugins/console_extensions/server/spec/generated/enrich.put_policy.json index 96d854f04dcfc..5ff0ab55aef80 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/enrich.put_policy.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/enrich.put_policy.json @@ -5,6 +5,7 @@ ], "patterns": [ "_enrich/policy/{name}" - ] + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/put-enrich-policy-api.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/enrich.stats.json b/x-pack/plugins/console_extensions/server/spec/generated/enrich.stats.json index e6d1b04d63e45..6cdd037a21216 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/enrich.stats.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/enrich.stats.json @@ -5,6 +5,7 @@ ], "patterns": [ "_enrich/_stats" - ] + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/enrich-stats-api.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/migration.deprecations.json b/x-pack/plugins/console_extensions/server/spec/generated/migration.deprecations.json index dce5244ea40ac..597791a2439c2 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/migration.deprecations.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/migration.deprecations.json @@ -7,6 +7,6 @@ "_migration/deprecations", "{indices}/_migration/deprecations" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/migration-api-deprecation.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/migration-api-deprecation.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.close_job.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.close_job.json index 310b0d125b1f9..b0f2c6489b30e 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.close_job.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.close_job.json @@ -11,6 +11,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/_close" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-close-job.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-close-job.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_data_frame_analytics.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_data_frame_analytics.json index c3d7048406ef6..2e4593f339212 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_data_frame_analytics.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_data_frame_analytics.json @@ -9,6 +9,6 @@ "patterns": [ "_ml/data_frame/analytics/{id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/delete-dfanalytics.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/delete-dfanalytics.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_datafeed.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_datafeed.json index 7c7f3c40f23bb..0836a844eb0f5 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_datafeed.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_datafeed.json @@ -9,6 +9,6 @@ "patterns": [ "_ml/datafeeds/{datafeed_id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-delete-datafeed.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-delete-datafeed.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_forecast.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_forecast.json index 971a761cc77e9..acaddfba74338 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_forecast.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_forecast.json @@ -11,6 +11,6 @@ "_ml/anomaly_detectors/{job_id}/_forecast", "_ml/anomaly_detectors/{job_id}/_forecast/{forecast_id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-delete-forecast.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-delete-forecast.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_job.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_job.json index ab518071bf765..aa79a4c195ebe 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_job.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_job.json @@ -10,6 +10,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-delete-job.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-delete-job.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_model_snapshot.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_model_snapshot.json index 53d45bf0498ab..af4a7a6d68498 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_model_snapshot.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.delete_model_snapshot.json @@ -6,6 +6,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/model_snapshots/{snapshot_id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-delete-snapshot.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-delete-snapshot.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.estimate_memory_usage.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.estimate_memory_usage.json deleted file mode 100644 index 2195b74640c79..0000000000000 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.estimate_memory_usage.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "ml.estimate_memory_usage": { - "methods": [ - "PUT" - ], - "patterns": [ - "_ml/data_frame/analytics/_estimate_memory_usage" - ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/estimate-memory-usage-dfanalytics.html" - } -} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.evaluate_data_frame.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.evaluate_data_frame.json index c4523a8b41604..40f913383424d 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.evaluate_data_frame.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.evaluate_data_frame.json @@ -6,6 +6,6 @@ "patterns": [ "_ml/data_frame/_evaluate" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/evaluate-dfanalytics.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/evaluate-dfanalytics.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.find_file_structure.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.find_file_structure.json index ec51a62c4f901..6e7163ae2b740 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.find_file_structure.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.find_file_structure.json @@ -27,6 +27,6 @@ "patterns": [ "_ml/find_file_structure" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-find-file-structure.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-find-file-structure.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.flush_job.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.flush_job.json index 2f496003a2834..38f8cd7e9b90b 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.flush_job.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.flush_job.json @@ -13,6 +13,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/_flush" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-flush-job.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-flush-job.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_buckets.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_buckets.json index 2cbcb9d6155ec..b7c864064496e 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_buckets.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_buckets.json @@ -19,6 +19,6 @@ "_ml/anomaly_detectors/{job_id}/results/buckets/{timestamp}", "_ml/anomaly_detectors/{job_id}/results/buckets" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-bucket.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-bucket.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_categories.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_categories.json index 357a7b7fb0ccc..64edb196bb366 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_categories.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_categories.json @@ -12,6 +12,6 @@ "_ml/anomaly_detectors/{job_id}/results/categories/{category_id}", "_ml/anomaly_detectors/{job_id}/results/categories/" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-category.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-category.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_data_frame_analytics.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_data_frame_analytics.json index b3a0c9cf3ef71..ecccec9c7e059 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_data_frame_analytics.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_data_frame_analytics.json @@ -12,6 +12,6 @@ "_ml/data_frame/analytics/{id}", "_ml/data_frame/analytics" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/get-dfanalytics.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/get-dfanalytics.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_data_frame_analytics_stats.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_data_frame_analytics_stats.json index e4b4ee7b1f64e..3ae103f79f798 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_data_frame_analytics_stats.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_data_frame_analytics_stats.json @@ -12,6 +12,6 @@ "_ml/data_frame/analytics/_stats", "_ml/data_frame/analytics/{id}/_stats" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/get-dfanalytics-stats.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/get-dfanalytics-stats.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_datafeed_stats.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_datafeed_stats.json index 5c300e444c794..2971b8a7f6c63 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_datafeed_stats.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_datafeed_stats.json @@ -10,6 +10,6 @@ "_ml/datafeeds/{datafeed_id}/_stats", "_ml/datafeeds/_stats" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-datafeed-stats.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-datafeed-stats.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_datafeeds.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_datafeeds.json index 9979a685426be..deeb81d692739 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_datafeeds.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_datafeeds.json @@ -10,6 +10,6 @@ "_ml/datafeeds/{datafeed_id}", "_ml/datafeeds" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-datafeed.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-datafeed.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_influencers.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_influencers.json index 9471fac64d489..6f6745d3a5472 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_influencers.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_influencers.json @@ -17,6 +17,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/results/influencers" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-influencer.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-influencer.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_job_stats.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_job_stats.json index b28a2655cbefe..6173b3ebdc6d0 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_job_stats.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_job_stats.json @@ -10,6 +10,6 @@ "_ml/anomaly_detectors/_stats", "_ml/anomaly_detectors/{job_id}/_stats" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-job-stats.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-job-stats.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_jobs.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_jobs.json index 8f7de906578d7..2486684424670 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_jobs.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_jobs.json @@ -10,6 +10,6 @@ "_ml/anomaly_detectors/{job_id}", "_ml/anomaly_detectors" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-job.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-job.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_model_snapshots.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_model_snapshots.json index a3b9702f4e4f0..19a61afc9e0e3 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_model_snapshots.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_model_snapshots.json @@ -16,6 +16,6 @@ "_ml/anomaly_detectors/{job_id}/model_snapshots/{snapshot_id}", "_ml/anomaly_detectors/{job_id}/model_snapshots" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-snapshot.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-snapshot.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_overall_buckets.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_overall_buckets.json index e89d63ae7f49f..3a88c9d8ab9c9 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_overall_buckets.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_overall_buckets.json @@ -16,6 +16,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/results/overall_buckets" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-overall-buckets.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-overall-buckets.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_records.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_records.json index fd03c8d34214c..6ad8ecb6f7d6b 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_records.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_records.json @@ -17,6 +17,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/results/records" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-record.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-get-record.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_trained_models.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_trained_models.json index cdeaca9654b77..76598ee015c6d 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.get_trained_models.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.get_trained_models.json @@ -5,7 +5,8 @@ "include_model_definition": "__flag__", "decompress_definition": "__flag__", "from": 0, - "size": 0 + "size": 0, + "tags": [] }, "methods": [ "GET" diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.open_job.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.open_job.json index cd330ec4822c0..969da2253cc89 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.open_job.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.open_job.json @@ -6,6 +6,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/_open" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-open-job.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-open-job.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.post_data.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.post_data.json index cc6f0b658e111..512d258f52780 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.post_data.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.post_data.json @@ -10,6 +10,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/_data" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-post-data.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-post-data.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.preview_datafeed.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.preview_datafeed.json index be3c3d466f37d..6eb537804134b 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.preview_datafeed.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.preview_datafeed.json @@ -6,6 +6,6 @@ "patterns": [ "_ml/datafeeds/{datafeed_id}/_preview" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-preview-datafeed.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-preview-datafeed.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.put_data_frame_analytics.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.put_data_frame_analytics.json index 83ffd0da3dda5..fd00ff3a94ebd 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.put_data_frame_analytics.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.put_data_frame_analytics.json @@ -6,6 +6,6 @@ "patterns": [ "_ml/data_frame/analytics/{id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/put-dfanalytics.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/put-dfanalytics.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.put_datafeed.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.put_datafeed.json index a61f9ab465724..302599b1633f4 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.put_datafeed.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.put_datafeed.json @@ -1,11 +1,23 @@ { "ml.put_datafeed": { + "url_params": { + "ignore_unavailable": "__flag__", + "allow_no_indices": "__flag__", + "ignore_throttled": "__flag__", + "expand_wildcards": [ + "open", + "closed", + "hidden", + "none", + "all" + ] + }, "methods": [ "PUT" ], "patterns": [ "_ml/datafeeds/{datafeed_id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-put-datafeed.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-put-datafeed.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.put_job.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.put_job.json index d8e38a0bd4b9d..7a48994bd1a6c 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.put_job.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.put_job.json @@ -6,6 +6,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-put-job.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-put-job.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.revert_model_snapshot.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.revert_model_snapshot.json index 7b6d74d47a711..b0763d8a9b329 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.revert_model_snapshot.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.revert_model_snapshot.json @@ -9,6 +9,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/model_snapshots/{snapshot_id}/_revert" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-revert-snapshot.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-revert-snapshot.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.set_upgrade_mode.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.set_upgrade_mode.json index d46e93c6eee46..71a0f0c042813 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.set_upgrade_mode.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.set_upgrade_mode.json @@ -10,6 +10,6 @@ "patterns": [ "_ml/set_upgrade_mode" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-set-upgrade-mode.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-set-upgrade-mode.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.start_data_frame_analytics.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.start_data_frame_analytics.json index 1b5d7c122fc53..0b420733cd9de 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.start_data_frame_analytics.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.start_data_frame_analytics.json @@ -9,6 +9,6 @@ "patterns": [ "_ml/data_frame/analytics/{id}/_start" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/start-dfanalytics.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/start-dfanalytics.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.start_datafeed.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.start_datafeed.json index 8171a792d7e33..36f9e5fa93257 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.start_datafeed.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.start_datafeed.json @@ -11,6 +11,6 @@ "patterns": [ "_ml/datafeeds/{datafeed_id}/_start" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-start-datafeed.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-start-datafeed.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.stop_data_frame_analytics.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.stop_data_frame_analytics.json index 05edf9bbef3a2..bda7a7c0d414b 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.stop_data_frame_analytics.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.stop_data_frame_analytics.json @@ -11,6 +11,6 @@ "patterns": [ "_ml/data_frame/analytics/{id}/_stop" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/stop-dfanalytics.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/stop-dfanalytics.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.stop_datafeed.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.stop_datafeed.json index b10fed7010a7f..d6769ed58148f 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.stop_datafeed.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.stop_datafeed.json @@ -11,6 +11,6 @@ "patterns": [ "_ml/datafeeds/{datafeed_id}/_stop" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-stop-datafeed.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-stop-datafeed.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.update_datafeed.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.update_datafeed.json index 9c0d7502d2fbe..4b31a9595659d 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.update_datafeed.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.update_datafeed.json @@ -1,11 +1,23 @@ { "ml.update_datafeed": { + "url_params": { + "ignore_unavailable": "__flag__", + "allow_no_indices": "__flag__", + "ignore_throttled": "__flag__", + "expand_wildcards": [ + "open", + "closed", + "hidden", + "none", + "all" + ] + }, "methods": [ "POST" ], "patterns": [ "_ml/datafeeds/{datafeed_id}/_update" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-update-datafeed.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-update-datafeed.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.update_job.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.update_job.json index 7276183b2e0c9..47ba249374e51 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.update_job.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.update_job.json @@ -6,6 +6,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/_update" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-update-job.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-update-job.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/ml.update_model_snapshot.json b/x-pack/plugins/console_extensions/server/spec/generated/ml.update_model_snapshot.json index 80e533eb55826..037982e7ebb2e 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/ml.update_model_snapshot.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/ml.update_model_snapshot.json @@ -6,6 +6,6 @@ "patterns": [ "_ml/anomaly_detectors/{job_id}/model_snapshots/{snapshot_id}/_update" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/ml-update-snapshot.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/ml-update-snapshot.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/security.delete_privileges.json b/x-pack/plugins/console_extensions/server/spec/generated/security.delete_privileges.json index b44798013fe59..a7b56aa904bb2 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/security.delete_privileges.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/security.delete_privileges.json @@ -13,6 +13,6 @@ "patterns": [ "_security/privilege/{application}/{name}" ], - "documentation": "TODO" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-delete-privilege.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/security.put_privileges.json b/x-pack/plugins/console_extensions/server/spec/generated/security.put_privileges.json index a42d5eb6c953e..4dbe88c526f0e 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/security.put_privileges.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/security.put_privileges.json @@ -12,8 +12,8 @@ "POST" ], "patterns": [ - "_security/privilege" + "_security/privilege/" ], - "documentation": "TODO" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-privileges.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/slm.delete_lifecycle.json b/x-pack/plugins/console_extensions/server/spec/generated/slm.delete_lifecycle.json index 621aa9327e798..ee63fd52eeb5b 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/slm.delete_lifecycle.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/slm.delete_lifecycle.json @@ -6,6 +6,6 @@ "patterns": [ "_slm/policy/{policy_id}" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-delete.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-delete-policy.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/slm.execute_lifecycle.json b/x-pack/plugins/console_extensions/server/spec/generated/slm.execute_lifecycle.json index 6d0b5fe02a9ee..9e50e2fc1009b 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/slm.execute_lifecycle.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/slm.execute_lifecycle.json @@ -6,6 +6,6 @@ "patterns": [ "_slm/policy/{policy_id}/_execute" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-execute.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-execute-lifecycle.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/slm.get_lifecycle.json b/x-pack/plugins/console_extensions/server/spec/generated/slm.get_lifecycle.json index 869438deb9219..93c32091be8e3 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/slm.get_lifecycle.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/slm.get_lifecycle.json @@ -7,6 +7,6 @@ "_slm/policy/{policy_id}", "_slm/policy" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-get.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-get-policy.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/slm.get_stats.json b/x-pack/plugins/console_extensions/server/spec/generated/slm.get_stats.json index e980534105b3c..b5af57beb2f79 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/slm.get_stats.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/slm.get_stats.json @@ -6,6 +6,6 @@ "patterns": [ "_slm/stats" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/slm-get-stats.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/slm-api-get-stats.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/slm.get_status.json b/x-pack/plugins/console_extensions/server/spec/generated/slm.get_status.json index a7ffde10b316d..3a01a414b5afd 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/slm.get_status.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/slm.get_status.json @@ -6,6 +6,6 @@ "patterns": [ "_slm/status" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-get-status.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-get-status.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/slm.put_lifecycle.json b/x-pack/plugins/console_extensions/server/spec/generated/slm.put_lifecycle.json index 1391669ed293b..09bc2b7bf836b 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/slm.put_lifecycle.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/slm.put_lifecycle.json @@ -6,6 +6,6 @@ "patterns": [ "_slm/policy/{policy_id}" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-put.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-put-policy.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/slm.start.json b/x-pack/plugins/console_extensions/server/spec/generated/slm.start.json index a5b94d98f08fb..1dff975cb2625 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/slm.start.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/slm.start.json @@ -6,6 +6,6 @@ "patterns": [ "_slm/start" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-start.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-start.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/slm.stop.json b/x-pack/plugins/console_extensions/server/spec/generated/slm.stop.json index 0b76fe68d2b5e..2970c9a355005 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/slm.stop.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/slm.stop.json @@ -6,6 +6,6 @@ "patterns": [ "_slm/stop" ], - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-stop.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/slm-api-stop.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/sql.clear_cursor.json b/x-pack/plugins/console_extensions/server/spec/generated/sql.clear_cursor.json index 6f15e1b979c51..3c98c9d295710 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/sql.clear_cursor.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/sql.clear_cursor.json @@ -6,6 +6,6 @@ "patterns": [ "_sql/close" ], - "documentation": "Clear SQL cursor" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/sql-pagination.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/sql.query.json b/x-pack/plugins/console_extensions/server/spec/generated/sql.query.json index 0e4274e772f30..75d0989fb779e 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/sql.query.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/sql.query.json @@ -10,6 +10,6 @@ "patterns": [ "_sql" ], - "documentation": "Execute SQL" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/sql-rest-overview.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/sql.translate.json b/x-pack/plugins/console_extensions/server/spec/generated/sql.translate.json index e80ae7f8e3c5f..f93669ad58dc8 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/sql.translate.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/sql.translate.json @@ -7,6 +7,6 @@ "patterns": [ "_sql/translate" ], - "documentation": "Translate SQL into Elasticsearch queries" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/sql-translate.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/transform.cat_transform.json b/x-pack/plugins/console_extensions/server/spec/generated/transform.cat_transform.json new file mode 100644 index 0000000000000..6fe19a6e53d28 --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/generated/transform.cat_transform.json @@ -0,0 +1,31 @@ +{ + "transform.cat_transform": { + "url_params": { + "from": 0, + "size": 0, + "allow_no_match": "__flag__", + "format": "", + "h": [], + "help": "__flag__", + "s": [], + "time": [ + "d", + "h", + "m", + "s", + "ms", + "micros", + "nanos" + ], + "v": "__flag__" + }, + "methods": [ + "GET" + ], + "patterns": [ + "_cat/transforms", + "_cat/transforms/{transform_id}" + ], + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/cat-transforms.html" + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/generated/watcher.ack_watch.json b/x-pack/plugins/console_extensions/server/spec/generated/watcher.ack_watch.json index bedaa40c10548..0eacab92ba98d 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/watcher.ack_watch.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/watcher.ack_watch.json @@ -8,6 +8,6 @@ "_watcher/watch/{watch_id}/_ack", "_watcher/watch/{watch_id}/_ack/{action_id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-ack-watch.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-ack-watch.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/watcher.delete_watch.json b/x-pack/plugins/console_extensions/server/spec/generated/watcher.delete_watch.json index 63e76c78c0d4a..4e0153423f540 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/watcher.delete_watch.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/watcher.delete_watch.json @@ -6,6 +6,6 @@ "patterns": [ "_watcher/watch/{id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-delete-watch.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-delete-watch.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/watcher.execute_watch.json b/x-pack/plugins/console_extensions/server/spec/generated/watcher.execute_watch.json index 7319d68d249ff..249c912637d5e 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/watcher.execute_watch.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/watcher.execute_watch.json @@ -11,6 +11,6 @@ "_watcher/watch/{id}/_execute", "_watcher/watch/_execute" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-execute-watch.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-execute-watch.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/watcher.get_watch.json b/x-pack/plugins/console_extensions/server/spec/generated/watcher.get_watch.json index d9e646712edd6..bc244ed9415d2 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/watcher.get_watch.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/watcher.get_watch.json @@ -6,6 +6,6 @@ "patterns": [ "_watcher/watch/{id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-get-watch.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-get-watch.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/watcher.put_watch.json b/x-pack/plugins/console_extensions/server/spec/generated/watcher.put_watch.json index 98250da734222..59eba35f7fcbd 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/watcher.put_watch.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/watcher.put_watch.json @@ -13,6 +13,6 @@ "patterns": [ "_watcher/watch/{id}" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-put-watch.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-put-watch.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/watcher.start.json b/x-pack/plugins/console_extensions/server/spec/generated/watcher.start.json index 28bc2db990ebd..e1d9e4c820ad7 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/watcher.start.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/watcher.start.json @@ -6,6 +6,6 @@ "patterns": [ "_watcher/_start" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-start.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-start.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/watcher.stats.json b/x-pack/plugins/console_extensions/server/spec/generated/watcher.stats.json index 62c6c5fea123e..d19446e0f5bb2 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/watcher.stats.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/watcher.stats.json @@ -19,6 +19,6 @@ "queued_watches" ] }, - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-stats.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-stats.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/watcher.stop.json b/x-pack/plugins/console_extensions/server/spec/generated/watcher.stop.json index c2f370981d8e6..ac8fdaf365346 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/watcher.stop.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/watcher.stop.json @@ -6,6 +6,6 @@ "patterns": [ "_watcher/_stop" ], - "documentation": "http://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-stop.html" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/watcher-api-stop.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/generated/xpack.usage.json b/x-pack/plugins/console_extensions/server/spec/generated/xpack.usage.json index cd43f16ec45f8..90d50ce8aa533 100644 --- a/x-pack/plugins/console_extensions/server/spec/generated/xpack.usage.json +++ b/x-pack/plugins/console_extensions/server/spec/generated/xpack.usage.json @@ -9,6 +9,6 @@ "patterns": [ "_xpack/usage" ], - "documentation": "Retrieve information about xpack features usage" + "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/usage-api.html" } } diff --git a/x-pack/plugins/console_extensions/server/spec/overrides/async_search.submit.json b/x-pack/plugins/console_extensions/server/spec/overrides/async_search.submit.json new file mode 100644 index 0000000000000..f176bf64fadd3 --- /dev/null +++ b/x-pack/plugins/console_extensions/server/spec/overrides/async_search.submit.json @@ -0,0 +1,7 @@ +{ + "async_search.submit": { + "data_autocomplete_rules": { + "__scope_link": "search" + } + } +} diff --git a/x-pack/plugins/console_extensions/server/spec/overrides/ml.estimate_memory_usage.json b/x-pack/plugins/console_extensions/server/spec/overrides/ml.estimate_memory_usage.json deleted file mode 100644 index 4954fd81a55d1..0000000000000 --- a/x-pack/plugins/console_extensions/server/spec/overrides/ml.estimate_memory_usage.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "ml.estimate_memory_usage": { - "data_autocomplete_rules": { - "data_frame_analytics_config": { - "source": { - "index": { "__one_of": ["SOURCE_INDEX_NAME", []] }, - "query": {} - }, - "dest": { - "index": "", - "results_field": "" - }, - "analysis": { - "outlier_detection": { - "n_neighbors": 1, - "method": {"__one_of": ["lof", "ldof", "distance_knn_nn", "distance_knn"]}, - "feature_influence_threshold": 1.0 - } - }, - "analyzed_fields": { - "__one_of": [ - "FIELD_NAME", - [], - { - "includes": { - "__one_of": ["FIELD_NAME", []] - }, - "excludes": { - "__one_of": ["FIELD_NAME", []] - } - } - ] - }, - "model_memory_limit": "" - } - } - } -} diff --git a/x-pack/plugins/console_extensions/server/spec/overrides/security.delete_privileges.json b/x-pack/plugins/console_extensions/server/spec/overrides/security.delete_privileges.json deleted file mode 100644 index 5486098ff7bd8..0000000000000 --- a/x-pack/plugins/console_extensions/server/spec/overrides/security.delete_privileges.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "security.delete_privileges": { - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-delete-privilege.html" - } -} diff --git a/x-pack/plugins/console_extensions/server/spec/overrides/security.put_privileges.json b/x-pack/plugins/console_extensions/server/spec/overrides/security.put_privileges.json deleted file mode 100644 index 9ebb1046047a7..0000000000000 --- a/x-pack/plugins/console_extensions/server/spec/overrides/security.put_privileges.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "security.put_privileges": { - "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-put-privileges.html" - } -} diff --git a/x-pack/plugins/dashboard_enhanced/README.md b/x-pack/plugins/dashboard_enhanced/README.md deleted file mode 100644 index d9296ae158621..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/README.md +++ /dev/null @@ -1 +0,0 @@ -# X-Pack part of Dashboard app diff --git a/x-pack/plugins/dashboard_enhanced/kibana.json b/x-pack/plugins/dashboard_enhanced/kibana.json deleted file mode 100644 index acbca5c33295c..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/kibana.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "id": "dashboardEnhanced", - "version": "kibana", - "server": true, - "ui": true, - "requiredPlugins": ["uiActions", "embeddable", "dashboard", "drilldowns"], - "configPath": ["xpack", "dashboardEnhanced"] -} diff --git a/x-pack/plugins/dashboard_enhanced/public/components/README.md b/x-pack/plugins/dashboard_enhanced/public/components/README.md deleted file mode 100644 index 8081f8a2451cf..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/components/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Presentation React components - -Here we keep reusable *presentation* (aka *dumb*) React components—these -components should not be connected to state and ideally should not know anything -about Kibana. diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx deleted file mode 100644 index 8e204b044a136..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx +++ /dev/null @@ -1,54 +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. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { DashboardDrilldownConfig } from '.'; - -export const dashboards = [ - { id: 'dashboard1', title: 'Dashboard 1' }, - { id: 'dashboard2', title: 'Dashboard 2' }, - { id: 'dashboard3', title: 'Dashboard 3' }, -]; - -const InteractiveDemo: React.FC = () => { - const [activeDashboardId, setActiveDashboardId] = React.useState('dashboard1'); - const [currentFilters, setCurrentFilters] = React.useState(false); - const [keepRange, setKeepRange] = React.useState(false); - - return ( - setActiveDashboardId(id)} - onCurrentFiltersToggle={() => setCurrentFilters(old => !old)} - onKeepRangeToggle={() => setKeepRange(old => !old)} - /> - ); -}; - -storiesOf('components/DashboardDrilldownConfig', module) - .add('default', () => ( - console.log('onDashboardSelect', e)} - /> - )) - .add('with switches', () => ( - console.log('onDashboardSelect', e)} - onCurrentFiltersToggle={() => console.log('onCurrentFiltersToggle')} - onKeepRangeToggle={() => console.log('onKeepRangeToggle')} - /> - )) - .add('interactive demo', () => ); diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx deleted file mode 100644 index 911ff6f632635..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx +++ /dev/null @@ -1,11 +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. - */ - -test.todo('renders list of dashboards'); -test.todo('renders correct selected dashboard'); -test.todo('can change dashboard'); -test.todo('can toggle "use current filters" switch'); -test.todo('can toggle "date range" switch'); diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx deleted file mode 100644 index b45ba602b9bb1..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx +++ /dev/null @@ -1,69 +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 { EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui'; -import { txtChooseDestinationDashboard } from './i18n'; - -export interface DashboardItem { - id: string; - title: string; -} - -export interface DashboardDrilldownConfigProps { - activeDashboardId?: string; - dashboards: DashboardItem[]; - currentFilters?: boolean; - keepRange?: boolean; - onDashboardSelect: (dashboardId: string) => void; - onCurrentFiltersToggle?: () => void; - onKeepRangeToggle?: () => void; -} - -export const DashboardDrilldownConfig: React.FC = ({ - activeDashboardId, - dashboards, - currentFilters, - keepRange, - onDashboardSelect, - onCurrentFiltersToggle, - onKeepRangeToggle, -}) => { - // TODO: use i18n below. - return ( - <> - - ({ value: id, text: title }))} - value={activeDashboardId} - onChange={e => onDashboardSelect(e.target.value)} - /> - - {!!onCurrentFiltersToggle && ( - - - - )} - {!!onKeepRangeToggle && ( - - - - )} - - ); -}; diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts deleted file mode 100644 index 38fe6dd150853..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts +++ /dev/null @@ -1,14 +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 { i18n } from '@kbn/i18n'; - -export const txtChooseDestinationDashboard = i18n.translate( - 'xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard', - { - defaultMessage: 'Choose destination dashboard', - } -); diff --git a/x-pack/plugins/dashboard_enhanced/public/index.ts b/x-pack/plugins/dashboard_enhanced/public/index.ts deleted file mode 100644 index 53540a4a1ad2e..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/index.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 { PluginInitializerContext } from 'src/core/public'; -import { DashboardEnhancedPlugin } from './plugin'; - -export { - SetupContract as DashboardEnhancedSetupContract, - SetupDependencies as DashboardEnhancedSetupDependencies, - StartContract as DashboardEnhancedStartContract, - StartDependencies as DashboardEnhancedStartDependencies, -} from './plugin'; - -export function plugin(context: PluginInitializerContext) { - return new DashboardEnhancedPlugin(context); -} diff --git a/x-pack/plugins/dashboard_enhanced/public/mocks.ts b/x-pack/plugins/dashboard_enhanced/public/mocks.ts deleted file mode 100644 index 67dc1fd97d521..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/mocks.ts +++ /dev/null @@ -1,27 +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 { DashboardEnhancedSetupContract, DashboardEnhancedStartContract } from '.'; - -export type Setup = jest.Mocked; -export type Start = jest.Mocked; - -const createSetupContract = (): Setup => { - const setupContract: Setup = {}; - - return setupContract; -}; - -const createStartContract = (): Start => { - const startContract: Start = {}; - - return startContract; -}; - -export const dashboardEnhancedPluginMock = { - createSetupContract, - createStartContract, -}; diff --git a/x-pack/plugins/dashboard_enhanced/public/plugin.ts b/x-pack/plugins/dashboard_enhanced/public/plugin.ts deleted file mode 100644 index 30b3f3c080f49..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/plugin.ts +++ /dev/null @@ -1,50 +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 { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; -import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; -import { DashboardDrilldownsService } from './services'; -import { DrilldownsSetup, DrilldownsStart } from '../../drilldowns/public'; - -export interface SetupDependencies { - uiActions: UiActionsSetup; - drilldowns: DrilldownsSetup; -} - -export interface StartDependencies { - uiActions: UiActionsStart; - drilldowns: DrilldownsStart; -} - -// eslint-disable-next-line -export interface SetupContract {} - -// eslint-disable-next-line -export interface StartContract {} - -export class DashboardEnhancedPlugin - implements Plugin { - public readonly drilldowns = new DashboardDrilldownsService(); - public readonly config: { drilldowns: { enabled: boolean } }; - - constructor(protected readonly context: PluginInitializerContext) { - this.config = context.config.get(); - } - - public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { - this.drilldowns.bootstrap(core, plugins, { - enableDrilldowns: this.config.drilldowns.enabled, - }); - - return {}; - } - - public start(core: CoreStart, plugins: StartDependencies): StartContract { - return {}; - } - - public stop() {} -} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx deleted file mode 100644 index 31ee9e29938cb..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - FlyoutCreateDrilldownAction, - OpenFlyoutAddDrilldownParams, -} from './flyout_create_drilldown'; -import { coreMock } from '../../../../../../../../src/core/public/mocks'; -import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks'; -import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; -import { uiActionsPluginMock } from '../../../../../../../../src/plugins/ui_actions/public/mocks'; -import { TriggerContextMapping } from '../../../../../../../../src/plugins/ui_actions/public'; -import { MockEmbeddable } from '../test_helpers'; - -const overlays = coreMock.createStart().overlays; -const drilldowns = drilldownsPluginMock.createStartContract(); -const uiActions = uiActionsPluginMock.createStartContract(); - -const actionParams: OpenFlyoutAddDrilldownParams = { - drilldowns: () => Promise.resolve(drilldowns), - overlays: () => Promise.resolve(overlays), -}; - -test('should create', () => { - expect(() => new FlyoutCreateDrilldownAction(actionParams)).not.toThrow(); -}); - -test('title is a string', () => { - expect(typeof new FlyoutCreateDrilldownAction(actionParams).getDisplayName() === 'string').toBe( - true - ); -}); - -test('icon exists', () => { - expect(typeof new FlyoutCreateDrilldownAction(actionParams).getIconType() === 'string').toBe( - true - ); -}); - -describe('isCompatible', () => { - const drilldownAction = new FlyoutCreateDrilldownAction(actionParams); - - function checkCompatibility(params: { - isEdit: boolean; - withUiActions: boolean; - isValueClickTriggerSupported: boolean; - }): Promise { - return drilldownAction.isCompatible({ - embeddable: new MockEmbeddable( - { id: '', viewMode: params.isEdit ? ViewMode.EDIT : ViewMode.VIEW }, - { - supportedTriggers: (params.isValueClickTriggerSupported - ? ['VALUE_CLICK_TRIGGER'] - : []) as Array, - uiActions: params.withUiActions ? uiActions : undefined, // dynamic actions support - } - ), - }); - } - - test("compatible if dynamicUiActions enabled, 'VALUE_CLICK_TRIGGER' is supported, in edit mode", async () => { - expect( - await checkCompatibility({ - withUiActions: true, - isEdit: true, - isValueClickTriggerSupported: true, - }) - ).toBe(true); - }); - - test('not compatible if dynamicUiActions disabled', async () => { - expect( - await checkCompatibility({ - withUiActions: false, - isEdit: true, - isValueClickTriggerSupported: true, - }) - ).toBe(false); - }); - - test("not compatible if 'VALUE_CLICK_TRIGGER' is not supported", async () => { - expect( - await checkCompatibility({ - withUiActions: true, - isEdit: true, - isValueClickTriggerSupported: false, - }) - ).toBe(false); - }); - - test('not compatible if in view mode', async () => { - expect( - await checkCompatibility({ - withUiActions: true, - isEdit: false, - isValueClickTriggerSupported: true, - }) - ).toBe(false); - }); -}); - -describe('execute', () => { - const drilldownAction = new FlyoutCreateDrilldownAction(actionParams); - test('throws error if no dynamicUiActions', async () => { - await expect( - drilldownAction.execute({ - embeddable: new MockEmbeddable({ id: '' }, {}), - }) - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Can't execute FlyoutCreateDrilldownAction without dynamicActionsManager"` - ); - }); - - test('should open flyout', async () => { - const spy = jest.spyOn(overlays, 'openFlyout'); - await drilldownAction.execute({ - embeddable: new MockEmbeddable({ id: '' }, { uiActions }), - }); - expect(spy).toBeCalled(); - }); -}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx deleted file mode 100644 index 00e74ea570a11..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx +++ /dev/null @@ -1,74 +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 { i18n } from '@kbn/i18n'; -import { CoreStart } from 'src/core/public'; -import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public'; -import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; -import { DrilldownsStart } from '../../../../../../drilldowns/public'; -import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public'; - -export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; - -export interface OpenFlyoutAddDrilldownParams { - overlays: () => Promise; - drilldowns: () => Promise; -} - -export class FlyoutCreateDrilldownAction implements ActionByType { - public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN; - public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN; - public order = 12; - - constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {} - - public getDisplayName() { - return i18n.translate('xpack.dashboard.FlyoutCreateDrilldownAction.displayName', { - defaultMessage: 'Create drilldown', - }); - } - - public getIconType() { - return 'plusInCircle'; - } - - private isEmbeddableCompatible(context: EmbeddableContext) { - if (!context.embeddable.dynamicActions) return false; - const supportedTriggers = context.embeddable.supportedTriggers(); - if (!supportedTriggers || !supportedTriggers.length) return false; - return supportedTriggers.indexOf('VALUE_CLICK_TRIGGER') > -1; - } - - public async isCompatible(context: EmbeddableContext) { - const isEditMode = context.embeddable.getInput().viewMode === 'edit'; - return isEditMode && this.isEmbeddableCompatible(context); - } - - public async execute(context: EmbeddableContext) { - const overlays = await this.params.overlays(); - const drilldowns = await this.params.drilldowns(); - const dynamicActionManager = context.embeddable.dynamicActions; - - if (!dynamicActionManager) { - throw new Error(`Can't execute FlyoutCreateDrilldownAction without dynamicActionsManager`); - } - - const handle = overlays.openFlyout( - toMountPoint( - handle.close()} - placeContext={context} - viewMode={'create'} - dynamicActionManager={dynamicActionManager} - /> - ), - { - ownFocus: true, - } - ); - } -} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts deleted file mode 100644 index 4d2db209fc961..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts +++ /dev/null @@ -1,11 +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. - */ - -export { - FlyoutCreateDrilldownAction, - OpenFlyoutAddDrilldownParams, - OPEN_FLYOUT_ADD_DRILLDOWN, -} from './flyout_create_drilldown'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx deleted file mode 100644 index a3f11eb976f90..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx +++ /dev/null @@ -1,102 +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 { FlyoutEditDrilldownAction, FlyoutEditDrilldownParams } from './flyout_edit_drilldown'; -import { coreMock } from '../../../../../../../../src/core/public/mocks'; -import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks'; -import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; -import { uiActionsPluginMock } from '../../../../../../../../src/plugins/ui_actions/public/mocks'; -import { MockEmbeddable } from '../test_helpers'; - -const overlays = coreMock.createStart().overlays; -const drilldowns = drilldownsPluginMock.createStartContract(); -const uiActions = uiActionsPluginMock.createStartContract(); - -const actionParams: FlyoutEditDrilldownParams = { - drilldowns: () => Promise.resolve(drilldowns), - overlays: () => Promise.resolve(overlays), -}; - -test('should create', () => { - expect(() => new FlyoutEditDrilldownAction(actionParams)).not.toThrow(); -}); - -test('title is a string', () => { - expect(typeof new FlyoutEditDrilldownAction(actionParams).getDisplayName() === 'string').toBe( - true - ); -}); - -test('icon exists', () => { - expect(typeof new FlyoutEditDrilldownAction(actionParams).getIconType() === 'string').toBe(true); -}); - -test('MenuItem exists', () => { - expect(new FlyoutEditDrilldownAction(actionParams).MenuItem).toBeDefined(); -}); - -describe('isCompatible', () => { - const drilldownAction = new FlyoutEditDrilldownAction(actionParams); - - function checkCompatibility(params: { - isEdit: boolean; - withUiActions: boolean; - }): Promise { - return drilldownAction.isCompatible({ - embeddable: new MockEmbeddable( - { - id: '', - viewMode: params.isEdit ? ViewMode.EDIT : ViewMode.VIEW, - }, - { - uiActions: params.withUiActions ? uiActions : undefined, // dynamic actions support - } - ), - }); - } - - // TODO: need proper DynamicActionsMock and ActionFactory mock - test.todo('compatible if dynamicUiActions enabled, in edit view, and have at least 1 drilldown'); - - test('not compatible if dynamicUiActions disabled', async () => { - expect( - await checkCompatibility({ - withUiActions: false, - isEdit: true, - }) - ).toBe(false); - }); - - test('not compatible if no drilldowns', async () => { - expect( - await checkCompatibility({ - withUiActions: true, - isEdit: true, - }) - ).toBe(false); - }); -}); - -describe('execute', () => { - const drilldownAction = new FlyoutEditDrilldownAction(actionParams); - test('throws error if no dynamicUiActions', async () => { - await expect( - drilldownAction.execute({ - embeddable: new MockEmbeddable({ id: '' }, {}), - }) - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Can't execute FlyoutEditDrilldownAction without dynamicActionsManager"` - ); - }); - - test('should open flyout', async () => { - const spy = jest.spyOn(overlays, 'openFlyout'); - await drilldownAction.execute({ - embeddable: new MockEmbeddable({ id: '' }, { uiActions }), - }); - expect(spy).toBeCalled(); - }); -}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx deleted file mode 100644 index 816b757592a72..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx +++ /dev/null @@ -1,71 +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 { CoreStart } from 'src/core/public'; -import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public'; -import { - reactToUiComponent, - toMountPoint, -} from '../../../../../../../../src/plugins/kibana_react/public'; -import { EmbeddableContext, ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; -import { DrilldownsStart } from '../../../../../../drilldowns/public'; -import { txtDisplayName } from './i18n'; -import { MenuItem } from './menu_item'; - -export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; - -export interface FlyoutEditDrilldownParams { - overlays: () => Promise; - drilldowns: () => Promise; -} - -export class FlyoutEditDrilldownAction implements ActionByType { - public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN; - public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN; - public order = 10; - - constructor(protected readonly params: FlyoutEditDrilldownParams) {} - - public getDisplayName() { - return txtDisplayName; - } - - public getIconType() { - return 'list'; - } - - MenuItem = reactToUiComponent(MenuItem); - - public async isCompatible({ embeddable }: EmbeddableContext) { - if (embeddable.getInput().viewMode !== ViewMode.EDIT) return false; - if (!embeddable.dynamicActions) return false; - return embeddable.dynamicActions.state.get().events.length > 0; - } - - public async execute(context: EmbeddableContext) { - const overlays = await this.params.overlays(); - const drilldowns = await this.params.drilldowns(); - const dynamicActionManager = context.embeddable.dynamicActions; - if (!dynamicActionManager) { - throw new Error(`Can't execute FlyoutEditDrilldownAction without dynamicActionsManager`); - } - - const handle = overlays.openFlyout( - toMountPoint( - handle.close()} - placeContext={context} - viewMode={'manage'} - dynamicActionManager={dynamicActionManager} - /> - ), - { - ownFocus: true, - } - ); - } -} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx deleted file mode 100644 index 3e1b37f270708..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx +++ /dev/null @@ -1,11 +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. - */ - -export { - FlyoutEditDrilldownAction, - FlyoutEditDrilldownParams, - OPEN_FLYOUT_EDIT_DRILLDOWN, -} from './flyout_edit_drilldown'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx deleted file mode 100644 index be693fadf9282..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx +++ /dev/null @@ -1,37 +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 { render, cleanup, act } from '@testing-library/react/pure'; -import { MenuItem } from './menu_item'; -import { createStateContainer } from '../../../../../../../../src/plugins/kibana_utils/common'; -import { DynamicActionManager } from '../../../../../../../../src/plugins/ui_actions/public'; -import { IEmbeddable } from '../../../../../../../../src/plugins/embeddable/public/lib/embeddables'; -import '@testing-library/jest-dom'; - -afterEach(cleanup); - -test('', () => { - const state = createStateContainer<{ events: object[] }>({ events: [] }); - const { getByText, queryByText } = render( - - ); - - expect(getByText(/manage drilldowns/i)).toBeInTheDocument(); - expect(queryByText('0')).not.toBeInTheDocument(); - - act(() => { - state.set({ events: [{}] }); - }); - - expect(queryByText('1')).toBeInTheDocument(); -}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx deleted file mode 100644 index 4f99fca511b07..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { EuiNotificationBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public'; -import { txtDisplayName } from './i18n'; -import { useContainerState } from '../../../../../../../../src/plugins/kibana_utils/common'; - -export const MenuItem: React.FC<{ context: EmbeddableContext }> = ({ context }) => { - if (!context.embeddable.dynamicActions) - throw new Error('Flyout edit drillldown context menu item requires `dynamicActions`'); - - const { events } = useContainerState(context.embeddable.dynamicActions.state); - const count = events.length; - - return ( - - {txtDisplayName} - {count > 0 && ( - - {count} - - )} - - ); -}; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts deleted file mode 100644 index 9b156b0ba85b4..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts +++ /dev/null @@ -1,28 +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 { Embeddable, EmbeddableInput } from '../../../../../../../src/plugins/embeddable/public/'; -import { - TriggerContextMapping, - UiActionsStart, -} from '../../../../../../../src/plugins/ui_actions/public'; - -export class MockEmbeddable extends Embeddable { - public readonly type = 'mock'; - private readonly triggers: Array = []; - constructor( - initialInput: EmbeddableInput, - params: { uiActions?: UiActionsStart; supportedTriggers?: Array } - ) { - super(initialInput, {}, undefined, params); - this.triggers = params.supportedTriggers ?? []; - } - public render(node: HTMLElement) {} - public reload() {} - public supportedTriggers(): Array { - return this.triggers; - } -} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts deleted file mode 100644 index 4bdf03dff3531..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts +++ /dev/null @@ -1,60 +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 { CoreSetup } from 'src/core/public'; -import { SetupDependencies } from '../../plugin'; -import { - CONTEXT_MENU_TRIGGER, - EmbeddableContext, -} from '../../../../../../src/plugins/embeddable/public'; -import { - FlyoutCreateDrilldownAction, - FlyoutEditDrilldownAction, - OPEN_FLYOUT_ADD_DRILLDOWN, - OPEN_FLYOUT_EDIT_DRILLDOWN, -} from './actions'; -import { DrilldownsStart } from '../../../../drilldowns/public'; -import { DashboardToDashboardDrilldown } from './dashboard_to_dashboard_drilldown'; - -declare module '../../../../../../src/plugins/ui_actions/public' { - export interface ActionContextMapping { - [OPEN_FLYOUT_ADD_DRILLDOWN]: EmbeddableContext; - [OPEN_FLYOUT_EDIT_DRILLDOWN]: EmbeddableContext; - } -} - -interface BootstrapParams { - enableDrilldowns: boolean; -} - -export class DashboardDrilldownsService { - bootstrap( - core: CoreSetup<{ drilldowns: DrilldownsStart }>, - plugins: SetupDependencies, - { enableDrilldowns }: BootstrapParams - ) { - if (enableDrilldowns) { - this.setupDrilldowns(core, plugins); - } - } - - setupDrilldowns(core: CoreSetup<{ drilldowns: DrilldownsStart }>, plugins: SetupDependencies) { - const overlays = async () => (await core.getStartServices())[0].overlays; - const drilldowns = async () => (await core.getStartServices())[1].drilldowns; - const savedObjects = async () => (await core.getStartServices())[0].savedObjects.client; - - const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ overlays, drilldowns }); - plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown); - - const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ overlays, drilldowns }); - plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown); - - const dashboardToDashboardDrilldown = new DashboardToDashboardDrilldown({ - savedObjects, - }); - plugins.drilldowns.registerDrilldown(dashboardToDashboardDrilldown); - } -} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx deleted file mode 100644 index 95101605ce468..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx +++ /dev/null @@ -1,9 +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. - */ - -test.todo('displays all dashboard in a list'); -test.todo('does not display dashboard on which drilldown is being created'); -test.todo('updates config object correctly'); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx deleted file mode 100644 index e463cc38b6fbf..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx +++ /dev/null @@ -1,55 +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, { useState, useEffect } from 'react'; -import { CollectConfigProps } from './types'; -import { DashboardDrilldownConfig } from '../../../components/dashboard_drilldown_config'; -import { Params } from './drilldown'; - -export interface CollectConfigContainerProps extends CollectConfigProps { - params: Params; -} - -export const CollectConfigContainer: React.FC = ({ - config, - onConfig, - params: { savedObjects }, -}) => { - const [dashboards] = useState([ - { id: 'dashboard1', title: 'Dashboard 1' }, - { id: 'dashboard2', title: 'Dashboard 2' }, - { id: 'dashboard3', title: 'Dashboard 3' }, - { id: 'dashboard4', title: 'Dashboard 4' }, - ]); - - useEffect(() => { - // TODO: Load dashboards... - }, [savedObjects]); - - return ( - { - onConfig({ ...config, dashboardId }); - }} - onCurrentFiltersToggle={() => - onConfig({ - ...config, - useCurrentFilters: !config.useCurrentFilters, - }) - } - onKeepRangeToggle={() => - onConfig({ - ...config, - useCurrentDateRange: !config.useCurrentDateRange, - }) - } - /> - ); -}; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts deleted file mode 100644 index e2a530b156da5..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts +++ /dev/null @@ -1,7 +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. - */ - -export const DASHBOARD_TO_DASHBOARD_DRILLDOWN = 'DASHBOARD_TO_DASHBOARD_DRILLDOWN'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx deleted file mode 100644 index 0fb60bb1064a1..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx +++ /dev/null @@ -1,20 +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. - */ - -describe('.isConfigValid()', () => { - test.todo('returns false for incorrect config'); - test.todo('returns true for incorrect config'); -}); - -describe('.execute()', () => { - test.todo('navigates to correct dashboard'); - test.todo( - 'when user chooses to keep current filters, current fileters are set on destination dashboard' - ); - test.todo( - 'when user chooses to keep current time range, current time range is set on destination dashboard' - ); -}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx deleted file mode 100644 index 9d2a378f08acd..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx +++ /dev/null @@ -1,52 +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 { CoreStart } from 'src/core/public'; -import { reactToUiComponent } from '../../../../../../../src/plugins/kibana_react/public'; -import { PlaceContext, ActionContext, Config, CollectConfigProps } from './types'; -import { CollectConfigContainer } from './collect_config'; -import { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from './constants'; -import { DrilldownDefinition as Drilldown } from '../../../../../drilldowns/public'; -import { txtGoToDashboard } from './i18n'; - -export interface Params { - savedObjects: () => Promise; -} - -export class DashboardToDashboardDrilldown - implements Drilldown { - constructor(protected readonly params: Params) {} - - public readonly id = DASHBOARD_TO_DASHBOARD_DRILLDOWN; - - public readonly order = 100; - - public readonly getDisplayName = () => txtGoToDashboard; - - public readonly euiIcon = 'dashboardApp'; - - private readonly ReactCollectConfig: React.FC = props => ( - - ); - - public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); - - public readonly createConfig = () => ({ - dashboardId: '123', - useCurrentFilters: true, - useCurrentDateRange: true, - }); - - public readonly isConfigValid = (config: Config): config is Config => { - if (!config.dashboardId) return false; - return true; - }; - - public readonly execute = () => { - alert('Go to another dashboard!'); - }; -} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts deleted file mode 100644 index 9daa485bb6e6c..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts +++ /dev/null @@ -1,16 +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. - */ - -export { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from './constants'; -export { - DashboardToDashboardDrilldown, - Params as DashboardToDashboardDrilldownParams, -} from './drilldown'; -export { - PlaceContext as DashboardToDashboardPlaceContext, - ActionContext as DashboardToDashboardActionContext, - Config as DashboardToDashboardConfig, -} from './types'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts deleted file mode 100644 index 398a259491e3e..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts +++ /dev/null @@ -1,22 +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 { - EmbeddableVisTriggerContext, - EmbeddableContext, -} from '../../../../../../../src/plugins/embeddable/public'; -import { UiActionsCollectConfigProps } from '../../../../../../../src/plugins/ui_actions/public'; - -export type PlaceContext = EmbeddableContext; -export type ActionContext = EmbeddableVisTriggerContext; - -export interface Config { - dashboardId?: string; - useCurrentFilters: boolean; - useCurrentDateRange: boolean; -} - -export type CollectConfigProps = UiActionsCollectConfigProps; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts deleted file mode 100644 index 7be8f1c65da12..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export * from './dashboard_drilldowns_services'; diff --git a/x-pack/plugins/dashboard_enhanced/server/config.ts b/x-pack/plugins/dashboard_enhanced/server/config.ts deleted file mode 100644 index b75c95d5f8832..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/server/config.ts +++ /dev/null @@ -1,23 +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 { schema, TypeOf } from '@kbn/config-schema'; -import { PluginConfigDescriptor } from '../../../../src/core/server'; - -export const configSchema = schema.object({ - drilldowns: schema.object({ - enabled: schema.boolean({ defaultValue: false }), - }), -}); - -export type ConfigSchema = TypeOf; - -export const config: PluginConfigDescriptor = { - schema: configSchema, - exposeToBrowser: { - drilldowns: true, - }, -}; diff --git a/x-pack/plugins/dashboard_enhanced/server/index.ts b/x-pack/plugins/dashboard_enhanced/server/index.ts deleted file mode 100644 index e361b9fb075ed..0000000000000 --- a/x-pack/plugins/dashboard_enhanced/server/index.ts +++ /dev/null @@ -1,12 +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. - */ - -export { config } from './config'; - -export const plugin = () => ({ - setup() {}, - start() {}, -}); diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 6316d87c50519..72e0817eea8df 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -17,6 +17,7 @@ import { asyncSearchStrategyProvider, enhancedEsSearchStrategyProvider, } from './search'; +import { EnhancedSearchInterceptor } from './search/search_interceptor'; export interface DataEnhancedSetupDependencies { data: DataPublicPluginSetup; @@ -45,5 +46,11 @@ export class DataEnhancedPlugin implements Plugin { public start(core: CoreStart, plugins: DataEnhancedStartDependencies) { setAutocompleteService(plugins.data.autocomplete); + const enhancedSearchInterceptor = new EnhancedSearchInterceptor( + core.notifications.toasts, + core.application, + core.injectedMetadata.getInjectedVar('esRequestTimeout') as number + ); + plugins.data.search.setInterceptor(enhancedSearchInterceptor); } } diff --git a/x-pack/plugins/data_enhanced/public/search/long_query_notification.tsx b/x-pack/plugins/data_enhanced/public/search/long_query_notification.tsx new file mode 100644 index 0000000000000..325cf1145fa5f --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/long_query_notification.tsx @@ -0,0 +1,47 @@ +/* + * 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 { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; + +interface Props { + cancel: () => void; + runBeyondTimeout: () => void; +} + +export function getLongQueryNotification(props: Props) { + return toMountPoint( + + ); +} + +export function LongQueryNotification(props: Props) { + return ( +
+ + + + + + + + + + + + + +
+ ); +} diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts new file mode 100644 index 0000000000000..1e554d3ff2d86 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { Observable, Subject } from 'rxjs'; +import { coreMock } from '../../../../../src/core/public/mocks'; +import { EnhancedSearchInterceptor } from './search_interceptor'; +import { CoreStart } from 'kibana/public'; + +jest.useFakeTimers(); + +const flushPromises = () => new Promise(resolve => setImmediate(resolve)); +const mockSearch = jest.fn(); +let searchInterceptor: EnhancedSearchInterceptor; +let mockCoreStart: MockedKeys; + +describe('EnhancedSearchInterceptor', () => { + beforeEach(() => { + mockCoreStart = coreMock.createStart(); + mockSearch.mockClear(); + searchInterceptor = new EnhancedSearchInterceptor( + mockCoreStart.notifications.toasts, + mockCoreStart.application, + 1000 + ); + }); + + describe('cancelPending', () => { + test('should abort all pending requests', async () => { + mockSearch.mockReturnValue(new Observable()); + + searchInterceptor.search(mockSearch, {}); + searchInterceptor.search(mockSearch, {}); + searchInterceptor.cancelPending(); + + await flushPromises(); + + const areAllRequestsAborted = mockSearch.mock.calls.every(([, { signal }]) => signal.aborted); + expect(areAllRequestsAborted).toBe(true); + }); + }); + + describe('runBeyondTimeout', () => { + test('should prevent the request from timing out', () => { + const mockResponse = new Subject(); + mockSearch.mockReturnValue(mockResponse.asObservable()); + const response = searchInterceptor.search(mockSearch, {}); + + setTimeout(searchInterceptor.runBeyondTimeout, 500); + setTimeout(() => mockResponse.next('hi'), 250); + setTimeout(() => mockResponse.complete(), 2000); + + const next = jest.fn(); + const complete = jest.fn(); + const error = jest.fn(); + response.subscribe({ next, error, complete }); + + jest.advanceTimersByTime(2000); + + expect(next).toHaveBeenCalledWith('hi'); + expect(error).not.toHaveBeenCalled(); + expect(complete).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts new file mode 100644 index 0000000000000..38452dee9a2da --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ApplicationStart, ToastsStart } from 'kibana/public'; +import { getLongQueryNotification } from './long_query_notification'; +import { SearchInterceptor } from '../../../../../src/plugins/data/public'; + +export class EnhancedSearchInterceptor extends SearchInterceptor { + /** + * This class should be instantiated with a `requestTimeout` corresponding with how many ms after + * requests are initiated that they should automatically cancel. + * @param toasts The `core.notifications.toasts` service + * @param application The `core.application` service + * @param requestTimeout Usually config value `elasticsearch.requestTimeout` + */ + constructor(toasts: ToastsStart, application: ApplicationStart, requestTimeout?: number) { + super(toasts, application, requestTimeout); + } + + /** + * Abort our `AbortController`, which in turn aborts any intercepted searches. + */ + public cancelPending = () => { + this.hideToast(); + this.abortController.abort(); + this.abortController = new AbortController(); + }; + + /** + * Un-schedule timing out all of the searches intercepted. + */ + public runBeyondTimeout = () => { + this.hideToast(); + this.timeoutSubscriptions.forEach(subscription => subscription.unsubscribe()); + this.timeoutSubscriptions.clear(); + }; + + protected showToast = () => { + if (this.longRunningToast) return; + this.longRunningToast = this.toasts.addInfo( + { + title: 'Your query is taking awhile', + text: getLongQueryNotification({ + cancel: this.cancelPending, + runBeyondTimeout: this.runBeyondTimeout, + }), + }, + { + toastLifeTimeMs: Infinity, + } + ); + }; +} diff --git a/x-pack/plugins/drilldowns/kibana.json b/x-pack/plugins/drilldowns/kibana.json index 8372d87166364..b951c7dc1fc87 100644 --- a/x-pack/plugins/drilldowns/kibana.json +++ b/x-pack/plugins/drilldowns/kibana.json @@ -3,5 +3,8 @@ "version": "kibana", "server": false, "ui": true, - "requiredPlugins": ["uiActions", "embeddable", "advancedUiActions"] + "requiredPlugins": [ + "uiActions", + "embeddable" + ] } diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx new file mode 100644 index 0000000000000..4834cc8081374 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { CoreStart } from 'src/core/public'; +import { ActionByType } from '../../../../../../src/plugins/ui_actions/public'; +import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; +import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; +import { FlyoutCreateDrilldown } from '../../components/flyout_create_drilldown'; + +export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; + +export interface FlyoutCreateDrilldownActionContext { + embeddable: IEmbeddable; +} + +export interface OpenFlyoutAddDrilldownParams { + overlays: () => Promise; +} + +export class FlyoutCreateDrilldownAction implements ActionByType { + public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN; + public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN; + public order = 100; + + constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {} + + public getDisplayName() { + return i18n.translate('xpack.drilldowns.FlyoutCreateDrilldownAction.displayName', { + defaultMessage: 'Create drilldown', + }); + } + + public getIconType() { + return 'plusInCircle'; + } + + public async isCompatible({ embeddable }: FlyoutCreateDrilldownActionContext) { + return embeddable.getInput().viewMode === 'edit'; + } + + public async execute(context: FlyoutCreateDrilldownActionContext) { + const overlays = await this.params.overlays(); + const handle = overlays.openFlyout( + toMountPoint( handle.close()} />) + ); + } +} diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx new file mode 100644 index 0000000000000..f109da94fcaca --- /dev/null +++ b/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx @@ -0,0 +1,72 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { CoreStart } from 'src/core/public'; +import { EuiNotificationBadge } from '@elastic/eui'; +import { ActionByType } from '../../../../../../src/plugins/ui_actions/public'; +import { + toMountPoint, + reactToUiComponent, +} from '../../../../../../src/plugins/kibana_react/public'; +import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; +import { FormCreateDrilldown } from '../../components/form_create_drilldown'; + +export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; + +export interface FlyoutEditDrilldownActionContext { + embeddable: IEmbeddable; +} + +export interface FlyoutEditDrilldownParams { + overlays: () => Promise; +} + +const displayName = i18n.translate('xpack.drilldowns.panel.openFlyoutEditDrilldown.displayName', { + defaultMessage: 'Manage drilldowns', +}); + +// mocked data +const drilldrownCount = 2; + +export class FlyoutEditDrilldownAction implements ActionByType { + public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN; + public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN; + public order = 100; + + constructor(protected readonly params: FlyoutEditDrilldownParams) {} + + public getDisplayName() { + return displayName; + } + + public getIconType() { + return 'list'; + } + + private ReactComp: React.FC<{ context: FlyoutEditDrilldownActionContext }> = () => { + return ( + <> + {displayName}{' '} + + {drilldrownCount} + + + ); + }; + + MenuItem = reactToUiComponent(this.ReactComp); + + public async isCompatible({ embeddable }: FlyoutEditDrilldownActionContext) { + return embeddable.getInput().viewMode === 'edit' && drilldrownCount > 0; + } + + public async execute({ embeddable }: FlyoutEditDrilldownActionContext) { + const overlays = await this.params.overlays(); + overlays.openFlyout(toMountPoint()); + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/index.ts b/x-pack/plugins/drilldowns/public/actions/index.ts similarity index 100% rename from x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/index.ts rename to x-pack/plugins/drilldowns/public/actions/index.ts diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx deleted file mode 100644 index 16b4d3a25d9e5..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; -import { - dashboardFactory, - urlFactory, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; -import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; -import { StubBrowserStorage } from '../../../../../../src/test_utils/public/stub_browser_storage'; -import { mockDynamicActionManager } from './test_data'; - -const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ - advancedUiActions: { - getActionFactories() { - return [dashboardFactory, urlFactory]; - }, - } as any, - storage: new Storage(new StubBrowserStorage()), - notifications: { - toasts: { - addError: (...args: any[]) => { - alert(JSON.stringify(args)); - }, - addSuccess: (...args: any[]) => { - alert(JSON.stringify(args)); - }, - } as any, - }, -}); - -storiesOf('components/FlyoutManageDrilldowns', module).add('default', () => ( - {}}> - - -)); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx deleted file mode 100644 index 6749b41e81fc7..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx +++ /dev/null @@ -1,221 +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 { cleanup, fireEvent, render, wait } from '@testing-library/react/pure'; -import '@testing-library/jest-dom/extend-expect'; -import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; -import { - dashboardFactory, - urlFactory, -} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; -import { StubBrowserStorage } from '../../../../../../src/test_utils/public/stub_browser_storage'; -import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; -import { mockDynamicActionManager } from './test_data'; -import { TEST_SUBJ_DRILLDOWN_ITEM } from '../list_manage_drilldowns'; -import { WELCOME_MESSAGE_TEST_SUBJ } from '../drilldown_hello_bar'; -import { coreMock } from '../../../../../../src/core/public/mocks'; -import { NotificationsStart } from 'kibana/public'; -import { toastDrilldownsCRUDError } from './i18n'; - -const storage = new Storage(new StubBrowserStorage()); -const notifications = coreMock.createStart().notifications; -const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ - advancedUiActions: { - getActionFactories() { - return [dashboardFactory, urlFactory]; - }, - } as any, - storage, - notifications, -}); - -// https://github.com/elastic/kibana/issues/59469 -afterEach(cleanup); - -beforeEach(() => { - storage.clear(); - (notifications.toasts as jest.Mocked).addSuccess.mockClear(); - (notifications.toasts as jest.Mocked).addError.mockClear(); -}); - -test('Allows to manage drilldowns', async () => { - const screen = render( - - ); - - // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); - - // no drilldowns in the list - expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0); - - fireEvent.click(screen.getByText(/Create new/i)); - - let [createHeading, createButton] = screen.getAllByText(/Create Drilldown/i); - expect(createHeading).toBeVisible(); - expect(screen.getByLabelText(/Back/i)).toBeVisible(); - - expect(createButton).toBeDisabled(); - - // input drilldown name - const name = 'Test name'; - fireEvent.change(screen.getByLabelText(/name/i), { - target: { value: name }, - }); - - // select URL one - fireEvent.click(screen.getByText(/Go to URL/i)); - - // Input url - const URL = 'https://elastic.co'; - fireEvent.change(screen.getByLabelText(/url/i), { - target: { value: URL }, - }); - - [createHeading, createButton] = screen.getAllByText(/Create Drilldown/i); - - expect(createButton).toBeEnabled(); - fireEvent.click(createButton); - - expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); - - await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(1)); - expect(screen.getByText(name)).toBeVisible(); - const editButton = screen.getByText(/edit/i); - fireEvent.click(editButton); - - expect(screen.getByText(/Edit Drilldown/i)).toBeVisible(); - // check that wizard is prefilled with current drilldown values - expect(screen.getByLabelText(/name/i)).toHaveValue(name); - expect(screen.getByLabelText(/url/i)).toHaveValue(URL); - - // input new drilldown name - const newName = 'New drilldown name'; - fireEvent.change(screen.getByLabelText(/name/i), { - target: { value: newName }, - }); - fireEvent.click(screen.getByText(/save/i)); - - expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); - await wait(() => screen.getByText(newName)); - - // delete drilldown from edit view - fireEvent.click(screen.getByText(/edit/i)); - fireEvent.click(screen.getByText(/delete/i)); - - expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); - await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); -}); - -test('Can delete multiple drilldowns', async () => { - const screen = render( - - ); - // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); - - const createDrilldown = async () => { - const oldCount = screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM).length; - fireEvent.click(screen.getByText(/Create new/i)); - fireEvent.change(screen.getByLabelText(/name/i), { - target: { value: 'test' }, - }); - fireEvent.click(screen.getByText(/Go to URL/i)); - fireEvent.change(screen.getByLabelText(/url/i), { - target: { value: 'https://elastic.co' }, - }); - fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); - await wait(() => - expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(oldCount + 1) - ); - }; - - await createDrilldown(); - await createDrilldown(); - await createDrilldown(); - - const checkboxes = screen.getAllByLabelText(/Select this drilldown/i); - expect(checkboxes).toHaveLength(3); - checkboxes.forEach(checkbox => fireEvent.click(checkbox)); - expect(screen.queryByText(/Create/i)).not.toBeInTheDocument(); - fireEvent.click(screen.getByText(/Delete \(3\)/i)); - - await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); -}); - -test('Create only mode', async () => { - const onClose = jest.fn(); - const screen = render( - - ); - // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); - fireEvent.change(screen.getByLabelText(/name/i), { - target: { value: 'test' }, - }); - fireEvent.click(screen.getByText(/Go to URL/i)); - fireEvent.change(screen.getByLabelText(/url/i), { - target: { value: 'https://elastic.co' }, - }); - fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); - - await wait(() => expect(notifications.toasts.addSuccess).toBeCalled()); - expect(onClose).toBeCalled(); - expect(await mockDynamicActionManager.state.get().events.length).toBe(1); -}); - -test.todo("Error when can't fetch drilldown list"); - -test("Error when can't save drilldown changes", async () => { - const error = new Error('Oops'); - jest.spyOn(mockDynamicActionManager, 'createEvent').mockImplementationOnce(async () => { - throw error; - }); - const screen = render( - - ); - // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); - fireEvent.click(screen.getByText(/Create new/i)); - fireEvent.change(screen.getByLabelText(/name/i), { - target: { value: 'test' }, - }); - fireEvent.click(screen.getByText(/Go to URL/i)); - fireEvent.change(screen.getByLabelText(/url/i), { - target: { value: 'https://elastic.co' }, - }); - fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); - await wait(() => - expect(notifications.toasts.addError).toBeCalledWith(error, { title: toastDrilldownsCRUDError }) - ); -}); - -test('Should show drilldown welcome message. Should be able to dismiss it', async () => { - let screen = render( - - ); - - // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); - - expect(screen.getByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeVisible(); - fireEvent.click(screen.getByText(/hide/i)); - expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); - cleanup(); - - screen = render( - - ); - // wait for initial render. It is async because resolving compatible action factories is async - await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); - expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); -}); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx deleted file mode 100644 index f22ccc2f26f02..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx +++ /dev/null @@ -1,332 +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, { useEffect, useState } from 'react'; -import useMountedState from 'react-use/lib/useMountedState'; -import { - AdvancedUiActionsActionFactory as ActionFactory, - AdvancedUiActionsStart, -} from '../../../../advanced_ui_actions/public'; -import { NotificationsStart } from '../../../../../../src/core/public'; -import { DrilldownWizardConfig, FlyoutDrilldownWizard } from '../flyout_drilldown_wizard'; -import { FlyoutListManageDrilldowns } from '../flyout_list_manage_drilldowns'; -import { IStorageWrapper } from '../../../../../../src/plugins/kibana_utils/public'; -import { - DynamicActionManager, - UiActionsSerializedEvent, - UiActionsSerializedAction, - VALUE_CLICK_TRIGGER, - SELECT_RANGE_TRIGGER, - TriggerContextMapping, -} from '../../../../../../src/plugins/ui_actions/public'; -import { useContainerState } from '../../../../../../src/plugins/kibana_utils/common'; -import { DrilldownListItem } from '../list_manage_drilldowns'; -import { - toastDrilldownCreated, - toastDrilldownDeleted, - toastDrilldownEdited, - toastDrilldownsCRUDError, - toastDrilldownsDeleted, -} from './i18n'; -import { DrilldownFactoryContext } from '../../types'; - -interface ConnectedFlyoutManageDrilldownsProps { - placeContext: Context; - dynamicActionManager: DynamicActionManager; - viewMode?: 'create' | 'manage'; - onClose?: () => void; -} - -/** - * Represent current state (route) of FlyoutManageDrilldowns - */ -enum Routes { - Manage = 'manage', - Create = 'create', - Edit = 'edit', -} - -export function createFlyoutManageDrilldowns({ - advancedUiActions, - storage, - notifications, -}: { - advancedUiActions: AdvancedUiActionsStart; - storage: IStorageWrapper; - notifications: NotificationsStart; -}) { - // fine to assume this is static, - // because all action factories should be registered in setup phase - const allActionFactories = advancedUiActions.getActionFactories(); - const allActionFactoriesById = allActionFactories.reduce((acc, next) => { - acc[next.id] = next; - return acc; - }, {} as Record); - - return (props: ConnectedFlyoutManageDrilldownsProps) => { - const isCreateOnly = props.viewMode === 'create'; - - const selectedTriggers: Array = React.useMemo( - () => [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER], - [] - ); - - const factoryContext: DrilldownFactoryContext = React.useMemo( - () => ({ - placeContext: props.placeContext, - triggers: selectedTriggers, - }), - [props.placeContext, selectedTriggers] - ); - - const actionFactories = useCompatibleActionFactoriesForCurrentContext( - allActionFactories, - factoryContext - ); - - const [route, setRoute] = useState( - () => (isCreateOnly ? Routes.Create : Routes.Manage) // initial state is different depending on `viewMode` - ); - const [currentEditId, setCurrentEditId] = useState(null); - - const [shouldShowWelcomeMessage, onHideWelcomeMessage] = useWelcomeMessage(storage); - - const { - drilldowns, - createDrilldown, - editDrilldown, - deleteDrilldown, - } = useDrilldownsStateManager(props.dynamicActionManager, notifications); - - /** - * isCompatible promise is not yet resolved. - * Skip rendering until it is resolved - */ - if (!actionFactories) return null; - /** - * Drilldowns are not fetched yet or error happened during fetching - * In case of error user is notified with toast - */ - if (!drilldowns) return null; - - /** - * Needed for edit mode to prefill wizard fields with data from current edited drilldown - */ - function resolveInitialDrilldownWizardConfig(): DrilldownWizardConfig | undefined { - if (route !== Routes.Edit) return undefined; - if (!currentEditId) return undefined; - const drilldownToEdit = drilldowns?.find(d => d.eventId === currentEditId); - if (!drilldownToEdit) return undefined; - - return { - actionFactory: allActionFactoriesById[drilldownToEdit.action.factoryId], - actionConfig: drilldownToEdit.action.config as object, // TODO: config is unknown, but we know it always extends object - name: drilldownToEdit.action.name, - }; - } - - /** - * Maps drilldown to list item view model - */ - function mapToDrilldownToDrilldownListItem( - drilldown: UiActionsSerializedEvent - ): DrilldownListItem { - const actionFactory = allActionFactoriesById[drilldown.action.factoryId]; - return { - id: drilldown.eventId, - drilldownName: drilldown.action.name, - actionName: actionFactory?.getDisplayName(factoryContext) ?? drilldown.action.factoryId, - icon: actionFactory?.getIconType(factoryContext), - }; - } - - switch (route) { - case Routes.Create: - case Routes.Edit: - return ( - setRoute(Routes.Manage)} - onSubmit={({ actionConfig, actionFactory, name }) => { - if (route === Routes.Create) { - createDrilldown( - { - name, - config: actionConfig, - factoryId: actionFactory.id, - }, - selectedTriggers - ); - } else { - editDrilldown( - currentEditId!, - { - name, - config: actionConfig, - factoryId: actionFactory.id, - }, - selectedTriggers - ); - } - - if (isCreateOnly) { - if (props.onClose) { - props.onClose(); - } - } else { - setRoute(Routes.Manage); - } - - setCurrentEditId(null); - }} - onDelete={() => { - deleteDrilldown(currentEditId!); - setRoute(Routes.Manage); - setCurrentEditId(null); - }} - actionFactoryContext={factoryContext} - initialDrilldownWizardConfig={resolveInitialDrilldownWizardConfig()} - /> - ); - - case Routes.Manage: - default: - return ( - { - setCurrentEditId(null); - deleteDrilldown(ids); - }} - onEdit={id => { - setCurrentEditId(id); - setRoute(Routes.Edit); - }} - onCreate={() => { - setCurrentEditId(null); - setRoute(Routes.Create); - }} - onClose={props.onClose} - /> - ); - } - }; -} - -function useCompatibleActionFactoriesForCurrentContext( - actionFactories: Array>, - context: Context -) { - const [compatibleActionFactories, setCompatibleActionFactories] = useState< - Array> - >(); - useEffect(() => { - let canceled = false; - async function updateCompatibleFactoriesForContext() { - const compatibility = await Promise.all( - actionFactories.map(factory => factory.isCompatible(context)) - ); - if (canceled) return; - setCompatibleActionFactories(actionFactories.filter((_, i) => compatibility[i])); - } - updateCompatibleFactoriesForContext(); - return () => { - canceled = true; - }; - }, [context, actionFactories]); - - return compatibleActionFactories; -} - -function useWelcomeMessage(storage: IStorageWrapper): [boolean, () => void] { - const key = `drilldowns:hidWelcomeMessage`; - const [hidWelcomeMessage, setHidWelcomeMessage] = useState(storage.get(key) ?? false); - - return [ - !hidWelcomeMessage, - () => { - if (hidWelcomeMessage) return; - setHidWelcomeMessage(true); - storage.set(key, true); - }, - ]; -} - -function useDrilldownsStateManager( - actionManager: DynamicActionManager, - notifications: NotificationsStart -) { - const { events: drilldowns } = useContainerState(actionManager.state); - const [isLoading, setIsLoading] = useState(false); - const isMounted = useMountedState(); - - async function run(op: () => Promise) { - setIsLoading(true); - try { - await op(); - } catch (e) { - notifications.toasts.addError(e, { - title: toastDrilldownsCRUDError, - }); - if (!isMounted) return; - setIsLoading(false); - return; - } - } - - async function createDrilldown( - action: UiActionsSerializedAction, - selectedTriggers: Array - ) { - await run(async () => { - await actionManager.createEvent(action, selectedTriggers); - notifications.toasts.addSuccess({ - title: toastDrilldownCreated.title, - text: toastDrilldownCreated.text(action.name), - }); - }); - } - - async function editDrilldown( - drilldownId: string, - action: UiActionsSerializedAction, - selectedTriggers: Array - ) { - await run(async () => { - await actionManager.updateEvent(drilldownId, action, selectedTriggers); - notifications.toasts.addSuccess({ - title: toastDrilldownEdited.title, - text: toastDrilldownEdited.text(action.name), - }); - }); - } - - async function deleteDrilldown(drilldownIds: string | string[]) { - await run(async () => { - drilldownIds = Array.isArray(drilldownIds) ? drilldownIds : [drilldownIds]; - await actionManager.deleteEvents(drilldownIds); - notifications.toasts.addSuccess( - drilldownIds.length === 1 - ? { - title: toastDrilldownDeleted.title, - text: toastDrilldownDeleted.text, - } - : { - title: toastDrilldownsDeleted.title, - text: toastDrilldownsDeleted.text(drilldownIds.length), - } - ); - }); - } - - return { drilldowns, isLoading, createDrilldown, editDrilldown, deleteDrilldown }; -} diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts deleted file mode 100644 index 70f4d735e2a74..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts +++ /dev/null @@ -1,88 +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 { i18n } from '@kbn/i18n'; - -export const toastDrilldownCreated = { - title: i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle', - { - defaultMessage: 'Drilldown created', - } - ), - text: (drilldownName: string) => - i18n.translate('xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText', { - defaultMessage: 'You created "{drilldownName}"', - values: { - drilldownName, - }, - }), -}; - -export const toastDrilldownEdited = { - title: i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle', - { - defaultMessage: 'Drilldown edited', - } - ), - text: (drilldownName: string) => - i18n.translate('xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText', { - defaultMessage: 'You edited "{drilldownName}"', - values: { - drilldownName, - }, - }), -}; - -export const toastDrilldownDeleted = { - title: i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle', - { - defaultMessage: 'Drilldown deleted', - } - ), - text: i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText', - { - defaultMessage: 'You deleted a drilldown', - } - ), -}; - -export const toastDrilldownsDeleted = { - title: i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle', - { - defaultMessage: 'Drilldowns deleted', - } - ), - text: (n: number) => - i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText', - { - defaultMessage: 'You deleted {n} drilldowns', - values: { - n, - }, - } - ), -}; - -export const toastDrilldownsCRUDError = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle', - { - defaultMessage: 'Error saving drilldown', - description: 'Title for generic error toast when persisting drilldown updates failed', - } -); - -export const toastDrilldownsFetchError = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsFetchErrorTitle', - { - defaultMessage: 'Error fetching drilldowns', - } -); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts deleted file mode 100644 index f084a3e563c23..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export * from './connected_flyout_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts deleted file mode 100644 index b8deaa8b842bc..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts +++ /dev/null @@ -1,89 +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 uuid from 'uuid'; -import { - DynamicActionManager, - DynamicActionManagerState, - UiActionsSerializedAction, - TriggerContextMapping, -} from '../../../../../../src/plugins/ui_actions/public'; -import { createStateContainer } from '../../../../../../src/plugins/kibana_utils/common'; - -class MockDynamicActionManager implements PublicMethodsOf { - public readonly state = createStateContainer({ - isFetchingEvents: false, - fetchCount: 0, - events: [], - }); - - async count() { - return this.state.get().events.length; - } - - async list() { - return this.state.get().events; - } - - async createEvent( - action: UiActionsSerializedAction, - triggers: Array - ) { - const event = { - action, - triggers, - eventId: uuid(), - }; - const state = this.state.get(); - this.state.set({ - ...state, - events: [...state.events, event], - }); - } - - async deleteEvents(eventIds: string[]) { - const state = this.state.get(); - let events = state.events; - - eventIds.forEach(id => { - events = events.filter(e => e.eventId !== id); - }); - - this.state.set({ - ...state, - events, - }); - } - - async updateEvent( - eventId: string, - action: UiActionsSerializedAction, - triggers: Array - ) { - const state = this.state.get(); - const events = state.events; - const idx = events.findIndex(e => e.eventId === eventId); - const event = { - eventId, - action, - triggers, - }; - - this.state.set({ - ...state, - events: [...events.slice(0, idx), event, ...events.slice(idx + 1)], - }); - } - - async deleteEvent() { - throw new Error('not implemented'); - } - - async start() {} - async stop() {} -} - -export const mockDynamicActionManager = (new MockDynamicActionManager() as unknown) as DynamicActionManager; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx index c4a4630397f1c..7a9e19342f27c 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx @@ -8,16 +8,6 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; import { DrilldownHelloBar } from '.'; -const Demo = () => { - const [show, setShow] = React.useState(true); - return show ? ( - { - setShow(false); - }} - /> - ) : null; -}; - -storiesOf('components/DrilldownHelloBar', module).add('default', () => ); +storiesOf('components/DrilldownHelloBar', module).add('default', () => { + return ; +}); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx index 8c6739a8ad6c8..1ef714f7b86e2 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx @@ -5,58 +5,22 @@ */ import React from 'react'; -import { - EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiTextColor, - EuiText, - EuiLink, - EuiSpacer, - EuiButtonEmpty, - EuiIcon, -} from '@elastic/eui'; -import { txtHideHelpButtonLabel, txtHelpText, txtViewDocsLinkLabel } from './i18n'; export interface DrilldownHelloBarProps { docsLink?: string; - onHideClick?: () => void; } -export const WELCOME_MESSAGE_TEST_SUBJ = 'drilldowns-welcome-message-test-subj'; - -export const DrilldownHelloBar: React.FC = ({ - docsLink, - onHideClick = () => {}, -}) => { +/** + * @todo https://github.com/elastic/kibana/issues/55311 + */ +export const DrilldownHelloBar: React.FC = ({ docsLink }) => { return ( - - -
- -
-
- - - {txtHelpText} - - {docsLink && ( - <> - - {txtViewDocsLinkLabel} - - )} - - - - {txtHideHelpButtonLabel} - - - - } - /> +
); }; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts deleted file mode 100644 index 63dc95dabc0fb..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts +++ /dev/null @@ -1,29 +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 { i18n } from '@kbn/i18n'; - -export const txtHelpText = i18n.translate( - 'xpack.drilldowns.components.DrilldownHelloBar.helpText', - { - defaultMessage: - 'Drilldowns provide the ability to define a new behavior when interacting with a panel. You can add multiple options or simply override the default filtering behavior.', - } -); - -export const txtViewDocsLinkLabel = i18n.translate( - 'xpack.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel', - { - defaultMessage: 'View docs', - } -); - -export const txtHideHelpButtonLabel = i18n.translate( - 'xpack.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel', - { - defaultMessage: 'Hide', - } -); diff --git a/x-pack/plugins/dashboard_enhanced/scripts/storybook.js b/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx similarity index 53% rename from x-pack/plugins/dashboard_enhanced/scripts/storybook.js rename to x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx index f2cbe4135f4cb..5627a5d6f4522 100644 --- a/x-pack/plugins/dashboard_enhanced/scripts/storybook.js +++ b/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { join } from 'path'; +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { DrilldownPicker } from '.'; -// eslint-disable-next-line -require('@kbn/storybook').runStorybookCli({ - name: 'dashboard_enhanced', - storyGlobs: [join(__dirname, '..', 'public', 'components', '**', '*.story.tsx')], +storiesOf('components/DrilldownPicker', module).add('default', () => { + return ; }); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx new file mode 100644 index 0000000000000..3748fc666c81c --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx @@ -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 React from 'react'; + +// eslint-disable-next-line +export interface DrilldownPickerProps {} + +export const DrilldownPicker: React.FC = () => { + return ( + + ); +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/index.ts b/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx similarity index 87% rename from x-pack/plugins/dashboard_enhanced/public/services/index.ts rename to x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx index 8cc3e12906531..3be289fe6d46e 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/index.ts +++ b/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './drilldowns'; +export * from './drilldown_picker'; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx new file mode 100644 index 0000000000000..4f024b7d9cd6a --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-console */ + +import * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { FlyoutCreateDrilldown } from '.'; + +storiesOf('components/FlyoutCreateDrilldown', module) + .add('default', () => { + return ; + }) + .add('open in flyout', () => { + return ( + + + + ); + }); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx new file mode 100644 index 0000000000000..b45ac9197c7e0 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx @@ -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 React from 'react'; +import { EuiButton } from '@elastic/eui'; +import { FormCreateDrilldown } from '../form_create_drilldown'; +import { FlyoutFrame } from '../flyout_frame'; +import { txtCreateDrilldown } from './i18n'; +import { FlyoutCreateDrilldownActionContext } from '../../actions'; + +export interface FlyoutCreateDrilldownProps { + context: FlyoutCreateDrilldownActionContext; + onClose?: () => void; +} + +export const FlyoutCreateDrilldown: React.FC = ({ + context, + onClose, +}) => { + const footer = ( + {}} fill> + {txtCreateDrilldown} + + ); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts similarity index 62% rename from x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts rename to x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts index 0dd4e37d4dddd..ceabc6d3a9aa5 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts +++ b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts @@ -6,9 +6,9 @@ import { i18n } from '@kbn/i18n'; -export const txtManageDrilldowns = i18n.translate( - 'xpack.drilldowns.components.FlyoutListManageDrilldowns.manageDrilldownsTitle', +export const txtCreateDrilldown = i18n.translate( + 'xpack.drilldowns.components.FlyoutCreateDrilldown.CreateDrilldown', { - defaultMessage: 'Manage Drilldowns', + defaultMessage: 'Create drilldown', } ); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts similarity index 84% rename from x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts rename to x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts index 96ed23bf112c9..ce235043b4ef6 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts +++ b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './flyout_drilldown_wizard'; +export * from './flyout_create_drilldown'; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx deleted file mode 100644 index 152cd393b9d3e..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx +++ /dev/null @@ -1,70 +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. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import { FlyoutDrilldownWizard } from '.'; -import { - dashboardFactory, - urlFactory, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; - -storiesOf('components/FlyoutDrilldownWizard', module) - .add('default', () => { - return ; - }) - .add('open in flyout - create', () => { - return ( - {}}> - {}} - drilldownActionFactories={[urlFactory, dashboardFactory]} - /> - - ); - }) - .add('open in flyout - edit', () => { - return ( - {}}> - {}} - drilldownActionFactories={[urlFactory, dashboardFactory]} - initialDrilldownWizardConfig={{ - name: 'My fancy drilldown', - actionFactory: urlFactory as any, - actionConfig: { - url: 'https://elastic.co', - openInNewTab: true, - }, - }} - mode={'edit'} - /> - - ); - }) - .add('open in flyout - edit, just 1 action type', () => { - return ( - {}}> - {}} - drilldownActionFactories={[dashboardFactory]} - initialDrilldownWizardConfig={{ - name: 'My fancy drilldown', - actionFactory: urlFactory as any, - actionConfig: { - url: 'https://elastic.co', - openInNewTab: true, - }, - }} - mode={'edit'} - /> - - ); - }); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx deleted file mode 100644 index faa965a98a4bb..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx +++ /dev/null @@ -1,139 +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, { useState } from 'react'; -import { EuiButton, EuiSpacer } from '@elastic/eui'; -import { FormDrilldownWizard } from '../form_drilldown_wizard'; -import { FlyoutFrame } from '../flyout_frame'; -import { - txtCreateDrilldownButtonLabel, - txtCreateDrilldownTitle, - txtDeleteDrilldownButtonLabel, - txtEditDrilldownButtonLabel, - txtEditDrilldownTitle, -} from './i18n'; -import { DrilldownHelloBar } from '../drilldown_hello_bar'; -import { AdvancedUiActionsActionFactory as ActionFactory } from '../../../../advanced_ui_actions/public'; - -export interface DrilldownWizardConfig { - name: string; - actionFactory?: ActionFactory; - actionConfig?: ActionConfig; -} - -export interface FlyoutDrilldownWizardProps { - drilldownActionFactories: Array>; - - onSubmit?: (drilldownWizardConfig: Required) => void; - onDelete?: () => void; - onClose?: () => void; - onBack?: () => void; - - mode?: 'create' | 'edit'; - initialDrilldownWizardConfig?: DrilldownWizardConfig; - - showWelcomeMessage?: boolean; - onWelcomeHideClick?: () => void; - - actionFactoryContext?: object; -} - -export function FlyoutDrilldownWizard({ - onClose, - onBack, - onSubmit = () => {}, - initialDrilldownWizardConfig, - mode = 'create', - onDelete = () => {}, - showWelcomeMessage = true, - onWelcomeHideClick, - drilldownActionFactories, - actionFactoryContext, -}: FlyoutDrilldownWizardProps) { - const [wizardConfig, setWizardConfig] = useState( - () => - initialDrilldownWizardConfig ?? { - name: '', - } - ); - - const isActionValid = ( - config: DrilldownWizardConfig - ): config is Required => { - if (!wizardConfig.name) return false; - if (!wizardConfig.actionFactory) return false; - if (!wizardConfig.actionConfig) return false; - - return wizardConfig.actionFactory.isConfigValid(wizardConfig.actionConfig); - }; - - const footer = ( - { - if (isActionValid(wizardConfig)) { - onSubmit(wizardConfig); - } - }} - fill - isDisabled={!isActionValid(wizardConfig)} - > - {mode === 'edit' ? txtEditDrilldownButtonLabel : txtCreateDrilldownButtonLabel} - - ); - - return ( - } - > - { - setWizardConfig({ - ...wizardConfig, - name: newName, - }); - }} - actionConfig={wizardConfig.actionConfig} - onActionConfigChange={newActionConfig => { - setWizardConfig({ - ...wizardConfig, - actionConfig: newActionConfig, - }); - }} - currentActionFactory={wizardConfig.actionFactory} - onActionFactoryChange={actionFactory => { - if (!actionFactory) { - setWizardConfig({ - ...wizardConfig, - actionFactory: undefined, - actionConfig: undefined, - }); - } else { - setWizardConfig({ - ...wizardConfig, - actionFactory, - actionConfig: actionFactory.createConfig(), - }); - } - }} - actionFactories={drilldownActionFactories} - actionFactoryContext={actionFactoryContext!} - /> - {mode === 'edit' && ( - <> - - - {txtDeleteDrilldownButtonLabel} - - - )} - - ); -} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts deleted file mode 100644 index a4a2754a444ab..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts +++ /dev/null @@ -1,42 +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 { i18n } from '@kbn/i18n'; - -export const txtCreateDrilldownTitle = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle', - { - defaultMessage: 'Create Drilldown', - } -); - -export const txtEditDrilldownTitle = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle', - { - defaultMessage: 'Edit Drilldown', - } -); - -export const txtCreateDrilldownButtonLabel = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownButtonLabel', - { - defaultMessage: 'Create drilldown', - } -); - -export const txtEditDrilldownButtonLabel = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownButtonLabel', - { - defaultMessage: 'Save', - } -); - -export const txtDeleteDrilldownButtonLabel = i18n.translate( - 'xpack.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel', - { - defaultMessage: 'Delete drilldown', - } -); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx index cb223db556f56..2715637f6392f 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx @@ -21,13 +21,6 @@ storiesOf('components/FlyoutFrame', module) .add('with onClose', () => { return console.log('onClose')}>test; }) - .add('with onBack', () => { - return ( - console.log('onClose')} title={'Title'}> - test - - ); - }) .add('custom footer', () => { return click me!}>test; }) diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx index 0a3989487745f..b5fb52fcf5c18 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx @@ -6,11 +6,9 @@ import React from 'react'; import { render } from 'react-dom'; -import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; +import { render as renderTestingLibrary, fireEvent } from '@testing-library/react'; import { FlyoutFrame } from '.'; -afterEach(cleanup); - describe('', () => { test('renders without crashing', () => { const div = document.createElement('div'); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx index b55cbd88d0dc0..2945cfd739482 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx @@ -13,16 +13,13 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, - EuiButtonIcon, } from '@elastic/eui'; -import { txtClose, txtBack } from './i18n'; +import { txtClose } from './i18n'; export interface FlyoutFrameProps { title?: React.ReactNode; footer?: React.ReactNode; - banner?: React.ReactNode; onClose?: () => void; - onBack?: () => void; } /** @@ -33,31 +30,11 @@ export const FlyoutFrame: React.FC = ({ footer, onClose, children, - onBack, - banner, }) => { - const headerFragment = (title || onBack) && ( + const headerFragment = title && ( - - {onBack && ( - -
- -
-
- )} - {title && ( - -

{title}

-
- )} -
+

{title}

); @@ -87,7 +64,7 @@ export const FlyoutFrame: React.FC = ({ return ( <> {headerFragment} - {children} + {children} {footerFragment} ); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts index 23af89ebf9bc7..257d7d36dbee1 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts @@ -6,10 +6,6 @@ import { i18n } from '@kbn/i18n'; -export const txtClose = i18n.translate('xpack.drilldowns.components.FlyoutFrame.CloseButtonLabel', { +export const txtClose = i18n.translate('xpack.drilldowns.components.FlyoutFrame.Close', { defaultMessage: 'Close', }); - -export const txtBack = i18n.translate('xpack.drilldowns.components.FlyoutFrame.BackButtonLabel', { - defaultMessage: 'Back', -}); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx deleted file mode 100644 index 0529f0451b16a..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import { FlyoutListManageDrilldowns } from './flyout_list_manage_drilldowns'; - -storiesOf('components/FlyoutListManageDrilldowns', module).add('default', () => ( - {}}> - - -)); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx deleted file mode 100644 index a44a7ccccb4dc..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx +++ /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 React from 'react'; -import { FlyoutFrame } from '../flyout_frame'; -import { DrilldownListItem, ListManageDrilldowns } from '../list_manage_drilldowns'; -import { txtManageDrilldowns } from './i18n'; -import { DrilldownHelloBar } from '../drilldown_hello_bar'; - -export interface FlyoutListManageDrilldownsProps { - drilldowns: DrilldownListItem[]; - onClose?: () => void; - onCreate?: () => void; - onEdit?: (drilldownId: string) => void; - onDelete?: (drilldownIds: string[]) => void; - showWelcomeMessage?: boolean; - onWelcomeHideClick?: () => void; -} - -export function FlyoutListManageDrilldowns({ - drilldowns, - onClose = () => {}, - onCreate, - onDelete, - onEdit, - showWelcomeMessage = true, - onWelcomeHideClick, -}: FlyoutListManageDrilldownsProps) { - return ( - } - > - - - ); -} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts deleted file mode 100644 index f8c9d224fb292..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export * from './flyout_list_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx new file mode 100644 index 0000000000000..e7e1d67473e8c --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx @@ -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. + */ + +/* eslint-disable no-console */ + +import * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { FormCreateDrilldown } from '.'; + +const DemoEditName: React.FC = () => { + const [name, setName] = React.useState(''); + + return ; +}; + +storiesOf('components/FormCreateDrilldown', module) + .add('default', () => { + return ; + }) + .add('[name=foobar]', () => { + return ; + }) + .add('can edit name', () => ) + .add('open in flyout', () => { + return ( + + + + ); + }); diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx similarity index 70% rename from x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx rename to x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx index 4560773cc8a6d..6691966e47e64 100644 --- a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx +++ b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx @@ -6,23 +6,21 @@ import React from 'react'; import { render } from 'react-dom'; -import { FormDrilldownWizard } from './form_drilldown_wizard'; -import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; +import { FormCreateDrilldown } from '.'; +import { render as renderTestingLibrary, fireEvent } from '@testing-library/react'; import { txtNameOfDrilldown } from './i18n'; -afterEach(cleanup); - -describe('', () => { +describe('', () => { test('renders without crashing', () => { const div = document.createElement('div'); - render( {}} actionFactoryContext={{}} />, div); + render( {}} />, div); }); describe('[name=]', () => { test('if name not provided, uses to empty string', () => { const div = document.createElement('div'); - render(, div); + render(, div); const input = div.querySelector( '[data-test-subj="dynamicActionNameInput"]' @@ -31,10 +29,10 @@ describe('', () => { expect(input?.value).toBe(''); }); - test('can set initial name input field value', () => { + test('can set name input field value', () => { const div = document.createElement('div'); - render(, div); + render(, div); const input = div.querySelector( '[data-test-subj="dynamicActionNameInput"]' @@ -42,7 +40,7 @@ describe('', () => { expect(input?.value).toBe('foo'); - render(, div); + render(, div); expect(input?.value).toBe('bar'); }); @@ -50,7 +48,7 @@ describe('', () => { test('fires onNameChange callback on name change', () => { const onNameChange = jest.fn(); const utils = renderTestingLibrary( - + ); const input = utils.getByLabelText(txtNameOfDrilldown); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx new file mode 100644 index 0000000000000..4422de604092b --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiForm, EuiFormRow, EuiFieldText } from '@elastic/eui'; +import { DrilldownHelloBar } from '../drilldown_hello_bar'; +import { txtNameOfDrilldown, txtUntitledDrilldown, txtDrilldownAction } from './i18n'; +import { DrilldownPicker } from '../drilldown_picker'; + +const noop = () => {}; + +export interface FormCreateDrilldownProps { + name?: string; + onNameChange?: (name: string) => void; +} + +export const FormCreateDrilldown: React.FC = ({ + name = '', + onNameChange = noop, +}) => { + const nameFragment = ( + + onNameChange(event.target.value)} + data-test-subj="dynamicActionNameInput" + /> + + ); + + const triggerPicker =
Trigger Picker will be here
; + const actionPicker = ( + + + + ); + + return ( + <> + + {nameFragment} + {triggerPicker} + {actionPicker} + + ); +}; diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts similarity index 89% rename from x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts rename to x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts index e9b19ab0afa97..4c0e287935edd 100644 --- a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts +++ b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const txtNameOfDrilldown = i18n.translate( 'xpack.drilldowns.components.FormCreateDrilldown.nameOfDrilldown', { - defaultMessage: 'Name', + defaultMessage: 'Name of drilldown', } ); @@ -23,6 +23,6 @@ export const txtUntitledDrilldown = i18n.translate( export const txtDrilldownAction = i18n.translate( 'xpack.drilldowns.components.FormCreateDrilldown.drilldownAction', { - defaultMessage: 'Action', + defaultMessage: 'Drilldown action', } ); diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx similarity index 85% rename from x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx rename to x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx index 4aea824de00d7..c2c5a7e435b39 100644 --- a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx +++ b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './form_drilldown_wizard'; +export * from './form_create_drilldown'; diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx deleted file mode 100644 index 2fc35eb6b5298..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { FormDrilldownWizard } from '.'; - -const DemoEditName: React.FC = () => { - const [name, setName] = React.useState(''); - - return ( - <> - {' '} -
name: {name}
- - ); -}; - -storiesOf('components/FormDrilldownWizard', module) - .add('default', () => { - return ; - }) - .add('[name=foobar]', () => { - return ; - }) - .add('can edit name', () => ); diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx deleted file mode 100644 index bdafaaf07873c..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx +++ /dev/null @@ -1,79 +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 { EuiFieldText, EuiForm, EuiFormRow, EuiSpacer } from '@elastic/eui'; -import { txtDrilldownAction, txtNameOfDrilldown, txtUntitledDrilldown } from './i18n'; -import { - AdvancedUiActionsActionFactory as ActionFactory, - ActionWizard, -} from '../../../../advanced_ui_actions/public'; - -const noopFn = () => {}; - -export interface FormDrilldownWizardProps { - name?: string; - onNameChange?: (name: string) => void; - - currentActionFactory?: ActionFactory; - onActionFactoryChange?: (actionFactory: ActionFactory | null) => void; - actionFactoryContext: object; - - actionConfig?: object; - onActionConfigChange?: (config: object) => void; - - actionFactories?: ActionFactory[]; -} - -export const FormDrilldownWizard: React.FC = ({ - name = '', - actionConfig, - currentActionFactory, - onNameChange = noopFn, - onActionConfigChange = noopFn, - onActionFactoryChange = noopFn, - actionFactories = [], - actionFactoryContext, -}) => { - const nameFragment = ( - - onNameChange(event.target.value)} - data-test-subj="dynamicActionNameInput" - /> - - ); - - const actionWizard = ( - 1 ? txtDrilldownAction : undefined} - fullWidth={true} - > - onActionFactoryChange(actionFactory)} - onConfigChange={config => onActionConfigChange(config)} - context={actionFactoryContext} - /> - - ); - - return ( - <> - - {nameFragment} - - {actionWizard} - - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts deleted file mode 100644 index fbc7c9dcfb4a1..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts +++ /dev/null @@ -1,36 +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 { i18n } from '@kbn/i18n'; - -export const txtCreateDrilldown = i18n.translate( - 'xpack.drilldowns.components.ListManageDrilldowns.createDrilldownButtonLabel', - { - defaultMessage: 'Create new', - } -); - -export const txtEditDrilldown = i18n.translate( - 'xpack.drilldowns.components.ListManageDrilldowns.editDrilldownButtonLabel', - { - defaultMessage: 'Edit', - } -); - -export const txtDeleteDrilldowns = (count: number) => - i18n.translate('xpack.drilldowns.components.ListManageDrilldowns.deleteDrilldownsButtonLabel', { - defaultMessage: 'Delete ({count})', - values: { - count, - }, - }); - -export const txtSelectDrilldown = i18n.translate( - 'xpack.drilldowns.components.ListManageDrilldowns.selectThisDrilldownCheckboxLabel', - { - defaultMessage: 'Select this drilldown', - } -); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx deleted file mode 100644 index 82b6ce27af6d4..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx +++ /dev/null @@ -1,7 +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. - */ - -export * from './list_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx deleted file mode 100644 index eafe50bab2016..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx +++ /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 * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { ListManageDrilldowns } from './list_manage_drilldowns'; - -storiesOf('components/ListManageDrilldowns', module).add('default', () => ( - -)); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx deleted file mode 100644 index 4a4d67b08b1d3..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx +++ /dev/null @@ -1,70 +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 { cleanup, fireEvent, render } from '@testing-library/react/pure'; -import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global -import { - DrilldownListItem, - ListManageDrilldowns, - TEST_SUBJ_DRILLDOWN_ITEM, -} from './list_manage_drilldowns'; - -// TODO: for some reason global cleanup from RTL doesn't work -// afterEach is not available for it globally during setup -afterEach(cleanup); - -const drilldowns: DrilldownListItem[] = [ - { id: '1', actionName: 'Dashboard', drilldownName: 'Drilldown 1' }, - { id: '2', actionName: 'Dashboard', drilldownName: 'Drilldown 2' }, - { id: '3', actionName: 'Dashboard', drilldownName: 'Drilldown 3' }, -]; - -test('Render list of drilldowns', () => { - const screen = render(); - expect(screen.getAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(drilldowns.length); -}); - -test('Emit onEdit() when clicking on edit drilldown', () => { - const fn = jest.fn(); - const screen = render(); - - const editButtons = screen.getAllByText('Edit'); - expect(editButtons).toHaveLength(drilldowns.length); - fireEvent.click(editButtons[1]); - expect(fn).toBeCalledWith(drilldowns[1].id); -}); - -test('Emit onCreate() when clicking on create drilldown', () => { - const fn = jest.fn(); - const screen = render(); - fireEvent.click(screen.getByText('Create new')); - expect(fn).toBeCalled(); -}); - -test('Delete button is not visible when non is selected', () => { - const fn = jest.fn(); - const screen = render(); - expect(screen.queryByText(/Delete/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/Create/i)).toBeInTheDocument(); -}); - -test('Can delete drilldowns', () => { - const fn = jest.fn(); - const screen = render(); - - const checkboxes = screen.getAllByLabelText(/Select this drilldown/i); - expect(checkboxes).toHaveLength(3); - - fireEvent.click(checkboxes[1]); - fireEvent.click(checkboxes[2]); - - expect(screen.queryByText(/Create/i)).not.toBeInTheDocument(); - - fireEvent.click(screen.getByText(/Delete \(2\)/i)); - - expect(fn).toBeCalledWith([drilldowns[1].id, drilldowns[2].id]); -}); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx deleted file mode 100644 index 5a15781a1faf2..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx +++ /dev/null @@ -1,116 +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 { - EuiBasicTable, - EuiBasicTableColumn, - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiSpacer, - EuiTextColor, -} from '@elastic/eui'; -import React, { useState } from 'react'; -import { - txtCreateDrilldown, - txtDeleteDrilldowns, - txtEditDrilldown, - txtSelectDrilldown, -} from './i18n'; - -export interface DrilldownListItem { - id: string; - actionName: string; - drilldownName: string; - icon?: string; -} - -export interface ListManageDrilldownsProps { - drilldowns: DrilldownListItem[]; - - onEdit?: (id: string) => void; - onCreate?: () => void; - onDelete?: (ids: string[]) => void; -} - -const noop = () => {}; - -export const TEST_SUBJ_DRILLDOWN_ITEM = 'list-manage-drilldowns-item'; - -export function ListManageDrilldowns({ - drilldowns, - onEdit = noop, - onCreate = noop, - onDelete = noop, -}: ListManageDrilldownsProps) { - const [selectedDrilldowns, setSelectedDrilldowns] = useState([]); - - const columns: Array> = [ - { - field: 'drilldownName', - name: 'Name', - truncateText: true, - width: '50%', - }, - { - name: 'Action', - render: (drilldown: DrilldownListItem) => ( - - {drilldown.icon && ( - - - - )} - - {drilldown.actionName} - - - ), - }, - { - align: 'right', - render: (drilldown: DrilldownListItem) => ( - onEdit(drilldown.id)}> - {txtEditDrilldown} - - ), - }, - ]; - - return ( - <> - { - setSelectedDrilldowns(selection.map(drilldown => drilldown.id)); - }, - selectableMessage: () => txtSelectDrilldown, - }} - rowProps={{ - 'data-test-subj': TEST_SUBJ_DRILLDOWN_ITEM, - }} - hasActions={true} - /> - - {selectedDrilldowns.length === 0 ? ( - onCreate()}> - {txtCreateDrilldown} - - ) : ( - onDelete(selectedDrilldowns)}> - {txtDeleteDrilldowns(selectedDrilldowns.length)} - - )} - - ); -} diff --git a/x-pack/plugins/drilldowns/public/index.ts b/x-pack/plugins/drilldowns/public/index.ts index 044e29c671de4..63e7a12235462 100644 --- a/x-pack/plugins/drilldowns/public/index.ts +++ b/x-pack/plugins/drilldowns/public/index.ts @@ -7,14 +7,12 @@ import { DrilldownsPlugin } from './plugin'; export { - SetupContract as DrilldownsSetup, - SetupDependencies as DrilldownsSetupDependencies, - StartContract as DrilldownsStart, - StartDependencies as DrilldownsStartDependencies, + DrilldownsSetupContract, + DrilldownsSetupDependencies, + DrilldownsStartContract, + DrilldownsStartDependencies, } from './plugin'; export function plugin() { return new DrilldownsPlugin(); } - -export { DrilldownDefinition } from './types'; diff --git a/x-pack/plugins/drilldowns/public/mocks.ts b/x-pack/plugins/drilldowns/public/mocks.ts index 18816243a3572..bfade1674072a 100644 --- a/x-pack/plugins/drilldowns/public/mocks.ts +++ b/x-pack/plugins/drilldowns/public/mocks.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DrilldownsSetup, DrilldownsStart } from '.'; +import { DrilldownsSetupContract, DrilldownsStartContract } from '.'; -export type Setup = jest.Mocked; -export type Start = jest.Mocked; +export type Setup = jest.Mocked; +export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { @@ -17,14 +17,12 @@ const createSetupContract = (): Setup => { }; const createStartContract = (): Start => { - const startContract: Start = { - FlyoutManageDrilldowns: jest.fn(), - }; + const startContract: Start = {}; return startContract; }; -export const drilldownsPluginMock = { +export const bfetchPluginMock = { createSetupContract, createStartContract, }; diff --git a/x-pack/plugins/drilldowns/public/plugin.ts b/x-pack/plugins/drilldowns/public/plugin.ts index bbc06847d5842..b89172541b91e 100644 --- a/x-pack/plugins/drilldowns/public/plugin.ts +++ b/x-pack/plugins/drilldowns/public/plugin.ts @@ -6,46 +6,52 @@ import { CoreStart, CoreSetup, Plugin } from 'src/core/public'; import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; -import { AdvancedUiActionsSetup, AdvancedUiActionsStart } from '../../advanced_ui_actions/public'; -import { DrilldownService, DrilldownServiceSetupContract } from './services'; -import { createFlyoutManageDrilldowns } from './components/connected_flyout_manage_drilldowns'; -import { Storage } from '../../../../src/plugins/kibana_utils/public'; - -export interface SetupDependencies { +import { DrilldownService } from './service'; +import { + FlyoutCreateDrilldownActionContext, + FlyoutEditDrilldownActionContext, + OPEN_FLYOUT_ADD_DRILLDOWN, + OPEN_FLYOUT_EDIT_DRILLDOWN, +} from './actions'; + +export interface DrilldownsSetupDependencies { uiActions: UiActionsSetup; - advancedUiActions: AdvancedUiActionsSetup; } -export interface StartDependencies { +export interface DrilldownsStartDependencies { uiActions: UiActionsStart; - advancedUiActions: AdvancedUiActionsStart; } -export type SetupContract = DrilldownServiceSetupContract; +export type DrilldownsSetupContract = Pick; // eslint-disable-next-line -export interface StartContract { - FlyoutManageDrilldowns: ReturnType; +export interface DrilldownsStartContract {} + +declare module '../../../../src/plugins/ui_actions/public' { + export interface ActionContextMapping { + [OPEN_FLYOUT_ADD_DRILLDOWN]: FlyoutCreateDrilldownActionContext; + [OPEN_FLYOUT_EDIT_DRILLDOWN]: FlyoutEditDrilldownActionContext; + } } export class DrilldownsPlugin - implements Plugin { + implements + Plugin< + DrilldownsSetupContract, + DrilldownsStartContract, + DrilldownsSetupDependencies, + DrilldownsStartDependencies + > { private readonly service = new DrilldownService(); - public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { - const setup = this.service.setup(core, plugins); + public setup(core: CoreSetup, plugins: DrilldownsSetupDependencies): DrilldownsSetupContract { + this.service.bootstrap(core, plugins); - return setup; + return this.service; } - public start(core: CoreStart, plugins: StartDependencies): StartContract { - return { - FlyoutManageDrilldowns: createFlyoutManageDrilldowns({ - advancedUiActions: plugins.advancedUiActions, - storage: new Storage(localStorage), - notifications: core.notifications, - }), - }; + public start(core: CoreStart, plugins: DrilldownsStartDependencies): DrilldownsStartContract { + return {}; } public stop() {} diff --git a/x-pack/plugins/drilldowns/public/service/drilldown_service.ts b/x-pack/plugins/drilldowns/public/service/drilldown_service.ts new file mode 100644 index 0000000000000..7745c30b4e335 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/service/drilldown_service.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 { CoreSetup } from 'src/core/public'; +// import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public'; +import { FlyoutCreateDrilldownAction, FlyoutEditDrilldownAction } from '../actions'; +import { DrilldownsSetupDependencies } from '../plugin'; + +export class DrilldownService { + bootstrap(core: CoreSetup, { uiActions }: DrilldownsSetupDependencies) { + const overlays = async () => (await core.getStartServices())[0].overlays; + + const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ overlays }); + uiActions.registerAction(actionFlyoutCreateDrilldown); + // uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown); + + const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ overlays }); + uiActions.registerAction(actionFlyoutEditDrilldown); + // uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown); + } + + /** + * Convenience method to register a drilldown. (It should set-up all the + * necessary triggers and actions.) + */ + registerDrilldown = (): void => { + throw new Error('not implemented'); + }; +} diff --git a/x-pack/plugins/drilldowns/public/services/index.ts b/x-pack/plugins/drilldowns/public/service/index.ts similarity index 100% rename from x-pack/plugins/drilldowns/public/services/index.ts rename to x-pack/plugins/drilldowns/public/service/index.ts diff --git a/x-pack/plugins/drilldowns/public/services/drilldown_service.ts b/x-pack/plugins/drilldowns/public/services/drilldown_service.ts deleted file mode 100644 index bfbe514d46095..0000000000000 --- a/x-pack/plugins/drilldowns/public/services/drilldown_service.ts +++ /dev/null @@ -1,79 +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 { CoreSetup } from 'src/core/public'; -import { AdvancedUiActionsSetup } from '../../../advanced_ui_actions/public'; -import { DrilldownDefinition, DrilldownFactoryContext } from '../types'; -import { UiActionsActionFactoryDefinition as ActionFactoryDefinition } from '../../../../../src/plugins/ui_actions/public'; - -export interface DrilldownServiceSetupDeps { - advancedUiActions: AdvancedUiActionsSetup; -} - -export interface DrilldownServiceSetupContract { - /** - * Convenience method to register a drilldown. - */ - registerDrilldown: < - Config extends object = object, - CreationContext extends object = object, - ExecutionContext extends object = object - >( - drilldown: DrilldownDefinition - ) => void; -} - -export class DrilldownService { - setup( - core: CoreSetup, - { advancedUiActions }: DrilldownServiceSetupDeps - ): DrilldownServiceSetupContract { - const registerDrilldown = < - Config extends object = object, - CreationContext extends object = object, - ExecutionContext extends object = object - >({ - id: factoryId, - CollectConfig, - createConfig, - isConfigValid, - getDisplayName, - euiIcon, - execute, - }: DrilldownDefinition) => { - const actionFactory: ActionFactoryDefinition< - Config, - DrilldownFactoryContext, - ExecutionContext - > = { - id: factoryId, - CollectConfig, - createConfig, - isConfigValid, - getDisplayName, - getIconType: () => euiIcon, - isCompatible: async () => true, - create: serializedAction => ({ - id: '', - type: factoryId, - getIconType: () => euiIcon, - getDisplayName: () => serializedAction.name, - execute: async context => await execute(serializedAction.config, context), - }), - } as ActionFactoryDefinition< - Config, - DrilldownFactoryContext, - ExecutionContext - >; - - advancedUiActions.registerActionFactory(actionFactory); - }; - - return { - registerDrilldown, - }; - } -} diff --git a/x-pack/plugins/drilldowns/public/types.ts b/x-pack/plugins/drilldowns/public/types.ts deleted file mode 100644 index a8232887f9ca6..0000000000000 --- a/x-pack/plugins/drilldowns/public/types.ts +++ /dev/null @@ -1,120 +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 { AdvancedUiActionsActionFactoryDefinition as ActionFactoryDefinition } from '../../advanced_ui_actions/public'; - -/** - * This is a convenience interface to register a drilldown. Drilldown has - * ability to collect configuration from user. Once drilldown is executed it - * receives the collected information together with the context of the - * user's interaction. - * - * `Config` is a serializable object containing the configuration that the - * drilldown is able to collect using UI. - * - * `PlaceContext` is an object that the app that opens drilldown management - * flyout provides to the React component, specifying the contextual information - * about that app. For example, on Dashboard app this context contains - * information about the current embeddable and dashboard. - * - * `ExecutionContext` is an object created in response to user's interaction - * and provided to the `execute` function of the drilldown. This object contains - * information about the action user performed. - */ -export interface DrilldownDefinition< - Config extends object = object, - PlaceContext extends object = object, - ExecutionContext extends object = object -> { - /** - * Globally unique identifier for this drilldown. - */ - id: string; - - /** - * Function that returns default config for this drilldown. - */ - createConfig: ActionFactoryDefinition< - Config, - DrilldownFactoryContext, - ExecutionContext - >['createConfig']; - - /** - * `UiComponent` that collections config for this drilldown. You can create - * a React component and transform it `UiComponent` using `uiToReactComponent` - * helper from `kibana_utils` plugin. - * - * ```tsx - * import React from 'react'; - * import { uiToReactComponent } from 'src/plugins/kibana_utils'; - * import { UiActionsCollectConfigProps as CollectConfigProps } from 'src/plugins/ui_actions/public'; - * - * type Props = CollectConfigProps; - * - * const ReactCollectConfig: React.FC = () => { - * return
Collecting config...'
; - * }; - * - * export const CollectConfig = uiToReactComponent(ReactCollectConfig); - * ``` - */ - CollectConfig: ActionFactoryDefinition< - Config, - DrilldownFactoryContext, - ExecutionContext - >['CollectConfig']; - - /** - * A validator function for the config object. Should always return a boolean - * given any input. - */ - isConfigValid: ActionFactoryDefinition< - Config, - DrilldownFactoryContext, - ExecutionContext - >['isConfigValid']; - - /** - * Name of EUI icon to display when showing this drilldown to user. - */ - euiIcon?: string; - - /** - * Should return an internationalized name of the drilldown, which will be - * displayed to the user. - */ - getDisplayName: () => string; - - /** - * Implements the "navigation" action of the drilldown. This happens when - * user clicks something in the UI that executes a trigger to which this - * drilldown was attached. - * - * @param config Config object that user configured this drilldown with. - * @param context Object that represents context in which the underlying - * `UIAction` of this drilldown is being executed in. - */ - execute(config: Config, context: ExecutionContext): void; -} - -/** - * Context object used when creating a drilldown. - */ -export interface DrilldownFactoryContext { - /** - * Context provided to the drilldown factory by the place where the UI is - * rendered. For example, for the "dashboard" place, this context contains - * the ID of the current dashboard, which could be used for filtering it out - * of the list. - */ - placeContext: T; - - /** - * List of triggers that user selected in the UI. - */ - triggers: string[]; -} diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts index 0dbdc2f3ac7e3..c76477cd8da43 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts @@ -4,14 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, SavedObject, SavedObjectsBaseOptions } from 'src/core/server'; +import { + StartServicesAccessor, + SavedObject, + SavedObjectsBaseOptions, + SavedObjectsServiceSetup, +} from 'src/core/server'; import { EncryptedSavedObjectsService } from '../crypto'; import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; interface SetupSavedObjectsParams { service: PublicMethodsOf; - savedObjects: CoreSetup['savedObjects']; - getStartServices: CoreSetup['getStartServices']; + savedObjects: SavedObjectsServiceSetup; + getStartServices: StartServicesAccessor; } export interface SavedObjectsSetup { diff --git a/x-pack/plugins/endpoint/server/plugin.ts b/x-pack/plugins/endpoint/server/plugin.ts index aef85f39e0382..4b4afd8088744 100644 --- a/x-pack/plugins/endpoint/server/plugin.ts +++ b/x-pack/plugins/endpoint/server/plugin.ts @@ -43,6 +43,7 @@ export class EndpointPlugin app: ['endpoint', 'kibana'], privileges: { all: { + app: ['endpoint', 'kibana'], api: ['resolver'], savedObject: { all: [], @@ -51,6 +52,7 @@ export class EndpointPlugin ui: ['save'], }, read: { + app: ['endpoint', 'kibana'], api: [], savedObject: { all: [], diff --git a/x-pack/plugins/features/common/feature.ts b/x-pack/plugins/features/common/feature.ts index 748076b95ad77..82fcc33f5c8ce 100644 --- a/x-pack/plugins/features/common/feature.ts +++ b/x-pack/plugins/features/common/feature.ts @@ -4,16 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FeatureKibanaPrivileges, FeatureKibanaPrivilegesSet } from './feature_kibana_privileges'; +import { RecursiveReadonly } from '@kbn/utility-types'; +import { FeatureKibanaPrivileges } from './feature_kibana_privileges'; +import { SubFeatureConfig, SubFeature } from './sub_feature'; /** * Interface for registering a feature. * Feature registration allows plugins to hide their applications with spaces, * and secure access when configured for security. */ -export interface Feature< - TPrivileges extends Partial = FeatureKibanaPrivilegesSet -> { +export interface FeatureConfig { /** * Unique identifier for this feature. * This identifier is also used when generating UI Capabilities. @@ -28,6 +28,11 @@ export interface Feature< */ name: string; + /** + * An ordinal used to sort features relative to one another for display. + */ + order?: number; + /** * Whether or not this feature should be excluded from the base privileges. * This is primarily helpful when migrating applications with a "legacy" privileges model @@ -98,7 +103,15 @@ export interface Feature< * ``` * @see FeatureKibanaPrivileges */ - privileges: TPrivileges; + privileges: { + all: FeatureKibanaPrivileges; + read: FeatureKibanaPrivileges; + } | null; + + /** + * Optional sub-feature privilege definitions. This can only be specified if `privileges` are are also defined. + */ + subFeatures?: SubFeatureConfig[]; /** * Optional message to display on the Role Management screen when configuring permissions for this feature. @@ -114,7 +127,64 @@ export interface Feature< }; } -export type FeatureWithAllOrReadPrivileges = Feature<{ - all?: FeatureKibanaPrivileges; - read?: FeatureKibanaPrivileges; -}>; +export class Feature { + public readonly subFeatures: SubFeature[]; + + constructor(protected readonly config: RecursiveReadonly) { + this.subFeatures = (config.subFeatures ?? []).map( + subFeatureConfig => new SubFeature(subFeatureConfig) + ); + } + + public get id() { + return this.config.id; + } + + public get name() { + return this.config.name; + } + + public get order() { + return this.config.order; + } + + public get navLinkId() { + return this.config.navLinkId; + } + + public get app() { + return this.config.app; + } + + public get catalogue() { + return this.config.catalogue; + } + + public get management() { + return this.config.management; + } + + public get icon() { + return this.config.icon; + } + + public get validLicenses() { + return this.config.validLicenses; + } + + public get privileges() { + return this.config.privileges; + } + + public get excludeFromBasePrivileges() { + return this.config.excludeFromBasePrivileges ?? false; + } + + public get reserved() { + return this.config.reserved; + } + + public toRaw() { + return { ...this.config } as FeatureConfig; + } +} diff --git a/x-pack/plugins/features/common/feature_kibana_privileges.ts b/x-pack/plugins/features/common/feature_kibana_privileges.ts index 1d14f3728282c..768c8c6ae1088 100644 --- a/x-pack/plugins/features/common/feature_kibana_privileges.ts +++ b/x-pack/plugins/features/common/feature_kibana_privileges.ts @@ -123,5 +123,3 @@ export interface FeatureKibanaPrivileges { */ ui: string[]; } - -export type FeatureKibanaPrivilegesSet = Record; diff --git a/x-pack/plugins/features/common/index.ts b/x-pack/plugins/features/common/index.ts index 6111d7d25a61b..e359efbda20d2 100644 --- a/x-pack/plugins/features/common/index.ts +++ b/x-pack/plugins/features/common/index.ts @@ -5,4 +5,11 @@ */ export { FeatureKibanaPrivileges } from './feature_kibana_privileges'; -export * from './feature'; +export { Feature, FeatureConfig } from './feature'; +export { + SubFeature, + SubFeatureConfig, + SubFeaturePrivilegeConfig, + SubFeaturePrivilegeGroupConfig, + SubFeaturePrivilegeGroupType, +} from './sub_feature'; diff --git a/x-pack/plugins/features/common/sub_feature.ts b/x-pack/plugins/features/common/sub_feature.ts new file mode 100644 index 0000000000000..121bb8514c8a2 --- /dev/null +++ b/x-pack/plugins/features/common/sub_feature.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RecursiveReadonly } from '@kbn/utility-types'; +import { FeatureKibanaPrivileges } from './feature_kibana_privileges'; + +/** + * Configuration for a sub-feature. + */ +export interface SubFeatureConfig { + /** Display name for this sub-feature */ + name: string; + + /** Collection of privilege groups */ + privilegeGroups: SubFeaturePrivilegeGroupConfig[]; +} + +/** + * The type of privilege group. + * - `mutually_exclusive`:: + * Users will be able to select at most one privilege within this group. + * Privileges must be specified in descending order of permissiveness (e.g. `All`, `Read`, not `Read`, `All) + * - `independent`:: + * Users will be able to select any combination of privileges within this group. + */ +export type SubFeaturePrivilegeGroupType = 'mutually_exclusive' | 'independent'; + +/** + * Configuration for a sub-feature privilege group. + */ +export interface SubFeaturePrivilegeGroupConfig { + /** + * The type of privilege group. + * - `mutually_exclusive`:: + * Users will be able to select at most one privilege within this group. + * Privileges must be specified in descending order of permissiveness (e.g. `All`, `Read`, not `Read`, `All) + * - `independent`:: + * Users will be able to select any combination of privileges within this group. + */ + groupType: SubFeaturePrivilegeGroupType; + + /** + * The privileges which belong to this group. + */ + privileges: SubFeaturePrivilegeConfig[]; +} + +/** + * Configuration for a sub-feature privilege. + */ +export interface SubFeaturePrivilegeConfig + extends Omit { + /** + * Identifier for this privilege. Must be unique across all other privileges within a feature. + */ + id: string; + + /** + * The display name for this privilege. + */ + name: string; + + /** + * Denotes which Primary Feature Privilege this sub-feature privilege should be included in. + * `read` is also included in `all` automatically. + */ + includeIn: 'all' | 'read' | 'none'; +} + +export class SubFeature { + constructor(protected readonly config: RecursiveReadonly) {} + + public get name() { + return this.config.name; + } + + public get privilegeGroups() { + return this.config.privilegeGroups; + } + + public toRaw() { + return { ...this.config }; + } +} diff --git a/x-pack/plugins/features/kibana.json b/x-pack/plugins/features/kibana.json index 553e920f0e720..e38d7be892904 100644 --- a/x-pack/plugins/features/kibana.json +++ b/x-pack/plugins/features/kibana.json @@ -4,5 +4,5 @@ "kibanaVersion": "kibana", "optionalPlugins": ["timelion"], "server": true, - "ui": false + "ui": true } diff --git a/x-pack/plugins/features/public/features_api_client.test.ts b/x-pack/plugins/features/public/features_api_client.test.ts new file mode 100644 index 0000000000000..e3a25ad57425c --- /dev/null +++ b/x-pack/plugins/features/public/features_api_client.test.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 { coreMock } from 'src/core/public/mocks'; +import { FeaturesAPIClient } from './features_api_client'; + +describe('Features API Client', () => { + describe('#getFeatures', () => { + it('returns an array of Features', async () => { + const rawFeatures = [ + { + id: 'feature-a', + }, + { + id: 'feature-b', + }, + { + id: 'feature-c', + }, + { + id: 'feature-d', + }, + { + id: 'feature-e', + }, + ]; + const coreSetup = coreMock.createSetup(); + coreSetup.http.get.mockResolvedValue(rawFeatures); + + const client = new FeaturesAPIClient(coreSetup.http); + const result = await client.getFeatures(); + expect(result.map(f => f.id)).toEqual([ + 'feature-a', + 'feature-b', + 'feature-c', + 'feature-d', + 'feature-e', + ]); + }); + }); +}); diff --git a/x-pack/plugins/features/public/features_api_client.ts b/x-pack/plugins/features/public/features_api_client.ts new file mode 100644 index 0000000000000..b93c9bf917d79 --- /dev/null +++ b/x-pack/plugins/features/public/features_api_client.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpSetup } from 'src/core/public'; +import { FeatureConfig, Feature } from '.'; + +export class FeaturesAPIClient { + constructor(private readonly http: HttpSetup) {} + + public async getFeatures() { + const features = await this.http.get('/api/features'); + return features.map(config => new Feature(config)); + } +} diff --git a/x-pack/plugins/features/public/index.ts b/x-pack/plugins/features/public/index.ts index 6a2c99aad4bd8..f19c7f947d97f 100644 --- a/x-pack/plugins/features/public/index.ts +++ b/x-pack/plugins/features/public/index.ts @@ -4,4 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -export { Feature, FeatureWithAllOrReadPrivileges, FeatureKibanaPrivileges } from '../common'; +import { PluginInitializer } from 'src/core/public'; +import { FeaturesPlugin, FeaturesPluginSetup, FeaturesPluginStart } from './plugin'; + +export { + Feature, + FeatureConfig, + FeatureKibanaPrivileges, + SubFeatureConfig, + SubFeaturePrivilegeConfig, +} from '../common'; + +export { FeaturesPluginSetup, FeaturesPluginStart } from './plugin'; + +export const plugin: PluginInitializer = () => + new FeaturesPlugin(); diff --git a/x-pack/plugins/features/public/mocks.ts b/x-pack/plugins/features/public/mocks.ts new file mode 100644 index 0000000000000..014883f3ce9cf --- /dev/null +++ b/x-pack/plugins/features/public/mocks.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FeaturesPluginStart } from './plugin'; + +const createStart = (): jest.Mocked => { + return { + getFeatures: jest.fn(), + }; +}; + +export const featuresPluginMock = { + createStart, +}; diff --git a/x-pack/plugins/features/public/plugin.test.ts b/x-pack/plugins/features/public/plugin.test.ts new file mode 100644 index 0000000000000..aab712d647508 --- /dev/null +++ b/x-pack/plugins/features/public/plugin.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FeaturesPlugin } from './plugin'; + +import { coreMock, httpServiceMock } from 'src/core/public/mocks'; + +jest.mock('./features_api_client', () => { + const instance = { + getFeatures: jest.fn(), + }; + return { + FeaturesAPIClient: jest.fn().mockImplementation(() => instance), + }; +}); + +import { FeaturesAPIClient } from './features_api_client'; + +describe('Features Plugin', () => { + describe('#setup', () => { + it('returns expected public contract', () => { + const plugin = new FeaturesPlugin(); + expect(plugin.setup(coreMock.createSetup())).toMatchInlineSnapshot(`undefined`); + }); + }); + + describe('#start', () => { + it('returns expected public contract', () => { + const plugin = new FeaturesPlugin(); + plugin.setup(coreMock.createSetup()); + + expect(plugin.start()).toMatchInlineSnapshot(` + Object { + "getFeatures": [Function], + } + `); + }); + + it('#getFeatures calls the underlying FeaturesAPIClient', () => { + const plugin = new FeaturesPlugin(); + const apiClient = new FeaturesAPIClient(httpServiceMock.createSetupContract()); + + plugin.setup(coreMock.createSetup()); + + const start = plugin.start(); + start.getFeatures(); + expect(apiClient.getFeatures).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/features/public/plugin.ts b/x-pack/plugins/features/public/plugin.ts new file mode 100644 index 0000000000000..c168384dae78f --- /dev/null +++ b/x-pack/plugins/features/public/plugin.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 { Plugin, CoreSetup } from 'src/core/public'; +import { FeaturesAPIClient } from './features_api_client'; + +export class FeaturesPlugin implements Plugin { + private apiClient?: FeaturesAPIClient; + + public setup(core: CoreSetup) { + this.apiClient = new FeaturesAPIClient(core.http); + } + + public start() { + return { + getFeatures: () => this.apiClient!.getFeatures(), + }; + } + + public stop() {} +} + +export type FeaturesPluginSetup = ReturnType; +export type FeaturesPluginStart = ReturnType; diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap new file mode 100644 index 0000000000000..ee94d0d40b853 --- /dev/null +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -0,0 +1,458 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`buildOSSFeatures returns the advancedSettings feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "advanced_settings", + ], + "management": Object { + "kibana": Array [ + "settings", + ], + }, + "savedObject": Object { + "all": Array [ + "config", + ], + "read": Array [], + }, + "ui": Array [ + "save", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "advanced_settings", + ], + "management": Object { + "kibana": Array [ + "settings", + ], + }, + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the dashboard feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "dashboard", + ], + "management": Object {}, + "savedObject": Object { + "all": Array [ + "dashboard", + "url", + "query", + ], + "read": Array [ + "index-pattern", + "search", + "visualization", + "timelion-sheet", + "canvas-workpad", + "lens", + "map", + ], + }, + "ui": Array [ + "createNew", + "show", + "showWriteControls", + "saveQuery", + "createShortUrl", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "dashboard", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "search", + "visualization", + "timelion-sheet", + "canvas-workpad", + "map", + "dashboard", + "query", + ], + }, + "ui": Array [ + "show", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the dev_tools feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [ + "console", + ], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "console", + "searchprofiler", + "grokdebugger", + ], + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [ + "show", + "save", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "api": Array [ + "console", + ], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "console", + "searchprofiler", + "grokdebugger", + ], + "savedObject": Object { + "all": Array [], + "read": Array [], + }, + "ui": Array [ + "show", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the discover feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "discover", + ], + "management": Object {}, + "savedObject": Object { + "all": Array [ + "search", + "query", + "url", + ], + "read": Array [ + "index-pattern", + ], + }, + "ui": Array [ + "show", + "save", + "saveQuery", + "createShortUrl", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "discover", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "search", + "query", + ], + }, + "ui": Array [ + "show", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the indexPatterns feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "index_patterns", + ], + "management": Object { + "kibana": Array [ + "index_patterns", + ], + }, + "savedObject": Object { + "all": Array [ + "index-pattern", + ], + "read": Array [], + }, + "ui": Array [ + "save", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "index_patterns", + ], + "management": Object { + "kibana": Array [ + "index_patterns", + ], + }, + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + ], + }, + "ui": Array [], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the savedObjectsManagement feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [ + "copySavedObjectsToSpaces", + ], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "saved_objects", + ], + "management": Object { + "kibana": Array [ + "objects", + ], + }, + "savedObject": Object { + "all": Array [ + "foo", + "bar", + ], + "read": Array [], + }, + "ui": Array [ + "read", + "edit", + "delete", + "copyIntoSpace", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "api": Array [ + "copySavedObjectsToSpaces", + ], + "app": Array [ + "kibana", + ], + "catalogue": Array [ + "saved_objects", + ], + "management": Object { + "kibana": Array [ + "objects", + ], + }, + "savedObject": Object { + "all": Array [], + "read": Array [ + "foo", + "bar", + ], + }, + "ui": Array [ + "read", + ], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the timelion feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "app": Array [ + "timelion", + "kibana", + ], + "catalogue": Array [ + "timelion", + ], + "savedObject": Object { + "all": Array [ + "timelion-sheet", + ], + "read": Array [ + "index-pattern", + ], + }, + "ui": Array [ + "save", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "timelion", + "kibana", + ], + "catalogue": Array [ + "timelion", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "timelion-sheet", + ], + }, + "ui": Array [], + }, + "privilegeId": "read", + }, +] +`; + +exports[`buildOSSFeatures returns the visualize feature augmented with appropriate sub feature privileges 1`] = ` +Array [ + Object { + "privilege": Object { + "api": Array [], + "app": Array [ + "kibana", + "lens", + ], + "catalogue": Array [ + "visualize", + ], + "management": Object {}, + "savedObject": Object { + "all": Array [ + "visualization", + "query", + "lens", + "url", + ], + "read": Array [ + "index-pattern", + "search", + ], + }, + "ui": Array [ + "show", + "delete", + "save", + "saveQuery", + "createShortUrl", + ], + }, + "privilegeId": "all", + }, + Object { + "privilege": Object { + "app": Array [ + "kibana", + "lens", + ], + "catalogue": Array [ + "visualize", + ], + "savedObject": Object { + "all": Array [], + "read": Array [ + "index-pattern", + "search", + "visualization", + "query", + "lens", + ], + }, + "ui": Array [ + "show", + ], + }, + "privilegeId": "read", + }, +] +`; diff --git a/x-pack/plugins/features/server/feature_registry.test.ts b/x-pack/plugins/features/server/feature_registry.test.ts index 7b25035892668..5b4f7728c9f31 100644 --- a/x-pack/plugins/features/server/feature_registry.test.ts +++ b/x-pack/plugins/features/server/feature_registry.test.ts @@ -5,15 +5,15 @@ */ import { FeatureRegistry } from './feature_registry'; -import { Feature } from '../common/feature'; +import { FeatureConfig } from '../common/feature'; describe('FeatureRegistry', () => { it('allows a minimal feature to be registered', () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], - privileges: {}, + privileges: null, }; const featureRegistry = new FeatureRegistry(); @@ -22,18 +22,18 @@ describe('FeatureRegistry', () => { expect(result).toHaveLength(1); // Should be the equal, but not the same instance (i.e., a defensive copy) - expect(result[0]).not.toBe(feature); - expect(result[0]).toEqual(feature); + expect(result[0].toRaw()).not.toBe(feature); + expect(result[0].toRaw()).toEqual(feature); }); it('allows a complex feature to be registered', () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', excludeFromBasePrivileges: true, icon: 'addDataApp', navLinkId: 'someNavLink', - app: ['app1', 'app2'], + app: ['app1'], validLicenses: ['standard', 'basic', 'gold', 'platinum'], catalogue: ['foo'], management: { @@ -53,7 +53,61 @@ describe('FeatureRegistry', () => { api: ['someApiEndpointTag', 'anotherEndpointTag'], ui: ['allowsFoo', 'showBar', 'showBaz'], }, + read: { + savedObject: { + all: [], + read: ['config', 'url'], + }, + ui: [], + }, }, + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'foo', + name: 'foo', + includeIn: 'read', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + ], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'bar', + name: 'bar', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + { + id: 'baz', + name: 'baz', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + ], + }, + ], + }, + ], privilegesTooltip: 'some fancy tooltip', reserved: { privilege: { @@ -79,12 +133,61 @@ describe('FeatureRegistry', () => { expect(result).toHaveLength(1); // Should be the equal, but not the same instance (i.e., a defensive copy) - expect(result[0]).not.toBe(feature); - expect(result[0]).toEqual(feature); + expect(result[0].toRaw()).not.toBe(feature); + expect(result[0].toRaw()).toEqual(feature); + }); + + it(`requires a value for privileges`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + } as any; + + const featureRegistry = new FeatureRegistry(); + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"child \\"privileges\\" fails because [\\"privileges\\" is required]"` + ); + }); + + it(`does not allow sub-features to be registered when no primary privileges are not registered`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + privileges: null, + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'my-sub-priv', + name: 'my sub priv', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"child \\"subFeatures\\" fails because [\\"subFeatures\\" must contain less than or equal to 0 items]"` + ); }); it(`automatically grants 'all' access to telemetry saved objects for the 'all' privilege`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -96,6 +199,13 @@ describe('FeatureRegistry', () => { read: [], }, }, + read: { + ui: [], + savedObject: { + all: [], + read: [], + }, + }, }, }; @@ -103,12 +213,15 @@ describe('FeatureRegistry', () => { featureRegistry.register(feature); const result = featureRegistry.getAll(); - const allPrivilege = result[0].privileges.all; - expect(allPrivilege.savedObject.all).toEqual(['telemetry']); + expect(result[0].privileges).toHaveProperty('all'); + expect(result[0].privileges).toHaveProperty('read'); + + const allPrivilege = result[0].privileges?.all; + expect(allPrivilege?.savedObject.all).toEqual(['telemetry']); }); it(`automatically grants 'read' access to config and url saved objects for both privileges`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -134,18 +247,21 @@ describe('FeatureRegistry', () => { featureRegistry.register(feature); const result = featureRegistry.getAll(); - const allPrivilege = result[0].privileges.all; - const readPrivilege = result[0].privileges.read; - expect(allPrivilege.savedObject.read).toEqual(['config', 'url']); - expect(readPrivilege.savedObject.read).toEqual(['config', 'url']); + expect(result[0].privileges).toHaveProperty('all'); + expect(result[0].privileges).toHaveProperty('read'); + + const allPrivilege = result[0].privileges?.all; + const readPrivilege = result[0].privileges?.read; + expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']); + expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']); }); it(`automatically grants 'all' access to telemetry and 'read' to [config, url] saved objects for the reserved privilege`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], - privileges: {}, + privileges: null, reserved: { description: 'foo', privilege: { @@ -168,7 +284,7 @@ describe('FeatureRegistry', () => { }); it(`does not duplicate the automatic grants if specified on the incoming feature`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -194,26 +310,29 @@ describe('FeatureRegistry', () => { featureRegistry.register(feature); const result = featureRegistry.getAll(); - const allPrivilege = result[0].privileges.all; - const readPrivilege = result[0].privileges.read; - expect(allPrivilege.savedObject.all).toEqual(['telemetry']); - expect(allPrivilege.savedObject.read).toEqual(['config', 'url']); - expect(readPrivilege.savedObject.read).toEqual(['config', 'url']); + expect(result[0].privileges).toHaveProperty('all'); + expect(result[0].privileges).toHaveProperty('read'); + + const allPrivilege = result[0].privileges!.all; + const readPrivilege = result[0].privileges!.read; + expect(allPrivilege?.savedObject.all).toEqual(['telemetry']); + expect(allPrivilege?.savedObject.read).toEqual(['config', 'url']); + expect(readPrivilege?.savedObject.read).toEqual(['config', 'url']); }); it(`does not allow duplicate features to be registered`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], - privileges: {}, + privileges: null, }; - const duplicateFeature: Feature = { + const duplicateFeature: FeatureConfig = { id: 'test-feature', name: 'Duplicate Test Feature', app: [], - privileges: {}, + privileges: null, }; const featureRegistry = new FeatureRegistry(); @@ -233,7 +352,7 @@ describe('FeatureRegistry', () => { name: 'some feature', navLinkId: prohibitedChars, app: [], - privileges: {}, + privileges: null, }) ).toThrowErrorMatchingSnapshot(); }); @@ -248,7 +367,7 @@ describe('FeatureRegistry', () => { kibana: [prohibitedChars], }, app: [], - privileges: {}, + privileges: null, }) ).toThrowErrorMatchingSnapshot(); }); @@ -261,7 +380,7 @@ describe('FeatureRegistry', () => { name: 'some feature', catalogue: [prohibitedChars], app: [], - privileges: {}, + privileges: null, }) ).toThrowErrorMatchingSnapshot(); }); @@ -275,19 +394,20 @@ describe('FeatureRegistry', () => { id: prohibitedId, name: 'some feature', app: [], - privileges: {}, + privileges: null, }) ).toThrowErrorMatchingSnapshot(); }); }); it('prevents features from being registered with invalid privilege names', () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: ['app1', 'app2'], privileges: { foo: { + name: 'Foo', app: ['app1', 'app2'], savedObject: { all: ['config', 'space', 'etc'], @@ -296,7 +416,7 @@ describe('FeatureRegistry', () => { api: ['someApiEndpointTag', 'anotherEndpointTag'], ui: ['allowsFoo', 'showBar', 'showBaz'], }, - }, + } as any, }; const featureRegistry = new FeatureRegistry(); @@ -306,7 +426,7 @@ describe('FeatureRegistry', () => { }); it(`prevents privileges from specifying app entries that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: ['bar'], @@ -319,6 +439,14 @@ describe('FeatureRegistry', () => { ui: [], app: ['foo', 'bar', 'baz'], }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['foo', 'bar', 'baz'], + }, }, }; @@ -329,12 +457,67 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying app entries that don't exist at the privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: ['foo', 'bar', 'baz'], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['bar'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool-sub-feature-privilege', + name: 'cool privilege', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['foo'], + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies app entries which are not granted to any privileges: baz"` + ); + }); + it(`prevents reserved privileges from specifying app entries that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: ['bar'], - privileges: {}, + privileges: null, reserved: { description: 'something', privilege: { @@ -355,8 +538,34 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying app entries that don't exist at the reserved privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: ['foo', 'bar', 'baz'], + privileges: null, + reserved: { + description: 'something', + privilege: { + savedObject: { + all: [], + read: [], + }, + ui: [], + app: ['foo', 'bar'], + }, + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies app entries which are not granted to any privileges: baz"` + ); + }); + it(`prevents privileges from specifying catalogue entries that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -371,6 +580,15 @@ describe('FeatureRegistry', () => { ui: [], app: [], }, + read: { + catalogue: ['foo', 'bar', 'baz'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, }, }; @@ -381,13 +599,71 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying catalogue entries that don't exist at the privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['foo', 'bar', 'baz'], + privileges: { + all: { + catalogue: ['foo'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + read: { + catalogue: ['foo'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool-sub-feature-privilege', + name: 'cool privilege', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + catalogue: ['bar'], + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies catalogue entries which are not granted to any privileges: baz"` + ); + }); + it(`prevents reserved privileges from specifying catalogue entries that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], catalogue: ['bar'], - privileges: {}, + privileges: null, reserved: { description: 'something', privilege: { @@ -409,8 +685,36 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying catalogue entries that don't exist at the reserved privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['foo', 'bar', 'baz'], + privileges: null, + reserved: { + description: 'something', + privilege: { + catalogue: ['foo', 'bar'], + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies catalogue entries which are not granted to any privileges: baz"` + ); + }); + it(`prevents privileges from specifying management sections that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -431,6 +735,18 @@ describe('FeatureRegistry', () => { ui: [], app: [], }, + read: { + catalogue: ['bar'], + management: { + elasticsearch: ['hey'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, }, }; @@ -441,8 +757,79 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying management sections that don't exist at the privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['bar'], + management: { + kibana: ['hey'], + elasticsearch: ['hey', 'there'], + }, + privileges: { + all: { + catalogue: ['bar'], + management: { + elasticsearch: ['hey'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + read: { + catalogue: ['bar'], + management: { + elasticsearch: ['hey'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool-sub-feature-privilege', + name: 'cool privilege', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + management: { + kibana: ['hey'], + elasticsearch: ['hey'], + }, + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies management entries which are not granted to any privileges: elasticsearch.there"` + ); + }); + it(`prevents reserved privileges from specifying management entries that don't exist at the root level`, () => { - const feature: Feature = { + const feature: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], @@ -450,7 +837,7 @@ describe('FeatureRegistry', () => { management: { kibana: ['hey'], }, - privileges: {}, + privileges: null, reserved: { description: 'something', privilege: { @@ -475,18 +862,52 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents features from specifying management entries that don't exist at the reserved privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + catalogue: ['bar'], + management: { + kibana: ['hey', 'hey-there'], + }, + privileges: null, + reserved: { + description: 'something', + privilege: { + catalogue: ['bar'], + management: { + kibana: ['hey-there'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies management entries which are not granted to any privileges: kibana.hey"` + ); + }); + it('cannot register feature after getAll has been called', () => { - const feature1: Feature = { + const feature1: FeatureConfig = { id: 'test-feature', name: 'Test Feature', app: [], - privileges: {}, + privileges: null, }; - const feature2: Feature = { + const feature2: FeatureConfig = { id: 'test-feature-2', name: 'Test Feature 2', app: [], - privileges: {}, + privileges: null, }; const featureRegistry = new FeatureRegistry(); diff --git a/x-pack/plugins/features/server/feature_registry.ts b/x-pack/plugins/features/server/feature_registry.ts index 60a229fc58612..73a353cd27471 100644 --- a/x-pack/plugins/features/server/feature_registry.ts +++ b/x-pack/plugins/features/server/feature_registry.ts @@ -5,14 +5,14 @@ */ import { cloneDeep, uniq } from 'lodash'; -import { Feature, FeatureWithAllOrReadPrivileges, FeatureKibanaPrivileges } from '../common'; +import { FeatureConfig, Feature, FeatureKibanaPrivileges } from '../common'; import { validateFeature } from './feature_schema'; export class FeatureRegistry { private locked = false; - private features: Record = {}; + private features: Record = {}; - public register(feature: FeatureWithAllOrReadPrivileges) { + public register(feature: FeatureConfig) { if (this.locked) { throw new Error( `Features are locked, can't register new features. Attempt to register ${feature.id} failed.` @@ -25,20 +25,21 @@ export class FeatureRegistry { throw new Error(`Feature with id ${feature.id} is already registered.`); } - const featureCopy: Feature = cloneDeep(feature as Feature); + const featureCopy = cloneDeep(feature); - this.features[feature.id] = applyAutomaticPrivilegeGrants(featureCopy as Feature); + this.features[feature.id] = applyAutomaticPrivilegeGrants(featureCopy); } public getAll(): Feature[] { this.locked = true; - return cloneDeep(Object.values(this.features)); + return Object.values(this.features).map(featureConfig => new Feature(featureConfig)); } } -function applyAutomaticPrivilegeGrants(feature: Feature): Feature { - const { all: allPrivilege, read: readPrivilege } = feature.privileges; - const reservedPrivilege = feature.reserved ? feature.reserved.privilege : null; +function applyAutomaticPrivilegeGrants(feature: FeatureConfig): FeatureConfig { + const allPrivilege = feature.privileges?.all; + const readPrivilege = feature.privileges?.read; + const reservedPrivilege = feature.reserved?.privilege; applyAutomaticAllPrivilegeGrants(allPrivilege, reservedPrivilege); applyAutomaticReadPrivilegeGrants(readPrivilege); @@ -46,7 +47,9 @@ function applyAutomaticPrivilegeGrants(feature: Feature): Feature { return feature; } -function applyAutomaticAllPrivilegeGrants(...allPrivileges: Array) { +function applyAutomaticAllPrivilegeGrants( + ...allPrivileges: Array +) { allPrivileges.forEach(allPrivilege => { if (allPrivilege) { allPrivilege.savedObject.all = uniq([...allPrivilege.savedObject.all, 'telemetry']); @@ -56,7 +59,7 @@ function applyAutomaticAllPrivilegeGrants(...allPrivileges: Array + ...readPrivileges: Array ) { readPrivileges.forEach(readPrivilege => { if (readPrivilege) { diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index cc12ea1b78dce..fdeceb30b4e3d 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -8,13 +8,15 @@ import Joi from 'joi'; import { difference } from 'lodash'; import { Capabilities as UICapabilities } from '../../../../src/core/server'; -import { FeatureWithAllOrReadPrivileges } from '../common/feature'; +import { FeatureConfig } from '../common/feature'; +import { FeatureKibanaPrivileges } from '.'; // Each feature gets its own property on the UICapabilities object, // but that object has a few built-in properties which should not be overwritten. const prohibitedFeatureIds: Array = ['catalogue', 'management', 'navLinks']; const featurePrivilegePartRegex = /^[a-zA-Z0-9_-]+$/; +const subFeaturePrivilegePartRegex = /^[a-zA-Z0-9_-]+$/; const managementSectionIdRegex = /^[a-zA-Z0-9_-]+$/; export const uiCapabilitiesRegex = /^[a-zA-Z0-9:_-]+$/; @@ -43,12 +45,52 @@ const privilegeSchema = Joi.object({ .required(), }); +const subFeaturePrivilegeSchema = Joi.object({ + id: Joi.string() + .regex(subFeaturePrivilegePartRegex) + .required(), + name: Joi.string().required(), + includeIn: Joi.string() + .allow('all', 'read', 'none') + .required(), + management: managementSchema, + catalogue: catalogueSchema, + api: Joi.array().items(Joi.string()), + app: Joi.array().items(Joi.string()), + savedObject: Joi.object({ + all: Joi.array() + .items(Joi.string()) + .required(), + read: Joi.array() + .items(Joi.string()) + .required(), + }).required(), + ui: Joi.array() + .items(Joi.string().regex(uiCapabilitiesRegex)) + .required(), +}); + +const subFeatureSchema = Joi.object({ + name: Joi.string().required(), + privilegeGroups: Joi.array().items( + Joi.object({ + groupType: Joi.string() + .valid('mutually_exclusive', 'independent') + .required(), + privileges: Joi.array() + .items(subFeaturePrivilegeSchema) + .min(1), + }) + ), +}); + const schema = Joi.object({ id: Joi.string() .regex(featurePrivilegePartRegex) .invalid(...prohibitedFeatureIds) .required(), name: Joi.string().required(), + order: Joi.number(), excludeFromBasePrivileges: Joi.boolean(), validLicenses: Joi.array().items( Joi.string().valid('basic', 'standard', 'gold', 'platinum', 'enterprise', 'trial') @@ -64,7 +106,16 @@ const schema = Joi.object({ privileges: Joi.object({ all: privilegeSchema, read: privilegeSchema, - }).required(), + }) + .allow(null) + .required(), + subFeatures: Joi.when('privileges', { + is: null, + then: Joi.array() + .items(subFeatureSchema) + .max(0), + otherwise: Joi.array().items(subFeatureSchema), + }), privilegesTooltip: Joi.string(), reserved: Joi.object({ privilege: privilegeSchema.required(), @@ -72,7 +123,7 @@ const schema = Joi.object({ }), }); -export function validateFeature(feature: FeatureWithAllOrReadPrivileges) { +export function validateFeature(feature: FeatureConfig) { const validateResult = Joi.validate(feature, schema); if (validateResult.error) { throw validateResult.error; @@ -80,17 +131,21 @@ export function validateFeature(feature: FeatureWithAllOrReadPrivileges) { // the following validation can't be enforced by the Joi schema, since it'd require us looking "up" the object graph for the list of valid value, which they explicitly forbid. const { app = [], management = {}, catalogue = [] } = feature; - const privilegeEntries = [...Object.entries(feature.privileges)]; - if (feature.reserved) { - privilegeEntries.push(['reserved', feature.reserved.privilege]); - } + const unseenApps = new Set(app); - privilegeEntries.forEach(([privilegeId, privilegeDefinition]) => { - if (!privilegeDefinition) { - throw new Error('Privilege definition may not be null or undefined'); - } + const managementSets = Object.entries(management).map(entry => [ + entry[0], + new Set(entry[1]), + ]) as Array<[string, Set]>; + + const unseenManagement = new Map>(managementSets); + + const unseenCatalogue = new Set(catalogue); + + function validateAppEntry(privilegeId: string, entry: string[] = []) { + entry.forEach(privilegeApp => unseenApps.delete(privilegeApp)); - const unknownAppEntries = difference(privilegeDefinition.app || [], app); + const unknownAppEntries = difference(entry, app); if (unknownAppEntries.length > 0) { throw new Error( `Feature privilege ${ @@ -98,8 +153,12 @@ export function validateFeature(feature: FeatureWithAllOrReadPrivileges) { }.${privilegeId} has unknown app entries: ${unknownAppEntries.join(', ')}` ); } + } + + function validateCatalogueEntry(privilegeId: string, entry: string[] = []) { + entry.forEach(privilegeCatalogue => unseenCatalogue.delete(privilegeCatalogue)); - const unknownCatalogueEntries = difference(privilegeDefinition.catalogue || [], catalogue); + const unknownCatalogueEntries = difference(entry || [], catalogue); if (unknownCatalogueEntries.length > 0) { throw new Error( `Feature privilege ${ @@ -107,27 +166,113 @@ export function validateFeature(feature: FeatureWithAllOrReadPrivileges) { }.${privilegeId} has unknown catalogue entries: ${unknownCatalogueEntries.join(', ')}` ); } + } - Object.entries(privilegeDefinition.management || {}).forEach( - ([managementSectionId, managementEntry]) => { - if (!management[managementSectionId]) { - throw new Error( - `Feature privilege ${feature.id}.${privilegeId} has unknown management section: ${managementSectionId}` - ); - } - - const unknownSectionEntries = difference(managementEntry, management[managementSectionId]); - - if (unknownSectionEntries.length > 0) { - throw new Error( - `Feature privilege ${ - feature.id - }.${privilegeId} has unknown management entries for section ${managementSectionId}: ${unknownSectionEntries.join( - ', ' - )}` - ); - } + function validateManagementEntry( + privilegeId: string, + managementEntry: Record = {} + ) { + Object.entries(managementEntry).forEach(([managementSectionId, managementSectionEntry]) => { + if (unseenManagement.has(managementSectionId)) { + managementSectionEntry.forEach(entry => { + unseenManagement.get(managementSectionId)!.delete(entry); + if (unseenManagement.get(managementSectionId)?.size === 0) { + unseenManagement.delete(managementSectionId); + } + }); } - ); + if (!management[managementSectionId]) { + throw new Error( + `Feature privilege ${feature.id}.${privilegeId} has unknown management section: ${managementSectionId}` + ); + } + + const unknownSectionEntries = difference( + managementSectionEntry, + management[managementSectionId] + ); + + if (unknownSectionEntries.length > 0) { + throw new Error( + `Feature privilege ${ + feature.id + }.${privilegeId} has unknown management entries for section ${managementSectionId}: ${unknownSectionEntries.join( + ', ' + )}` + ); + } + }); + } + + const privilegeEntries: Array<[string, FeatureKibanaPrivileges]> = []; + if (feature.privileges) { + privilegeEntries.push(...Object.entries(feature.privileges)); + } + if (feature.reserved) { + privilegeEntries.push(['reserved', feature.reserved.privilege]); + } + + if (privilegeEntries.length === 0) { + return; + } + + privilegeEntries.forEach(([privilegeId, privilegeDefinition]) => { + if (!privilegeDefinition) { + throw new Error('Privilege definition may not be null or undefined'); + } + + validateAppEntry(privilegeId, privilegeDefinition.app); + + validateCatalogueEntry(privilegeId, privilegeDefinition.catalogue); + + validateManagementEntry(privilegeId, privilegeDefinition.management); + }); + + const subFeatureEntries = feature.subFeatures ?? []; + subFeatureEntries.forEach(subFeature => { + subFeature.privilegeGroups.forEach(subFeaturePrivilegeGroup => { + subFeaturePrivilegeGroup.privileges.forEach(subFeaturePrivilege => { + validateAppEntry(subFeaturePrivilege.id, subFeaturePrivilege.app); + validateCatalogueEntry(subFeaturePrivilege.id, subFeaturePrivilege.catalogue); + validateManagementEntry(subFeaturePrivilege.id, subFeaturePrivilege.management); + }); + }); }); + + if (unseenApps.size > 0) { + throw new Error( + `Feature ${ + feature.id + } specifies app entries which are not granted to any privileges: ${Array.from( + unseenApps.values() + ).join(',')}` + ); + } + + if (unseenCatalogue.size > 0) { + throw new Error( + `Feature ${ + feature.id + } specifies catalogue entries which are not granted to any privileges: ${Array.from( + unseenCatalogue.values() + ).join(',')}` + ); + } + + if (unseenManagement.size > 0) { + const ungrantedManagement = Array.from(unseenManagement.entries()).reduce((acc, entry) => { + const values = Array.from(entry[1].values()).map( + managementPage => `${entry[0]}.${managementPage}` + ); + return [...acc, ...values]; + }, [] as string[]); + + throw new Error( + `Feature ${ + feature.id + } specifies management entries which are not granted to any privileges: ${ungrantedManagement.join( + ',' + )}` + ); + } } diff --git a/x-pack/plugins/features/server/index.ts b/x-pack/plugins/features/server/index.ts index 48ef97a494f7e..48a350ae8f8fd 100644 --- a/x-pack/plugins/features/server/index.ts +++ b/x-pack/plugins/features/server/index.ts @@ -13,7 +13,7 @@ import { Plugin } from './plugin'; // run-time contracts. export { uiCapabilitiesRegex } from './feature_schema'; -export { Feature, FeatureWithAllOrReadPrivileges, FeatureKibanaPrivileges } from '../common'; +export { Feature, FeatureConfig, FeatureKibanaPrivileges } from '../common'; export { PluginSetupContract, PluginStartContract } from './plugin'; export const plugin = (initializerContext: PluginInitializerContext) => diff --git a/x-pack/plugins/features/server/oss_features.test.ts b/x-pack/plugins/features/server/oss_features.test.ts index 987af08fe7cda..72beff02173d2 100644 --- a/x-pack/plugins/features/server/oss_features.test.ts +++ b/x-pack/plugins/features/server/oss_features.test.ts @@ -5,6 +5,8 @@ */ import { buildOSSFeatures } from './oss_features'; +import { featurePrivilegeIterator } from '../../security/server/authorization'; +import { Feature } from '.'; describe('buildOSSFeatures', () => { it('returns features including timelion', () => { @@ -39,4 +41,17 @@ Array [ ] `); }); + + const features = buildOSSFeatures({ savedObjectTypes: ['foo', 'bar'], includeTimelion: true }); + features.forEach(featureConfig => { + it(`returns the ${featureConfig.id} feature augmented with appropriate sub feature privileges`, () => { + const privileges = []; + for (const featurePrivilege of featurePrivilegeIterator(new Feature(featureConfig), { + augmentWithSubFeaturePrivileges: true, + })) { + privileges.push(featurePrivilege); + } + expect(privileges).toMatchSnapshot(); + }); + }); }); diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index b48963ebb8139..3e8ce37fd1578 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import { Feature } from '../common/feature'; +import { FeatureConfig } from '../common/feature'; export interface BuildOSSFeaturesParams { savedObjectTypes: string[]; @@ -18,19 +18,24 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.discoverFeatureName', { defaultMessage: 'Discover', }), + order: 100, icon: 'discoverApp', navLinkId: 'kibana:discover', app: ['kibana'], catalogue: ['discover'], privileges: { all: { + app: ['kibana'], + catalogue: ['discover'], savedObject: { - all: ['search', 'url', 'query'], + all: ['search', 'query'], read: ['index-pattern'], }, - ui: ['show', 'createShortUrl', 'save', 'saveQuery'], + ui: ['show', 'save', 'saveQuery'], }, read: { + app: ['kibana'], + catalogue: ['discover'], savedObject: { all: [], read: ['index-pattern', 'search', 'query'], @@ -38,25 +43,59 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['show'], }, }, + subFeatures: [ + { + name: i18n.translate('xpack.features.ossFeatures.discoverShortUrlSubFeatureName', { + defaultMessage: 'Short URLs', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'url_create', + name: i18n.translate( + 'xpack.features.ossFeatures.discoverCreateShortUrlPrivilegeName', + { + defaultMessage: 'Create Short URLs', + } + ), + includeIn: 'all', + savedObject: { + all: ['url'], + read: [], + }, + ui: ['createShortUrl'], + }, + ], + }, + ], + }, + ], }, { id: 'visualize', name: i18n.translate('xpack.features.visualizeFeatureName', { defaultMessage: 'Visualize', }), + order: 200, icon: 'visualizeApp', navLinkId: 'kibana:visualize', app: ['kibana', 'lens'], catalogue: ['visualize'], privileges: { all: { + app: ['kibana', 'lens'], + catalogue: ['visualize'], savedObject: { - all: ['visualization', 'url', 'query', 'lens'], + all: ['visualization', 'query', 'lens'], read: ['index-pattern', 'search'], }, - ui: ['show', 'createShortUrl', 'delete', 'save', 'saveQuery'], + ui: ['show', 'delete', 'save', 'saveQuery'], }, read: { + app: ['kibana', 'lens'], + catalogue: ['visualize'], savedObject: { all: [], read: ['index-pattern', 'search', 'visualization', 'query', 'lens'], @@ -64,18 +103,50 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['show'], }, }, + subFeatures: [ + { + name: i18n.translate('xpack.features.ossFeatures.visualizeShortUrlSubFeatureName', { + defaultMessage: 'Short URLs', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'url_create', + name: i18n.translate( + 'xpack.features.ossFeatures.visualizeCreateShortUrlPrivilegeName', + { + defaultMessage: 'Create Short URLs', + } + ), + includeIn: 'all', + savedObject: { + all: ['url'], + read: [], + }, + ui: ['createShortUrl'], + }, + ], + }, + ], + }, + ], }, { id: 'dashboard', name: i18n.translate('xpack.features.dashboardFeatureName', { defaultMessage: 'Dashboard', }), + order: 300, icon: 'dashboardApp', navLinkId: 'kibana:dashboard', app: ['kibana'], catalogue: ['dashboard'], privileges: { all: { + app: ['kibana'], + catalogue: ['dashboard'], savedObject: { all: ['dashboard', 'url', 'query'], read: [ @@ -91,6 +162,8 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['createNew', 'show', 'showWriteControls', 'saveQuery'], }, read: { + app: ['kibana'], + catalogue: ['dashboard'], savedObject: { all: [], read: [ @@ -107,18 +180,50 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['show'], }, }, + subFeatures: [ + { + name: i18n.translate('xpack.features.ossFeatures.dashboardShortUrlSubFeatureName', { + defaultMessage: 'Short URLs', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'url_create', + name: i18n.translate( + 'xpack.features.ossFeatures.dashboardCreateShortUrlPrivilegeName', + { + defaultMessage: 'Create Short URLs', + } + ), + includeIn: 'all', + savedObject: { + all: ['url'], + read: [], + }, + ui: ['createShortUrl'], + }, + ], + }, + ], + }, + ], }, { id: 'dev_tools', name: i18n.translate('xpack.features.devToolsFeatureName', { defaultMessage: 'Dev Tools', }), + order: 1300, icon: 'devToolsApp', navLinkId: 'kibana:dev_tools', app: ['kibana'], catalogue: ['console', 'searchprofiler', 'grokdebugger'], privileges: { all: { + app: ['kibana'], + catalogue: ['console', 'searchprofiler', 'grokdebugger'], api: ['console'], savedObject: { all: [], @@ -127,6 +232,8 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['show', 'save'], }, read: { + app: ['kibana'], + catalogue: ['console', 'searchprofiler', 'grokdebugger'], api: ['console'], savedObject: { all: [], @@ -145,6 +252,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.advancedSettingsFeatureName', { defaultMessage: 'Advanced Settings', }), + order: 1500, icon: 'advancedSettingsApp', app: ['kibana'], catalogue: ['advanced_settings'], @@ -153,6 +261,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, privileges: { all: { + app: ['kibana'], + catalogue: ['advanced_settings'], + management: { + kibana: ['settings'], + }, savedObject: { all: ['config'], read: [], @@ -160,6 +273,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['save'], }, read: { + app: ['kibana'], + catalogue: ['advanced_settings'], + management: { + kibana: ['settings'], + }, savedObject: { all: [], read: [], @@ -173,6 +291,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.indexPatternFeatureName', { defaultMessage: 'Index Pattern Management', }), + order: 1600, icon: 'indexPatternApp', app: ['kibana'], catalogue: ['index_patterns'], @@ -181,6 +300,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, privileges: { all: { + app: ['kibana'], + catalogue: ['index_patterns'], + management: { + kibana: ['index_patterns'], + }, savedObject: { all: ['index-pattern'], read: [], @@ -188,6 +312,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['save'], }, read: { + app: ['kibana'], + catalogue: ['index_patterns'], + management: { + kibana: ['index_patterns'], + }, savedObject: { all: [], read: ['index-pattern'], @@ -201,6 +330,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS name: i18n.translate('xpack.features.savedObjectsManagementFeatureName', { defaultMessage: 'Saved Objects Management', }), + order: 1700, icon: 'savedObjectsApp', app: ['kibana'], catalogue: ['saved_objects'], @@ -209,6 +339,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, privileges: { all: { + app: ['kibana'], + catalogue: ['saved_objects'], + management: { + kibana: ['objects'], + }, api: ['copySavedObjectsToSpaces'], savedObject: { all: [...savedObjectTypes], @@ -217,6 +352,11 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS ui: ['read', 'edit', 'delete', 'copyIntoSpace'], }, read: { + app: ['kibana'], + catalogue: ['saved_objects'], + management: { + kibana: ['objects'], + }, api: ['copySavedObjectsToSpaces'], savedObject: { all: [], @@ -227,18 +367,21 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS }, }, ...(includeTimelion ? [timelionFeature] : []), - ]; + ] as FeatureConfig[]; }; -const timelionFeature: Feature = { +const timelionFeature: FeatureConfig = { id: 'timelion', name: 'Timelion', + order: 350, icon: 'timelionApp', navLinkId: 'timelion', app: ['timelion', 'kibana'], catalogue: ['timelion'], privileges: { all: { + app: ['timelion', 'kibana'], + catalogue: ['timelion'], savedObject: { all: ['timelion-sheet'], read: ['index-pattern'], @@ -246,6 +389,8 @@ const timelionFeature: Feature = { ui: ['save'], }, read: { + app: ['timelion', 'kibana'], + catalogue: ['timelion'], savedObject: { all: [], read: ['index-pattern', 'timelion-sheet'], diff --git a/x-pack/plugins/features/server/plugin.ts b/x-pack/plugins/features/server/plugin.ts index e77fa218c0681..cebf67243fb28 100644 --- a/x-pack/plugins/features/server/plugin.ts +++ b/x-pack/plugins/features/server/plugin.ts @@ -15,7 +15,7 @@ import { deepFreeze } from '../../../../src/core/utils'; import { XPackInfo } from '../../../legacy/plugins/xpack_main/server/lib/xpack_info'; import { PluginSetupContract as TimelionSetupContract } from '../../../../src/plugins/timelion/server'; import { FeatureRegistry } from './feature_registry'; -import { Feature, FeatureWithAllOrReadPrivileges } from '../common/feature'; +import { Feature, FeatureConfig } from '../common/feature'; import { uiCapabilitiesForFeatures } from './ui_capabilities_for_features'; import { buildOSSFeatures } from './oss_features'; import { defineRoutes } from './routes'; @@ -24,7 +24,7 @@ import { defineRoutes } from './routes'; * Describes public Features plugin contract returned at the `setup` stage. */ export interface PluginSetupContract { - registerFeature(feature: FeatureWithAllOrReadPrivileges): void; + registerFeature(feature: FeatureConfig): void; getFeatures(): Feature[]; getFeaturesUICapabilities(): UICapabilities; registerLegacyAPI: (legacyAPI: LegacyAPI) => void; diff --git a/x-pack/plugins/features/server/routes/index.test.ts b/x-pack/plugins/features/server/routes/index.test.ts index b0f8417b7175d..c43e2a5195fe7 100644 --- a/x-pack/plugins/features/server/routes/index.test.ts +++ b/x-pack/plugins/features/server/routes/index.test.ts @@ -10,6 +10,7 @@ import { defineRoutes } from './index'; import { httpServerMock, httpServiceMock } from '../../../../../src/core/server/mocks'; import { XPackInfoLicense } from '../../../../legacy/plugins/xpack_main/server/lib/xpack_info_license'; import { RequestHandler } from '../../../../../src/core/server'; +import { FeatureConfig } from '../../common'; let currentLicenseLevel: string = 'gold'; @@ -21,7 +22,23 @@ describe('GET /api/features', () => { id: 'feature_1', name: 'Feature 1', app: [], - privileges: {}, + privileges: null, + }); + + featureRegistry.register({ + id: 'feature_2', + name: 'Feature 2', + order: 2, + app: [], + privileges: null, + }); + + featureRegistry.register({ + id: 'feature_3', + name: 'Feature 2', + order: 1, + app: [], + privileges: null, }); featureRegistry.register({ @@ -29,7 +46,7 @@ describe('GET /api/features', () => { name: 'Licensed Feature', app: ['bar-app'], validLicenses: ['gold'], - privileges: {}, + privileges: null, }); const routerMock = httpServiceMock.createRouter(); @@ -51,37 +68,33 @@ describe('GET /api/features', () => { routeHandler = routerMock.get.mock.calls[0][1]; }); - it('returns a list of available features', async () => { + it('returns a list of available features, sorted by their configured order', async () => { const mockResponse = httpServerMock.createResponseFactory(); routeHandler(undefined as any, { query: {} } as any, mockResponse); - expect(mockResponse.ok.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "body": Array [ - Object { - "app": Array [], - "id": "feature_1", - "name": "Feature 1", - "privileges": Object {}, - }, - Object { - "app": Array [ - "bar-app", - ], - "id": "licensed_feature", - "name": "Licensed Feature", - "privileges": Object {}, - "validLicenses": Array [ - "gold", - ], - }, - ], - }, - ], - ] - `); + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body as FeatureConfig[]; + + const features = body.map(feature => ({ id: feature.id, order: feature.order })); + expect(features).toEqual([ + { + id: 'feature_3', + order: 1, + }, + { + id: 'feature_2', + order: 2, + }, + { + id: 'feature_1', + order: undefined, + }, + { + id: 'licensed_feature', + order: undefined, + }, + ]); }); it(`by default does not return features that arent allowed by current license`, async () => { @@ -90,22 +103,26 @@ describe('GET /api/features', () => { const mockResponse = httpServerMock.createResponseFactory(); routeHandler(undefined as any, { query: {} } as any, mockResponse); - expect(mockResponse.ok.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "body": Array [ - Object { - "app": Array [], - "id": "feature_1", - "name": "Feature 1", - "privileges": Object {}, - }, - ], - }, - ], - ] - `); + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body as FeatureConfig[]; + + const features = body.map(feature => ({ id: feature.id, order: feature.order })); + + expect(features).toEqual([ + { + id: 'feature_3', + order: 1, + }, + { + id: 'feature_2', + order: 2, + }, + { + id: 'feature_1', + order: undefined, + }, + ]); }); it(`ignoreValidLicenses=false does not return features that arent allowed by current license`, async () => { @@ -114,22 +131,26 @@ describe('GET /api/features', () => { const mockResponse = httpServerMock.createResponseFactory(); routeHandler(undefined as any, { query: { ignoreValidLicenses: false } } as any, mockResponse); - expect(mockResponse.ok.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "body": Array [ - Object { - "app": Array [], - "id": "feature_1", - "name": "Feature 1", - "privileges": Object {}, - }, - ], - }, - ], - ] - `); + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body as FeatureConfig[]; + + const features = body.map(feature => ({ id: feature.id, order: feature.order })); + + expect(features).toEqual([ + { + id: 'feature_3', + order: 1, + }, + { + id: 'feature_2', + order: 2, + }, + { + id: 'feature_1', + order: undefined, + }, + ]); }); it(`ignoreValidLicenses=true returns features that arent allowed by current license`, async () => { @@ -138,32 +159,29 @@ describe('GET /api/features', () => { const mockResponse = httpServerMock.createResponseFactory(); routeHandler(undefined as any, { query: { ignoreValidLicenses: true } } as any, mockResponse); - expect(mockResponse.ok.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "body": Array [ - Object { - "app": Array [], - "id": "feature_1", - "name": "Feature 1", - "privileges": Object {}, - }, - Object { - "app": Array [ - "bar-app", - ], - "id": "licensed_feature", - "name": "Licensed Feature", - "privileges": Object {}, - "validLicenses": Array [ - "gold", - ], - }, - ], - }, - ], - ] - `); + expect(mockResponse.ok).toHaveBeenCalledTimes(1); + const [call] = mockResponse.ok.mock.calls; + const body = call[0]!.body as FeatureConfig[]; + + const features = body.map(feature => ({ id: feature.id, order: feature.order })); + + expect(features).toEqual([ + { + id: 'feature_3', + order: 1, + }, + { + id: 'feature_2', + order: 2, + }, + { + id: 'feature_1', + order: undefined, + }, + { + id: 'licensed_feature', + order: undefined, + }, + ]); }); }); diff --git a/x-pack/plugins/features/server/routes/index.ts b/x-pack/plugins/features/server/routes/index.ts index cf4d61ccac88b..428500c3daa88 100644 --- a/x-pack/plugins/features/server/routes/index.ts +++ b/x-pack/plugins/features/server/routes/index.ts @@ -31,13 +31,19 @@ export function defineRoutes({ router, featureRegistry, getLegacyAPI }: RouteDef const allFeatures = featureRegistry.getAll(); return response.ok({ - body: allFeatures.filter( - feature => - request.query.ignoreValidLicenses || - !feature.validLicenses || - !feature.validLicenses.length || - getLegacyAPI().xpackInfo.license.isOneOf(feature.validLicenses) - ), + body: allFeatures + .filter( + feature => + request.query.ignoreValidLicenses || + !feature.validLicenses || + !feature.validLicenses.length || + getLegacyAPI().xpackInfo.license.isOneOf(feature.validLicenses) + ) + .sort( + (f1, f2) => + (f1.order ?? Number.MAX_SAFE_INTEGER) - (f2.order ?? Number.MAX_SAFE_INTEGER) + ) + .map(feature => feature.toRaw()), }); } ); diff --git a/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts b/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts index bb2cd82891a15..73c399878b17b 100644 --- a/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts +++ b/x-pack/plugins/features/server/ui_capabilities_for_features.test.ts @@ -5,17 +5,31 @@ */ import { uiCapabilitiesForFeatures } from './ui_capabilities_for_features'; +import { Feature } from '.'; +import { SubFeaturePrivilegeGroupConfig } from '../common'; -function createFeaturePrivilege(key: string, capabilities: string[] = []) { +function createFeaturePrivilege(capabilities: string[] = []) { return { - [key]: { - savedObject: { - all: [], - read: [], - }, - app: [], - ui: [...capabilities], + savedObject: { + all: [], + read: [], + }, + app: [], + ui: [...capabilities], + }; +} + +function createSubFeaturePrivilege(privilegeId: string, capabilities: string[] = []) { + return { + id: privilegeId, + name: `sub-feature privilege ${privilegeId}`, + includeIn: 'none', + savedObject: { + all: [], + read: [], }, + app: [], + ui: [...capabilities], }; } @@ -27,14 +41,15 @@ describe('populateUICapabilities', () => { it('handles features with no registered capabilities', () => { expect( uiCapabilitiesForFeatures([ - { + new Feature({ id: 'newFeature', name: 'my new feature', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('all'), + all: createFeaturePrivilege(), + read: createFeaturePrivilege(), }, - }, + }), ]) ).toEqual({ catalogue: {}, @@ -45,15 +60,16 @@ describe('populateUICapabilities', () => { it('augments the original uiCapabilities with registered feature capabilities', () => { expect( uiCapabilitiesForFeatures([ - { + new Feature({ id: 'newFeature', name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('all', ['capability1', 'capability2']), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(), }, - }, + }), ]) ).toEqual({ catalogue: {}, @@ -67,18 +83,17 @@ describe('populateUICapabilities', () => { it('combines catalogue entries from multiple features', () => { expect( uiCapabilitiesForFeatures([ - { + new Feature({ id: 'newFeature', name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], catalogue: ['anotherFooEntry', 'anotherBarEntry'], privileges: { - ...createFeaturePrivilege('foo', ['capability1', 'capability2']), - ...createFeaturePrivilege('bar', ['capability3', 'capability4']), - ...createFeaturePrivilege('baz'), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4']), }, - }, + }), ]) ).toEqual({ catalogue: { @@ -97,17 +112,75 @@ describe('populateUICapabilities', () => { it(`merges capabilities from all feature privileges`, () => { expect( uiCapabilitiesForFeatures([ - { + new Feature({ + id: 'newFeature', + name: 'my new feature', + navLinkId: 'newFeatureNavLink', + app: ['bar-app'], + privileges: { + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4', 'capability5']), + }, + }), + ]) + ).toEqual({ + catalogue: {}, + newFeature: { + capability1: true, + capability2: true, + capability3: true, + capability4: true, + capability5: true, + }, + }); + }); + + it(`supports merging features with sub privileges`, () => { + expect( + uiCapabilitiesForFeatures([ + new Feature({ id: 'newFeature', name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('foo', ['capability1', 'capability2']), - ...createFeaturePrivilege('bar', ['capability3', 'capability4']), - ...createFeaturePrivilege('baz', ['capability1', 'capability5']), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4']), }, - }, + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + createSubFeaturePrivilege('privilege-1', ['capability5']), + createSubFeaturePrivilege('privilege-2', ['capability6']), + ], + } as SubFeaturePrivilegeGroupConfig, + { + groupType: 'mutually_exclusive', + privileges: [ + createSubFeaturePrivilege('privilege-3', ['capability7']), + createSubFeaturePrivilege('privilege-4', ['capability8']), + ], + } as SubFeaturePrivilegeGroupConfig, + ], + }, + { + name: 'sub-feature-2', + privilegeGroups: [ + { + name: 'Group Name', + groupType: 'independent', + privileges: [ + createSubFeaturePrivilege('privilege-5', ['capability9', 'capability10']), + ], + } as SubFeaturePrivilegeGroupConfig, + ], + }, + ], + }), ]) ).toEqual({ catalogue: {}, @@ -117,6 +190,11 @@ describe('populateUICapabilities', () => { capability3: true, capability4: true, capability5: true, + capability6: true, + capability7: true, + capability8: true, + capability9: true, + capability10: true, }, }); }); @@ -124,41 +202,49 @@ describe('populateUICapabilities', () => { it('supports merging multiple features with multiple privileges each', () => { expect( uiCapabilitiesForFeatures([ - { + new Feature({ id: 'newFeature', name: 'my new feature', navLinkId: 'newFeatureNavLink', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('foo', ['capability1', 'capability2']), - ...createFeaturePrivilege('bar', ['capability3', 'capability4']), - ...createFeaturePrivilege('baz', ['capability1', 'capability5']), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4']), }, - }, - { + }), + new Feature({ id: 'anotherNewFeature', name: 'another new feature', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('foo', ['capability1', 'capability2']), - ...createFeaturePrivilege('bar', ['capability3', 'capability4']), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['capability3', 'capability4']), }, - }, - { + }), + new Feature({ id: 'yetAnotherNewFeature', name: 'yet another new feature', navLinkId: 'yetAnotherNavLink', app: ['bar-app'], privileges: { - ...createFeaturePrivilege('all', ['capability1', 'capability2']), - ...createFeaturePrivilege('read', []), - ...createFeaturePrivilege('somethingInBetween', [ - 'something1', - 'something2', - 'something3', - ]), + all: createFeaturePrivilege(['capability1', 'capability2']), + read: createFeaturePrivilege(['something1', 'something2', 'something3']), }, - }, + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + createSubFeaturePrivilege('privilege-1', ['capability3']), + createSubFeaturePrivilege('privilege-2', ['capability4']), + ], + } as SubFeaturePrivilegeGroupConfig, + ], + }, + ], + }), ]) ).toEqual({ anotherNewFeature: { @@ -173,11 +259,12 @@ describe('populateUICapabilities', () => { capability2: true, capability3: true, capability4: true, - capability5: true, }, yetAnotherNewFeature: { capability1: true, capability2: true, + capability3: true, + capability4: true, something1: true, something2: true, something3: true, diff --git a/x-pack/plugins/features/server/ui_capabilities_for_features.ts b/x-pack/plugins/features/server/ui_capabilities_for_features.ts index a13afa854de52..d3d3230822749 100644 --- a/x-pack/plugins/features/server/ui_capabilities_for_features.ts +++ b/x-pack/plugins/features/server/ui_capabilities_for_features.ts @@ -39,7 +39,14 @@ function getCapabilitiesFromFeature(feature: Feature): FeatureCapabilities { }; } - Object.values(feature.privileges).forEach(privilege => { + const featurePrivileges = Object.values(feature.privileges ?? {}); + if (feature.subFeatures) { + featurePrivileges.push( + ...feature.subFeatures.map(sf => sf.privilegeGroups.map(pg => pg.privileges)).flat(2) + ); + } + + featurePrivileges.forEach(privilege => { UIFeatureCapabilities[feature.id] = { ...UIFeatureCapabilities[feature.id], ...privilege.ui.reduce( diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx index a00d63af8aac2..914054e1fd9b7 100644 --- a/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx +++ b/x-pack/plugins/infra/public/components/alerting/metrics/alert_flyout.tsx @@ -35,6 +35,7 @@ export const AlertFlyout = (props: Props) => { }, toastNotifications: services.notifications?.toasts, http: services.http, + docLinks: services.docLinks, actionTypeRegistry: triggersActionsUI.actionTypeRegistry, alertTypeRegistry: triggersActionsUI.alertTypeRegistry, }} diff --git a/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx b/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx index ea8dd1484a670..0909a3c2ed569 100644 --- a/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx +++ b/x-pack/plugins/infra/public/components/alerting/metrics/expression.tsx @@ -70,7 +70,7 @@ export const Expressions: React.FC = props => { const { setAlertParams, alertParams, errors, alertsContext } = props; const { source, createDerivedIndexPattern } = useSource({ sourceId: 'default' }); const [timeSize, setTimeSize] = useState(1); - const [timeUnit, setTimeUnit] = useState('s'); + const [timeUnit, setTimeUnit] = useState('m'); const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ createDerivedIndexPattern, @@ -93,7 +93,7 @@ export const Expressions: React.FC = props => { comparator: '>', threshold: [], timeSize: 1, - timeUnit: 's', + timeUnit: 'm', indexPattern: source?.configuration.metricAlias, }), [source] diff --git a/x-pack/plugins/infra/public/pages/infrastructure/index.tsx b/x-pack/plugins/infra/public/pages/infrastructure/index.tsx index 730f67ab2bdca..422eb53148fe6 100644 --- a/x-pack/plugins/infra/public/pages/infrastructure/index.tsx +++ b/x-pack/plugins/infra/public/pages/infrastructure/index.tsx @@ -91,7 +91,7 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { /> - + diff --git a/x-pack/plugins/infra/server/features.ts b/x-pack/plugins/infra/server/features.ts index edf94beab43a7..5301e1e9cbd0b 100644 --- a/x-pack/plugins/infra/server/features.ts +++ b/x-pack/plugins/infra/server/features.ts @@ -11,12 +11,15 @@ export const METRICS_FEATURE = { name: i18n.translate('xpack.infra.featureRegistry.linkInfrastructureTitle', { defaultMessage: 'Metrics', }), + order: 700, icon: 'metricsApp', navLinkId: 'metrics', app: ['infra', 'kibana'], catalogue: ['infraops'], privileges: { all: { + app: ['infra', 'kibana'], + catalogue: ['infraops'], api: ['infra'], savedObject: { all: ['infrastructure-ui-source'], @@ -25,6 +28,8 @@ export const METRICS_FEATURE = { ui: ['show', 'configureSource', 'save'], }, read: { + app: ['infra', 'kibana'], + catalogue: ['infraops'], api: ['infra'], savedObject: { all: [], @@ -40,12 +45,15 @@ export const LOGS_FEATURE = { name: i18n.translate('xpack.infra.featureRegistry.linkLogsTitle', { defaultMessage: 'Logs', }), + order: 800, icon: 'logsApp', navLinkId: 'logs', app: ['infra', 'kibana'], catalogue: ['infralogging'], privileges: { all: { + app: ['infra', 'kibana'], + catalogue: ['infralogging'], api: ['infra'], savedObject: { all: ['infrastructure-ui-source'], @@ -54,6 +62,8 @@ export const LOGS_FEATURE = { ui: ['show', 'configureSource', 'save'], }, read: { + app: ['infra', 'kibana'], + catalogue: ['infralogging'], api: ['infra'], savedObject: { all: [], diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index 778889ba0c7a5..bfe04b82b95fc 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -11,6 +11,10 @@ import { getAllCompositeData } from '../../../utils/get_all_composite_data'; import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic'; import { MetricExpressionParams, Comparator, AlertStates } from './types'; import { AlertServices, AlertExecutorOptions } from '../../../../../alerting/server'; +import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; +import { getDateHistogramOffset } from '../../snapshot/query_helpers'; + +const TOTAL_BUCKETS = 5; interface Aggregation { aggregatedIntervals: { @@ -70,6 +74,12 @@ export const getElasticsearchMetricQuery = ( throw new Error('Can only aggregate without a metric if using the document count aggregator'); } const interval = `${timeSize}${timeUnit}`; + const to = Date.now(); + const intervalAsSeconds = getIntervalInSeconds(interval); + // We need enough data for 5 buckets worth of data. We also need + // to convert the intervalAsSeconds to milliseconds. + const from = to - intervalAsSeconds * 1000 * TOTAL_BUCKETS; + const offset = getDateHistogramOffset(from, interval); const aggregations = aggType === 'count' @@ -89,6 +99,11 @@ export const getElasticsearchMetricQuery = ( date_histogram: { field: '@timestamp', fixed_interval: interval, + offset, + extended_bounds: { + min: from, + max: to, + }, }, aggregations, }, @@ -118,7 +133,9 @@ export const getElasticsearchMetricQuery = ( { range: { '@timestamp': { - gte: `now-${interval}`, + gte: from, + lte: to, + format: 'epoch_millis', }, }, }, diff --git a/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts b/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts index 383dc9a773abe..82a393079745f 100644 --- a/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts +++ b/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts @@ -87,8 +87,7 @@ export const getMetricsAggregations = (options: InfraSnapshotRequestOptions): Sn return aggregation; }; -export const getDateHistogramOffset = (options: InfraSnapshotRequestOptions): string => { - const { from, interval } = options.timerange; +export const getDateHistogramOffset = (from: number, interval: string): string => { const fromInSeconds = Math.floor(from / 1000); const bucketSizeInSeconds = getIntervalInSeconds(interval); diff --git a/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts b/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts index 8e5f8e6716f3c..07abfa5fd474a 100644 --- a/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts +++ b/x-pack/plugins/infra/server/lib/snapshot/snapshot.ts @@ -159,7 +159,7 @@ const requestNodeMetrics = async ( date_histogram: { field: options.sourceConfiguration.fields.timestamp, interval: options.timerange.interval || '1m', - offset: getDateHistogramOffset(options), + offset: getDateHistogramOffset(options.timerange.from, options.timerange.interval), extended_bounds: { min: options.timerange.from, max: options.timerange.to, diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index 67737c6fe502e..45c847fe1f68a 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -88,6 +88,7 @@ export class IngestManagerPlugin implements Plugin { privileges: { all: { api: [`${PLUGIN_ID}-read`, `${PLUGIN_ID}-all`], + app: [PLUGIN_ID, 'kibana'], savedObject: { all: allSavedObjectTypes, read: [], @@ -96,6 +97,7 @@ export class IngestManagerPlugin implements Plugin { }, read: { api: [`${PLUGIN_ID}-read`], + app: [PLUGIN_ID, 'kibana'], savedObject: { all: [], read: allSavedObjectTypes, diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index 12b03f0386304..814825483d0dd 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -188,7 +188,7 @@ export enum LABEL_BORDER_SIZES { LARGE = 'LARGE', } -export const DEFAULT_ICON = 'airfield'; +export const DEFAULT_ICON = 'marker'; export enum VECTOR_STYLES { SYMBOLIZE_AS = 'symbolizeAs', diff --git a/x-pack/plugins/ml/public/application/components/anomalies_table/influencers_cell.js b/x-pack/plugins/ml/public/application/components/anomalies_table/influencers_cell.js index 7d00c9818a1e2..f4b16dab5ef52 100644 --- a/x-pack/plugins/ml/public/application/components/anomalies_table/influencers_cell.js +++ b/x-pack/plugins/ml/public/application/components/anomalies_table/influencers_cell.js @@ -56,7 +56,7 @@ export class InfluencersCell extends Component { } > influencerFilter( @@ -83,7 +83,7 @@ export class InfluencersCell extends Component { } > influencerFilter( diff --git a/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.js b/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.js index 259e0d335c40f..02a9e569f28a4 100644 --- a/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.js +++ b/x-pack/plugins/ml/public/application/components/entity_cell/entity_cell.js @@ -22,7 +22,7 @@ function getAddFilter({ entityName, entityValue, filter }) { } > filter(entityName, entityValue, '+')} iconType="plusInCircle" @@ -45,7 +45,7 @@ function getRemoveFilter({ entityName, entityValue, filter }) { } > filter(entityName, entityValue, '-')} iconType="minusInCircle" diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss index 962d3f4c7bd54..83314a74331fd 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss @@ -1,4 +1,3 @@ -@import 'pages/analytics_exploration/components/exploration/index'; @import 'pages/analytics_exploration/components/regression_exploration/index'; @import 'pages/analytics_exploration/components/classification_exploration/index'; @import 'pages/analytics_management/components/analytics_list/index'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 9c239df357163..95a8dfbb308f8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -56,6 +56,8 @@ export interface LoadExploreDataArg { direction: SortDirection; searchQuery: SavedSearchQuery; requiresKeyword?: boolean; + pageIndex?: number; + pageSize?: number; } export const SEARCH_SIZE = 1000; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/data_grid.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/data_grid.ts new file mode 100644 index 0000000000000..2b6d733837562 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/data_grid.ts @@ -0,0 +1,23 @@ +/* + * 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 { EuiDataGridStyle } from '@elastic/eui'; + +export const euiDataGridStyle: EuiDataGridStyle = { + border: 'all', + fontSize: 's', + cellPadding: 's', + stripes: false, + rowHover: 'none', + header: 'shade', +}; + +export const euiDataGridToolbarSettings = { + showColumnSelector: true, + showStyleSelector: false, + showSortSelector: true, + showFullScreenSelector: false, +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts index e8ebf2b1cfd56..fb1d4edb37af8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/fields.ts @@ -362,18 +362,16 @@ export const getDefaultSelectableFields = (docs: EsDoc[], resultsField: string): } const newDocFields = getFlattenedFields(docs[0]._source, resultsField); - return newDocFields - .filter(k => { - if (k === `${resultsField}.outlier_score`) { - return true; - } - if (k.split('.')[0] === resultsField) { - return false; - } + return newDocFields.filter(k => { + if (k === `${resultsField}.outlier_score`) { + return true; + } + if (k.split('.')[0] === resultsField) { + return false; + } - return docs.some(row => row._source[k] !== null); - }) - .slice(0, MAX_COLUMNS); + return docs.some(row => row._source[k] !== null); + }); }; export const toggleSelectedFieldSimple = ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts index 62ef73670d8f5..7b76faf613ce8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts @@ -46,3 +46,5 @@ export { EsFieldName, MAX_COLUMNS, } from './fields'; + +export { euiDataGridStyle, euiDataGridToolbarSettings } from './data_grid'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx index baf7fd32b0f60..14493ab024f34 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/column_data.tsx @@ -4,79 +4,115 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { ConfusionMatrix, PredictedClass } from '../../../../common/analytics'; +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiDataGridControlColumn, + EuiPopover, + EuiText, +} from '@elastic/eui'; +import { ConfusionMatrix } from '../../../../common/analytics'; interface ColumnData { actual_class: string; actual_class_doc_count: number; - predicted_class?: string; - count?: number; - error_count?: number; + [key: string]: string | number; } export const ACTUAL_CLASS_ID = 'actual_class'; +export const OTHER_CLASS_ID = 'other'; +export const MAX_COLUMNS = 6; export function getColumnData(confusionMatrixData: ConfusionMatrix[]) { const colData: Partial = []; + const columns: Array<{ id: string; display?: any }> = [ + { + id: ACTUAL_CLASS_ID, + display: , + }, + ]; - confusionMatrixData.forEach((classData: any) => { - const correctlyPredictedClass = classData.predicted_classes.find( - (pc: PredictedClass) => pc.predicted_class === classData.actual_class - ); - const incorrectlyPredictedClass = classData.predicted_classes.find( - (pc: PredictedClass) => pc.predicted_class !== classData.actual_class - ); + let showOther = false; - let accuracy; - if (correctlyPredictedClass !== undefined) { - accuracy = correctlyPredictedClass.count / classData.actual_class_doc_count; - // round to 2 decimal places without converting to string; - accuracy = Math.round(accuracy * 100) / 100; - } + confusionMatrixData.forEach(classData => { + const otherCount = classData.other_predicted_class_doc_count; - let error; - if (incorrectlyPredictedClass !== undefined) { - error = incorrectlyPredictedClass.count / classData.actual_class_doc_count; - error = Math.round(error * 100) / 100; + if (otherCount > 0) { + showOther = true; } - let col: any = { + const col: any = { actual_class: classData.actual_class, actual_class_doc_count: classData.actual_class_doc_count, + other: otherCount, }; - if (correctlyPredictedClass !== undefined) { - col = { - ...col, - predicted_class: correctlyPredictedClass.predicted_class, - [correctlyPredictedClass.predicted_class]: accuracy, - count: correctlyPredictedClass.count, - accuracy, - }; - } + const predictedClasses = classData.predicted_classes || []; - if (incorrectlyPredictedClass !== undefined) { - col = { - ...col, - [incorrectlyPredictedClass.predicted_class]: error, - error_count: incorrectlyPredictedClass.count, - }; + columns.push({ id: classData.actual_class }); + + for (let i = 0; i < predictedClasses.length; i++) { + const predictedClass = predictedClasses[i].predicted_class; + const predictedClassCount = predictedClasses[i].count; + col[predictedClass] = predictedClassCount; } colData.push(col); }); - const columns: any = [ + if (showOther) { + columns.push({ id: OTHER_CLASS_ID }); + } + + return { columns, columnData: colData }; +} + +export function getTrailingControlColumns( + numColumns: number, + setShowFullColumns: any +): EuiDataGridControlColumn[] { + return [ { - id: ACTUAL_CLASS_ID, - display: , + id: 'actions', + width: 60, + headerCellRender: () => {`${numColumns} more`}, + rowCellRender: function RowCellRender() { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + return ( + setIsPopoverOpen(!isPopoverOpen)} + /> + } + closePopover={() => setIsPopoverOpen(false)} + ownFocus={true} + > + setShowFullColumns(true)}> + + {i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.showAllColumns', + { + defaultMessage: 'Show all columns', + } + )} + + + + ); + }, }, ]; - - colData.forEach((data: any) => { - columns.push({ id: data.predicted_class }); - }); - - return { columns, columnData: colData }; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx index 7bf55f4ecf392..1c5563bdb4f83 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -39,7 +39,12 @@ import { ANALYSIS_CONFIG_TYPE, } from '../../../../common/analytics'; import { LoadingPanel } from '../loading_panel'; -import { getColumnData, ACTUAL_CLASS_ID } from './column_data'; +import { + getColumnData, + ACTUAL_CLASS_ID, + MAX_COLUMNS, + getTrailingControlColumns, +} from './column_data'; const defaultPanelWidth = 500; @@ -57,6 +62,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) const [confusionMatrixData, setConfusionMatrixData] = useState([]); const [columns, setColumns] = useState([]); const [columnsData, setColumnsData] = useState([]); + const [showFullColumns, setShowFullColumns] = useState(false); const [popoverContents, setPopoverContents] = useState([]); const [docsCount, setDocsCount] = useState(null); const [error, setError] = useState(null); @@ -168,8 +174,9 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) const colId = children?.props?.columnId; const gridItem = columnData[rowIndex]; - if (gridItem !== undefined) { - const count = colId === gridItem.actual_class ? gridItem.count : gridItem.error_count; + if (gridItem !== undefined && colId !== ACTUAL_CLASS_ID) { + // @ts-ignore + const count = gridItem[colId]; return `${count} / ${gridItem.actual_class_doc_count} * 100 = ${cellContentsElement.textContent}`; } @@ -203,19 +210,26 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) setCellProps: any; }) => { const cellValue = columnsData[rowIndex][columnId]; + const actualCount = columnsData[rowIndex] && columnsData[rowIndex].actual_class_doc_count; + let accuracy: number | string = '0%'; + + if (columnId !== ACTUAL_CLASS_ID && actualCount) { + accuracy = cellValue / actualCount; + // round to 2 decimal places without converting to string; + accuracy = Math.round(accuracy * 100) / 100; + accuracy = `${Math.round(accuracy * 100)}%`; + } // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { if (columnId !== ACTUAL_CLASS_ID) { setCellProps({ style: { - backgroundColor: `rgba(0, 179, 164, ${cellValue})`, + backgroundColor: `rgba(0, 179, 164, ${accuracy})`, }, }); } }, [rowIndex, columnId, setCellProps]); - return ( - {typeof cellValue === 'number' ? `${Math.round(cellValue * 100)}%` : cellValue} - ); + return {columnId === ACTUAL_CLASS_ID ? cellValue : accuracy}; }; if (isLoading === true) { @@ -224,6 +238,15 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; + const showTrailingColumns = columnsData.length > MAX_COLUMNS; + const extraColumns = columnsData.length - MAX_COLUMNS; + const shownColumns = + showTrailingColumns === true && showFullColumns === false + ? columns.slice(0, MAX_COLUMNS + 1) + : columns; + const rowCount = + showTrailingColumns === true && showFullColumns === false ? MAX_COLUMNS : columnsData.length; + return ( = ({ jobConfig, jobStatus, searchQuery }) )} {/* BEGIN TABLE ELEMENTS */} - + = ({ jobConfig, jobStatus, searchQuery }) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/_exploration.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/_exploration.scss deleted file mode 100644 index b5b90347cf0b8..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/_exploration.scss +++ /dev/null @@ -1,19 +0,0 @@ -.mlDataFrameAnalyticsExploration { - /* Overwrite to give table cells a more grid-like appearance */ - .euiTableHeaderCell { - padding: 0 4px; - } - .euiTableCellContent { - padding: 0; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - line-height: 2; - } -} - -.mlColoredTableCell { - width: 100%; - height: 100%; - padding: 0 4px; -} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/_index.scss deleted file mode 100644 index ca27eec1d5a4d..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'exploration'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx deleted file mode 100644 index 70c29051c8215..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx +++ /dev/null @@ -1,572 +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, { FC, useEffect, useState } from 'react'; -import moment from 'moment-timezone'; - -import { i18n } from '@kbn/i18n'; - -import { - EuiBadge, - EuiButtonIcon, - EuiCallOut, - EuiCheckbox, - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiPopover, - EuiPopoverTitle, - EuiProgress, - EuiSpacer, - EuiText, - EuiTitle, - EuiToolTip, - Query, -} from '@elastic/eui'; - -import { - useColorRange, - ColorRangeLegend, - COLOR_RANGE, - COLOR_RANGE_SCALE, -} from '../../../../../components/color_range_legend'; -import { - ColumnType, - mlInMemoryTableBasicFactory, - OnTableChangeArg, - SortingPropType, - SORT_DIRECTION, -} from '../../../../../components/ml_in_memory_table'; - -import { formatHumanReadableDateTimeSeconds } from '../../../../../util/date_utils'; -import { ml } from '../../../../../services/ml_api_service'; - -import { - sortColumns, - toggleSelectedFieldSimple, - DataFrameAnalyticsConfig, - EsFieldName, - EsDoc, - MAX_COLUMNS, - INDEX_STATUS, - SEARCH_SIZE, - defaultSearchQuery, -} from '../../../../common'; -import { isKeywordAndTextType } from '../../../../common/fields'; - -import { getOutlierScoreFieldName } from './common'; -import { useExploreData, TableItem } from './use_explore_data'; -import { - DATA_FRAME_TASK_STATE, - Query as QueryType, -} from '../../../analytics_management/components/analytics_list/common'; -import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/columns'; -import { SavedSearchQuery } from '../../../../../contexts/ml'; -import { getIndexPatternIdFromName } from '../../../../../util/index_utils'; -import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/common/index_patterns'; -import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; -import { useMlContext } from '../../../../../contexts/ml'; - -const FEATURE_INFLUENCE = 'feature_influence'; - -const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]; - -const ExplorationTitle: React.FC<{ jobId: string }> = ({ jobId }) => ( - - - {i18n.translate('xpack.ml.dataframe.analytics.exploration.jobIdTitle', { - defaultMessage: 'Outlier detection job ID {jobId}', - values: { jobId }, - })} - - -); - -interface Props { - jobId: string; - jobStatus: DATA_FRAME_TASK_STATE; -} - -const getFeatureCount = (jobConfig?: DataFrameAnalyticsConfig, tableItems: TableItem[] = []) => { - if (jobConfig === undefined || tableItems.length === 0) { - return 0; - } - - return Object.keys(tableItems[0]).filter(key => - key.includes(`${jobConfig.dest.results_field}.${FEATURE_INFLUENCE}.`) - ).length; -}; - -export const Exploration: FC = React.memo(({ jobId, jobStatus }) => { - const [jobConfig, setJobConfig] = useState(undefined); - - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(25); - - const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); - const [searchError, setSearchError] = useState(undefined); - const [searchString, setSearchString] = useState(undefined); - - const mlContext = useMlContext(); - - const initializeJobCapsService = async () => { - if (jobConfig !== undefined) { - const sourceIndex = jobConfig.source.index[0]; - const indexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex; - const indexPattern: IIndexPattern = await mlContext.indexPatterns.get(indexPatternId); - if (indexPattern !== undefined) { - await newJobCapsService.initializeFromIndexPattern(indexPattern, false, false); - } - } - }; - - useEffect(() => { - (async function() { - const analyticsConfigs = await ml.dataFrameAnalytics.getDataFrameAnalytics(jobId); - if ( - Array.isArray(analyticsConfigs.data_frame_analytics) && - analyticsConfigs.data_frame_analytics.length > 0 - ) { - setJobConfig(analyticsConfigs.data_frame_analytics[0]); - } - })(); - }, []); - - useEffect(() => { - initializeJobCapsService(); - }, [jobConfig && jobConfig.id]); - - const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]); - const [isColumnsPopoverVisible, setColumnsPopoverVisible] = useState(false); - - function toggleColumnsPopover() { - setColumnsPopoverVisible(!isColumnsPopoverVisible); - } - - function closeColumnsPopover() { - setColumnsPopoverVisible(false); - } - - function toggleColumn(column: EsFieldName) { - if (tableItems.length > 0 && jobConfig !== undefined) { - // spread to a new array otherwise the component wouldn't re-render - setSelectedFields([...toggleSelectedFieldSimple(selectedFields, column)]); - } - } - - const { - errorMessage, - loadExploreData, - sortField, - sortDirection, - status, - tableItems, - } = useExploreData(jobConfig, selectedFields, setSelectedFields); - - let docFields: EsFieldName[] = []; - let docFieldsCount = 0; - if (tableItems.length > 0) { - docFields = Object.keys(tableItems[0]); - docFields.sort(); - docFieldsCount = docFields.length; - } - - const columns: Array> = []; - - const cellBgColor = useColorRange( - COLOR_RANGE.BLUE, - COLOR_RANGE_SCALE.INFLUENCER, - getFeatureCount(jobConfig, tableItems) - ); - - if (jobConfig !== undefined && selectedFields.length > 0 && tableItems.length > 0) { - columns.push( - ...selectedFields.sort(sortColumns(tableItems[0], jobConfig.dest.results_field)).map(k => { - const column: ColumnType = { - field: k, - name: k, - sortable: true, - truncateText: true, - }; - - const render = (d: any, fullItem: EsDoc) => { - if (Array.isArray(d) && d.every(item => typeof item === 'string')) { - // If the cells data is an array of strings, return as a comma separated list. - // The list will get limited to 5 items with `…` at the end if there's more in the original array. - return `${d.slice(0, 5).join(', ')}${d.length > 5 ? ', …' : ''}`; - } else if (Array.isArray(d)) { - // If the cells data is an array of e.g. objects, display a 'array' badge with a - // tooltip that explains that this type of field is not supported in this table. - return ( - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.exploration.indexArrayBadgeContent', - { - defaultMessage: 'array', - } - )} - - - ); - } else if (typeof d === 'object' && d !== null) { - // If the cells data is an object, display a 'object' badge with a - // tooltip that explains that this type of field is not supported in this table. - return ( - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.exploration.indexObjectBadgeContent', - { - defaultMessage: 'object', - } - )} - - - ); - } - - const split = k.split('.'); - let backgroundColor; - const color = undefined; - const resultsField = jobConfig.dest.results_field; - - if (fullItem[`${resultsField}.${FEATURE_INFLUENCE}.${k}`] !== undefined) { - backgroundColor = cellBgColor(fullItem[`${resultsField}.${FEATURE_INFLUENCE}.${k}`]); - } - - if (split.length > 2 && split[0] === resultsField && split[1] === FEATURE_INFLUENCE) { - backgroundColor = cellBgColor(d); - } - - return ( -
- {d} -
- ); - }; - - let columnType; - - if (tableItems.length > 0) { - columnType = typeof tableItems[0][k]; - } - - if (typeof columnType !== 'undefined') { - switch (columnType) { - case 'boolean': - column.dataType = 'boolean'; - break; - case 'Date': - column.align = 'right'; - column.render = (d: any) => - formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000); - break; - case 'number': - column.dataType = 'number'; - column.render = render; - break; - default: - column.render = render; - break; - } - } else { - column.render = render; - } - - return column; - }) - ); - } - - useEffect(() => { - if (jobConfig !== undefined) { - const outlierScoreFieldName = getOutlierScoreFieldName(jobConfig); - const outlierScoreFieldSelected = selectedFields.includes(outlierScoreFieldName); - let requiresKeyword = false; - - const field = outlierScoreFieldSelected ? outlierScoreFieldName : selectedFields[0]; - const direction = outlierScoreFieldSelected ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC; - - if (outlierScoreFieldSelected === false) { - requiresKeyword = isKeywordAndTextType(field); - } - - loadExploreData({ field, direction, searchQuery, requiresKeyword }); - } - }, [JSON.stringify(searchQuery)]); - - useEffect(() => { - // by default set the sorting to descending on the `outlier_score` field. - // if that's not available sort ascending on the first column. - // also check if the current sorting field is still available. - if (jobConfig !== undefined && columns.length > 0 && !selectedFields.includes(sortField)) { - const outlierScoreFieldName = getOutlierScoreFieldName(jobConfig); - const outlierScoreFieldSelected = selectedFields.includes(outlierScoreFieldName); - let requiresKeyword = false; - - const field = outlierScoreFieldSelected ? outlierScoreFieldName : selectedFields[0]; - const direction = outlierScoreFieldSelected ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC; - - if (outlierScoreFieldSelected === false) { - requiresKeyword = isKeywordAndTextType(field); - } - - loadExploreData({ field, direction, searchQuery, requiresKeyword }); - return; - } - }, [jobConfig, columns.length, sortField, sortDirection, tableItems.length]); - - let sorting: SortingPropType = false; - let onTableChange; - - if (columns.length > 0 && sortField !== '') { - sorting = { - sort: { - field: sortField, - direction: sortDirection, - }, - }; - - onTableChange = ({ - page = { index: 0, size: 10 }, - sort = { field: sortField, direction: sortDirection }, - }: OnTableChangeArg) => { - const { index, size } = page; - setPageIndex(index); - setPageSize(size); - - if ( - (sort.field !== sortField || sort.direction !== sortDirection) && - jobConfig !== undefined - ) { - const outlierScoreFieldName = getOutlierScoreFieldName(jobConfig); - let requiresKeyword = false; - - if (outlierScoreFieldName !== sort.field) { - requiresKeyword = isKeywordAndTextType(sort.field); - } - loadExploreData({ ...sort, searchQuery, requiresKeyword }); - } - }; - } - - const pagination = { - initialPageIndex: pageIndex, - initialPageSize: pageSize, - totalItemCount: tableItems.length, - pageSizeOptions: PAGE_SIZE_OPTIONS, - hidePerPageOptions: false, - }; - - const onQueryChange = ({ query, error }: { query: QueryType; error: any }) => { - if (error) { - setSearchError(error.message); - } else { - try { - const esQueryDsl = Query.toESQuery(query); - setSearchQuery(esQueryDsl); - setSearchString(query.text); - setSearchError(undefined); - } catch (e) { - setSearchError(e.toString()); - } - } - }; - - const search = { - onChange: onQueryChange, - defaultQuery: searchString, - box: { - incremental: false, - placeholder: i18n.translate('xpack.ml.dataframe.analytics.exploration.searchBoxPlaceholder', { - defaultMessage: 'E.g. avg>0.5', - }), - }, - }; - - if (jobConfig === undefined) { - return null; - } - // if it's a searchBar syntax error leave the table visible so they can try again - if (status === INDEX_STATUS.ERROR && !errorMessage.includes('parsing_exception')) { - return ( - - - -

{errorMessage}

-
-
- ); - } - - let tableError = - status === INDEX_STATUS.ERROR && errorMessage.includes('parsing_exception') - ? errorMessage - : searchError; - - if (status === INDEX_STATUS.LOADED && tableItems.length === 0 && tableError === undefined) { - tableError = i18n.translate('xpack.ml.dataframe.analytics.exploration.noDataCalloutBody', { - defaultMessage: - 'The query for the index returned no results. Please make sure the index contains documents and your query is not too restrictive.', - }); - } - - const MlInMemoryTableBasic = mlInMemoryTableBasicFactory(); - - return ( - - - - - - - - - {getTaskStateBadge(jobStatus)} - - - - - - - {docFieldsCount > MAX_COLUMNS && ( - - {i18n.translate('xpack.ml.dataframe.analytics.exploration.fieldSelection', { - defaultMessage: - '{selectedFieldsLength, number} of {docFieldsCount, number} {docFieldsCount, plural, one {field} other {fields}} selected', - values: { selectedFieldsLength: selectedFields.length, docFieldsCount }, - })} - - )} - - - - - } - isOpen={isColumnsPopoverVisible} - closePopover={closeColumnsPopover} - ownFocus - > - - {i18n.translate( - 'xpack.ml.dataframe.analytics.exploration.selectFieldsPopoverTitle', - { - defaultMessage: 'Select fields', - } - )} - -
- {docFields.map(d => ( - toggleColumn(d)} - disabled={selectedFields.includes(d) && selectedFields.length === 1} - /> - ))} -
-
-
-
-
-
-
- {status === INDEX_STATUS.LOADING && } - {status !== INDEX_STATUS.LOADING && ( - - )} - {(columns.length > 0 || searchQuery !== defaultSearchQuery) && sortField !== '' && ( - <> - - - - {tableItems.length === SEARCH_SIZE && ( - - {i18n.translate( - 'xpack.ml.dataframe.analytics.exploration.documentsShownHelpText', - { - defaultMessage: 'Showing first {searchSize} documents', - values: { searchSize: SEARCH_SIZE }, - } - )} - - )} - - - - - - - - )} -
- ); -}); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/index.ts deleted file mode 100644 index 6f15c278158dc..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export { Exploration } from './exploration'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/use_explore_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/use_explore_data.ts deleted file mode 100644 index 24cc8d000de7e..0000000000000 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/use_explore_data.ts +++ /dev/null @@ -1,153 +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, { useEffect, useState } from 'react'; - -import { SearchResponse } from 'elasticsearch'; - -import { SortDirection, SORT_DIRECTION } from '../../../../../components/ml_in_memory_table'; - -import { ml } from '../../../../../services/ml_api_service'; -import { getNestedProperty } from '../../../../../util/object_utils'; - -import { - getDefaultSelectableFields, - getFlattenedFields, - DataFrameAnalyticsConfig, - EsFieldName, - INDEX_STATUS, - SEARCH_SIZE, - defaultSearchQuery, - SearchQuery, -} from '../../../../common'; -import { LoadExploreDataArg } from '../../../../common/analytics'; - -import { getOutlierScoreFieldName } from './common'; - -export type TableItem = Record; - -export interface UseExploreDataReturnType { - errorMessage: string; - loadExploreData: (arg: LoadExploreDataArg) => void; - sortField: EsFieldName; - sortDirection: SortDirection; - status: INDEX_STATUS; - tableItems: TableItem[]; -} - -export const useExploreData = ( - jobConfig: DataFrameAnalyticsConfig | undefined, - selectedFields: EsFieldName[], - setSelectedFields: React.Dispatch> -): UseExploreDataReturnType => { - const [errorMessage, setErrorMessage] = useState(''); - const [status, setStatus] = useState(INDEX_STATUS.UNUSED); - const [tableItems, setTableItems] = useState([]); - const [sortField, setSortField] = useState(''); - const [sortDirection, setSortDirection] = useState(SORT_DIRECTION.ASC); - - const loadExploreData = async ({ - field, - direction, - searchQuery, - requiresKeyword, - }: LoadExploreDataArg) => { - if (jobConfig !== undefined) { - setErrorMessage(''); - setStatus(INDEX_STATUS.LOADING); - - try { - const resultsField = jobConfig.dest.results_field; - - const body: SearchQuery = { - query: searchQuery, - }; - - if (field !== undefined) { - body.sort = [ - { - [`${field}${requiresKeyword ? '.keyword' : ''}`]: { - order: direction, - }, - }, - ]; - } - - const resp: SearchResponse = await ml.esSearch({ - index: jobConfig.dest.index, - size: SEARCH_SIZE, - body, - }); - - setSortField(field); - setSortDirection(direction); - - const docs = resp.hits.hits; - - if (docs.length === 0) { - setTableItems([]); - setStatus(INDEX_STATUS.LOADED); - return; - } - - if (selectedFields.length === 0) { - const newSelectedFields = getDefaultSelectableFields(docs, resultsField); - setSelectedFields(newSelectedFields); - } - - // Create a version of the doc's source with flattened field names. - // This avoids confusion later on if a field name has dots in its name - // or is a nested fields when displaying it via EuiInMemoryTable. - const flattenedFields = getFlattenedFields(docs[0]._source, resultsField); - const transformedTableItems = docs.map(doc => { - const item: TableItem = {}; - flattenedFields.forEach(ff => { - item[ff] = getNestedProperty(doc._source, ff); - if (item[ff] === undefined) { - // If the attribute is undefined, it means it was not a nested property - // but had dots in its actual name. This selects the property by its - // full name and assigns it to `item[ff]`. - item[ff] = doc._source[`"${ff}"`]; - } - if (item[ff] === undefined) { - const parts = ff.split('.'); - if (parts[0] === resultsField && parts.length >= 2) { - parts.shift(); - if (doc._source[resultsField] !== undefined) { - item[ff] = doc._source[resultsField][parts.join('.')]; - } - } - } - }); - return item; - }); - - setTableItems(transformedTableItems); - setStatus(INDEX_STATUS.LOADED); - } catch (e) { - if (e.message !== undefined) { - setErrorMessage(e.message); - } else { - setErrorMessage(JSON.stringify(e)); - } - setTableItems([]); - setStatus(INDEX_STATUS.ERROR); - } - } - }; - - useEffect(() => { - if (jobConfig !== undefined) { - loadExploreData({ - field: getOutlierScoreFieldName(jobConfig), - direction: SORT_DIRECTION.DESC, - searchQuery: defaultSearchQuery, - }); - } - }, [jobConfig && jobConfig.id]); - - return { errorMessage, loadExploreData, sortField, sortDirection, status, tableItems }; -}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_data_grid/exploration_data_grid.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_data_grid/exploration_data_grid.tsx new file mode 100644 index 0000000000000..2df0f70a56722 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_data_grid/exploration_data_grid.tsx @@ -0,0 +1,145 @@ +/* + * 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, { Dispatch, FC, SetStateAction, useCallback, useMemo } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { EuiDataGrid, EuiDataGridPaginationProps, EuiDataGridSorting } from '@elastic/eui'; + +import { euiDataGridStyle, euiDataGridToolbarSettings } from '../../../../common'; + +const FEATURE_INFLUENCE = 'feature_influence'; +const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]; + +type Pagination = Pick; +type TableItem = Record; + +interface ExplorationDataGridProps { + colorRange: (d: number) => string; + columns: any[]; + pagination: Pagination; + resultsField: string; + rowCount: number; + selectedFields: string[]; + setPagination: Dispatch>; + setSelectedFields: Dispatch>; + setSortingColumns: Dispatch>; + sortingColumns: EuiDataGridSorting['columns']; + tableItems: TableItem[]; +} + +export const ExplorationDataGrid: FC = ({ + colorRange, + columns, + pagination, + resultsField, + rowCount, + selectedFields, + setPagination, + setSelectedFields, + setSortingColumns, + sortingColumns, + tableItems, +}) => { + const renderCellValue = useMemo(() => { + return ({ + rowIndex, + columnId, + setCellProps, + }: { + rowIndex: number; + columnId: string; + setCellProps: any; + }) => { + const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize; + + const fullItem = tableItems[adjustedRowIndex]; + + if (fullItem === undefined) { + return null; + } + + const cellValue = + fullItem.hasOwnProperty(columnId) && fullItem[columnId] !== undefined + ? fullItem[columnId] + : null; + + const split = columnId.split('.'); + let backgroundColor; + + // column with feature values get color coded by its corresponding influencer value + if (fullItem[`${resultsField}.${FEATURE_INFLUENCE}.${columnId}`] !== undefined) { + backgroundColor = colorRange(fullItem[`${resultsField}.${FEATURE_INFLUENCE}.${columnId}`]); + } + + // column with influencer values get color coded by its own value + if (split.length > 2 && split[0] === resultsField && split[1] === FEATURE_INFLUENCE) { + backgroundColor = colorRange(cellValue); + } + + if (backgroundColor !== undefined) { + setCellProps({ + style: { backgroundColor }, + }); + } + + if (typeof cellValue === 'string' || cellValue === null) { + return cellValue; + } + + if (typeof cellValue === 'boolean') { + return cellValue ? 'true' : 'false'; + } + + if (typeof cellValue === 'object' && cellValue !== null) { + return JSON.stringify(cellValue); + } + + return cellValue; + }; + }, [resultsField, rowCount, tableItems, pagination.pageIndex, pagination.pageSize]); + + const onChangeItemsPerPage = useCallback( + pageSize => { + setPagination(p => { + const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize); + return { pageIndex, pageSize }; + }); + }, + [setPagination] + ); + + const onChangePage = useCallback(pageIndex => setPagination(p => ({ ...p, pageIndex })), [ + setPagination, + ]); + + const onSort = useCallback(sc => setSortingColumns(sc), [setSortingColumns]); + + return ( + + ); +}; diff --git a/x-pack/plugins/advanced_ui_actions/public/services/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_data_grid/index.ts similarity index 79% rename from x-pack/plugins/advanced_ui_actions/public/services/index.ts rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_data_grid/index.ts index 0f8b4c8d8f409..ea89e91de5046 100644 --- a/x-pack/plugins/advanced_ui_actions/public/services/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_data_grid/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './action_factory_service'; +export { ExplorationDataGrid } from './exploration_data_grid'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx new file mode 100644 index 0000000000000..f95e6a93058ba --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx @@ -0,0 +1,106 @@ +/* + * 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, { Dispatch, FC, SetStateAction, useState } from 'react'; + +import { EuiCode, EuiInputPopover } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/common/index_patterns'; +import { + esKuery, + esQuery, + Query, + QueryStringInput, +} from '../../../../../../../../../../src/plugins/data/public'; + +import { SEARCH_QUERY_LANGUAGE } from '../../../../../../../common/constants/search'; + +import { SavedSearchQuery } from '../../../../../contexts/ml'; + +interface ErrorMessage { + query: string; + message: string; +} + +interface ExplorationQueryBarProps { + indexPattern: IIndexPattern; + setSearchQuery: Dispatch>; +} + +export const ExplorationQueryBar: FC = ({ + indexPattern, + setSearchQuery, +}) => { + // The internal state of the input query bar updated on every key stroke. + const [searchInput, setSearchInput] = useState({ + query: '', + language: SEARCH_QUERY_LANGUAGE.KUERY, + }); + + const [errorMessage, setErrorMessage] = useState(undefined); + + const searchChangeHandler = (query: Query) => setSearchInput(query); + const searchSubmitHandler = (query: Query) => { + try { + switch (query.language) { + case SEARCH_QUERY_LANGUAGE.KUERY: + setSearchQuery( + esKuery.toElasticsearchQuery( + esKuery.fromKueryExpression(query.query as string), + indexPattern + ) + ); + return; + case SEARCH_QUERY_LANGUAGE.LUCENE: + setSearchQuery(esQuery.luceneStringToDsl(query.query as string)); + return; + } + } catch (e) { + setErrorMessage({ query: query.query as string, message: e.message }); + } + }; + + return ( + setErrorMessage(undefined)} + input={ + + } + isOpen={errorMessage?.query === searchInput.query && errorMessage?.message !== ''} + > + + {i18n.translate('xpack.ml.stepDefineForm.invalidQuery', { + defaultMessage: 'Invalid Query', + })} + {': '} + {errorMessage?.message.split('\n')[0]} + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/index.ts new file mode 100644 index 0000000000000..bebf4f65db04e --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { ExplorationQueryBar } from './exploration_query_bar'; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/index.ts similarity index 80% rename from x-pack/plugins/advanced_ui_actions/public/components/index.ts rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/index.ts index 236b1a6ec4611..de49556f9cc98 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './action_wizard'; +export { OutlierExploration } from './outlier_exploration'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.test.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.test.tsx similarity index 88% rename from x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.test.tsx rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.test.tsx index ca8fd68079f7e..030447873f6a5 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.test.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.test.tsx @@ -10,7 +10,7 @@ import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/ import { MlContext } from '../../../../../contexts/ml'; import { kibanaContextValueMock } from '../../../../../contexts/ml/__mocks__/kibana_context_value'; -import { Exploration } from './exploration'; +import { OutlierExploration } from './outlier_exploration'; // workaround to make React.memo() work with enzyme jest.mock('react', () => { @@ -22,7 +22,7 @@ describe('Data Frame Analytics: ', () => { test('Minimal initialization', () => { const wrapper = shallow( - + ); // Without the jobConfig being loaded, the component will just return empty. diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx new file mode 100644 index 0000000000000..214bc01c6a2ef --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx @@ -0,0 +1,220 @@ +/* + * 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, { FC } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiPanel, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; + +import { + useColorRange, + ColorRangeLegend, + COLOR_RANGE, + COLOR_RANGE_SCALE, +} from '../../../../../components/color_range_legend'; + +import { sortColumns, INDEX_STATUS, defaultSearchQuery } from '../../../../common'; + +import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; +import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/columns'; + +import { useExploreData, TableItem } from '../../hooks/use_explore_data'; + +import { ExplorationDataGrid } from '../exploration_data_grid'; +import { ExplorationQueryBar } from '../exploration_query_bar'; + +const FEATURE_INFLUENCE = 'feature_influence'; + +const ExplorationTitle: FC<{ jobId: string }> = ({ jobId }) => ( + + + {i18n.translate('xpack.ml.dataframe.analytics.exploration.jobIdTitle', { + defaultMessage: 'Outlier detection job ID {jobId}', + values: { jobId }, + })} + + +); + +interface ExplorationProps { + jobId: string; + jobStatus: DATA_FRAME_TASK_STATE; +} + +const getFeatureCount = (resultsField: string, tableItems: TableItem[] = []) => { + if (tableItems.length === 0) { + return 0; + } + + return Object.keys(tableItems[0]).filter(key => + key.includes(`${resultsField}.${FEATURE_INFLUENCE}.`) + ).length; +}; + +export const OutlierExploration: FC = React.memo(({ jobId, jobStatus }) => { + const { + errorMessage, + indexPattern, + jobConfig, + pagination, + searchQuery, + selectedFields, + setPagination, + setSearchQuery, + setSelectedFields, + setSortingColumns, + sortingColumns, + rowCount, + status, + tableFields, + tableItems, + } = useExploreData(jobId); + + const columns = []; + + if ( + jobConfig !== undefined && + indexPattern !== undefined && + selectedFields.length > 0 && + tableItems.length > 0 + ) { + const resultsField = jobConfig.dest.results_field; + const removePrefix = new RegExp(`^${resultsField}\.${FEATURE_INFLUENCE}\.`, 'g'); + columns.push( + ...tableFields.sort(sortColumns(tableItems[0], resultsField)).map(id => { + const idWithoutPrefix = id.replace(removePrefix, ''); + const field = indexPattern.fields.getByName(idWithoutPrefix); + + // Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json'] + // To fall back to the default string schema it needs to be undefined. + let schema; + + switch (field?.type) { + case 'date': + schema = 'datetime'; + break; + case 'geo_point': + schema = 'json'; + break; + case 'number': + schema = 'numeric'; + break; + } + + if (id === `${resultsField}.outlier_score`) { + schema = 'numeric'; + } + + return { id, schema }; + }) + ); + } + + const colorRange = useColorRange( + COLOR_RANGE.BLUE, + COLOR_RANGE_SCALE.INFLUENCER, + jobConfig !== undefined ? getFeatureCount(jobConfig.dest.results_field, tableItems) : 1 + ); + + if (jobConfig === undefined || indexPattern === undefined) { + return null; + } + + // if it's a searchBar syntax error leave the table visible so they can try again + if (status === INDEX_STATUS.ERROR && !errorMessage.includes('parsing_exception')) { + return ( + + + +

{errorMessage}

+
+
+ ); + } + + let tableError = + status === INDEX_STATUS.ERROR && errorMessage.includes('parsing_exception') + ? errorMessage + : undefined; + + if (status === INDEX_STATUS.LOADED && tableItems.length === 0 && tableError === undefined) { + tableError = i18n.translate('xpack.ml.dataframe.analytics.exploration.noDataCalloutBody', { + defaultMessage: + 'The query for the index returned no results. Please make sure the index contains documents and your query is not too restrictive.', + }); + } + + return ( + + + + + + + {getTaskStateBadge(jobStatus)} + + + + {(columns.length > 0 || searchQuery !== defaultSearchQuery) && ( + <> + + + + + + + + + + + {columns.length > 0 && tableItems.length > 0 && ( + + )} + + )} + + ); +}); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/common.test.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/common.test.ts similarity index 100% rename from x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/common.test.ts rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/common.test.ts diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/common.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/common.ts similarity index 100% rename from x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/common.ts rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/common.ts diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/index.ts new file mode 100644 index 0000000000000..dd896ca02f7f7 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { useExploreData, TableItem } from './use_explore_data'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/use_explore_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/use_explore_data.ts new file mode 100644 index 0000000000000..6ad0a1822e490 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/hooks/use_explore_data/use_explore_data.ts @@ -0,0 +1,232 @@ +/* + * 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 { useEffect, useState, Dispatch, SetStateAction } from 'react'; +import { SearchResponse } from 'elasticsearch'; + +import { EuiDataGridPaginationProps, EuiDataGridSorting } from '@elastic/eui'; + +import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; + +import { Dictionary } from '../../../../../../../common/types/common'; + +import { SavedSearchQuery } from '../../../../../contexts/ml'; +import { ml } from '../../../../../services/ml_api_service'; +import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; +import { getIndexPatternIdFromName } from '../../../../../util/index_utils'; +import { getNestedProperty } from '../../../../../util/object_utils'; +import { useMlContext } from '../../../../../contexts/ml'; + +import { + getDefaultSelectableFields, + getFlattenedFields, + DataFrameAnalyticsConfig, + EsFieldName, + INDEX_STATUS, + defaultSearchQuery, +} from '../../../../common'; +import { isKeywordAndTextType } from '../../../../common/fields'; + +import { getOutlierScoreFieldName } from './common'; + +export type TableItem = Record; + +type Pagination = Pick; + +interface UseExploreDataReturnType { + errorMessage: string; + indexPattern: IndexPattern | undefined; + jobConfig: DataFrameAnalyticsConfig | undefined; + pagination: Pagination; + searchQuery: SavedSearchQuery; + selectedFields: EsFieldName[]; + setJobConfig: Dispatch>; + setPagination: Dispatch>; + setSearchQuery: Dispatch>; + setSelectedFields: Dispatch>; + setSortingColumns: Dispatch>; + rowCount: number; + sortingColumns: EuiDataGridSorting['columns']; + status: INDEX_STATUS; + tableFields: string[]; + tableItems: TableItem[]; +} + +type EsSorting = Dictionary<{ + order: 'asc' | 'desc'; +}>; + +// The types specified in `@types/elasticsearch` are out of date and still have `total: number`. +interface SearchResponse7 extends SearchResponse { + hits: SearchResponse['hits'] & { + total: { + value: number; + relation: string; + }; + }; +} + +export const useExploreData = (jobId: string): UseExploreDataReturnType => { + const mlContext = useMlContext(); + + const [indexPattern, setIndexPattern] = useState(undefined); + const [jobConfig, setJobConfig] = useState(undefined); + const [errorMessage, setErrorMessage] = useState(''); + const [status, setStatus] = useState(INDEX_STATUS.UNUSED); + + const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]); + const [tableFields, setTableFields] = useState([]); + const [tableItems, setTableItems] = useState([]); + const [rowCount, setRowCount] = useState(0); + + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); + const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); + const [sortingColumns, setSortingColumns] = useState([]); + + // get analytics configuration + useEffect(() => { + (async function() { + const analyticsConfigs = await ml.dataFrameAnalytics.getDataFrameAnalytics(jobId); + if ( + Array.isArray(analyticsConfigs.data_frame_analytics) && + analyticsConfigs.data_frame_analytics.length > 0 + ) { + setJobConfig(analyticsConfigs.data_frame_analytics[0]); + } + })(); + }, []); + + // get index pattern and field caps + useEffect(() => { + (async () => { + if (jobConfig !== undefined) { + const sourceIndex = jobConfig.source.index[0]; + const indexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex; + const jobCapsIndexPattern: IndexPattern = await mlContext.indexPatterns.get(indexPatternId); + if (jobCapsIndexPattern !== undefined) { + setIndexPattern(jobCapsIndexPattern); + await newJobCapsService.initializeFromIndexPattern(jobCapsIndexPattern, false, false); + } + } + })(); + }, [jobConfig && jobConfig.id]); + + // initialize sorting: reverse sort on outlier score column + useEffect(() => { + if (jobConfig !== undefined) { + setSortingColumns([{ id: getOutlierScoreFieldName(jobConfig), direction: 'desc' }]); + } + }, [jobConfig && jobConfig.id]); + + // update data grid data + useEffect(() => { + (async () => { + if (jobConfig !== undefined) { + setErrorMessage(''); + setStatus(INDEX_STATUS.LOADING); + + try { + const resultsField = jobConfig.dest.results_field; + + const sort: EsSorting = sortingColumns + .map(column => { + const { id } = column; + column.id = isKeywordAndTextType(id) ? `${id}.keyword` : id; + return column; + }) + .reduce((s, column) => { + s[column.id] = { order: column.direction }; + return s; + }, {} as EsSorting); + + const { pageIndex, pageSize } = pagination; + const resp: SearchResponse7 = await ml.esSearch({ + index: jobConfig.dest.index, + body: { + query: searchQuery, + from: pageIndex * pageSize, + size: pageSize, + ...(Object.keys(sort).length > 0 ? { sort } : {}), + }, + }); + + setRowCount(resp.hits.total.value); + + const docs = resp.hits.hits; + + if (docs.length === 0) { + setTableItems([]); + setStatus(INDEX_STATUS.LOADED); + return; + } + + if (selectedFields.length === 0) { + const newSelectedFields = getDefaultSelectableFields(docs, resultsField); + setSelectedFields(newSelectedFields); + } + + // Create a version of the doc's source with flattened field names. + // This avoids confusion later on if a field name has dots in its name + // or is a nested fields when displaying it via EuiInMemoryTable. + const flattenedFields = getFlattenedFields(docs[0]._source, resultsField); + const transformedTableItems = docs.map(doc => { + const item: TableItem = {}; + flattenedFields.forEach(ff => { + item[ff] = getNestedProperty(doc._source, ff); + if (item[ff] === undefined) { + // If the attribute is undefined, it means it was not a nested property + // but had dots in its actual name. This selects the property by its + // full name and assigns it to `item[ff]`. + item[ff] = doc._source[`"${ff}"`]; + } + if (item[ff] === undefined) { + const parts = ff.split('.'); + if (parts[0] === resultsField && parts.length >= 2) { + parts.shift(); + if (doc._source[resultsField] !== undefined) { + item[ff] = doc._source[resultsField][parts.join('.')]; + } + } + } + }); + return item; + }); + + setTableFields(flattenedFields); + setTableItems(transformedTableItems); + setStatus(INDEX_STATUS.LOADED); + } catch (e) { + if (e.message !== undefined) { + setErrorMessage(e.message); + } else { + setErrorMessage(JSON.stringify(e)); + } + setTableItems([]); + setStatus(INDEX_STATUS.ERROR); + } + } + })(); + }, [jobConfig && jobConfig.id, pagination, searchQuery, selectedFields, sortingColumns]); + + return { + errorMessage, + indexPattern, + jobConfig, + pagination, + rowCount, + searchQuery, + selectedFields, + setJobConfig, + setPagination, + setSearchQuery, + setSelectedFields, + setSortingColumns, + sortingColumns, + status, + tableFields, + tableItems, + }; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx index b00a38e2b5f65..efbebc1564bf9 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx @@ -22,7 +22,7 @@ import { import { NavigationMenu } from '../../../components/navigation_menu'; -import { Exploration } from './components/exploration'; +import { OutlierExploration } from './components/outlier_exploration'; import { RegressionExploration } from './components/regression_exploration'; import { ClassificationExploration } from './components/classification_exploration'; @@ -37,7 +37,7 @@ export const Page: FC<{ - + @@ -65,10 +65,10 @@ export const Page: FC<{ - + {analysisType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && ( - + )} {analysisType === ANALYSIS_CONFIG_TYPE.REGRESSION && ( diff --git a/x-pack/plugins/ml/public/index.ts b/x-pack/plugins/ml/public/index.ts index f20f3836ab433..f9f2be390e05f 100755 --- a/x-pack/plugins/ml/public/index.ts +++ b/x-pack/plugins/ml/public/index.ts @@ -6,8 +6,8 @@ import { PluginInitializer } from 'kibana/public'; import './index.scss'; -import { MlPlugin, Setup, Start } from './plugin'; +import { MlPlugin, MlPluginSetup, MlPluginStart } from './plugin'; -export const plugin: PluginInitializer = () => new MlPlugin(); +export const plugin: PluginInitializer = () => new MlPlugin(); -export { Setup, Start }; +export { MlPluginSetup, MlPluginStart }; diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 79aebece85af2..30b7133f4147e 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -28,7 +28,7 @@ export interface MlSetupDependencies { usageCollection: UsageCollectionSetup; } -export class MlPlugin implements Plugin { +export class MlPlugin implements Plugin { setup(core: CoreSetup, pluginsSetup: MlSetupDependencies) { core.application.register({ id: PLUGIN_ID, @@ -77,5 +77,5 @@ export class MlPlugin implements Plugin { public stop() {} } -export type Setup = ReturnType; -export type Start = ReturnType; +export type MlPluginSetup = ReturnType; +export type MlPluginStart = ReturnType; diff --git a/x-pack/plugins/ml/server/index.ts b/x-pack/plugins/ml/server/index.ts index 6cfa1b23408c0..175c20bf49c94 100644 --- a/x-pack/plugins/ml/server/index.ts +++ b/x-pack/plugins/ml/server/index.ts @@ -6,6 +6,6 @@ import { PluginInitializerContext } from 'kibana/server'; import { MlServerPlugin } from './plugin'; -export { MlStartContract, MlSetupContract } from './plugin'; +export { MlPluginSetup, MlPluginStart } from './plugin'; export const plugin = (ctx: PluginInitializerContext) => new MlServerPlugin(ctx); diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 8948d232b9e5e..674c3886c12f8 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -49,10 +49,10 @@ declare module 'kibana/server' { } } -export type MlSetupContract = SharedServices; -export type MlStartContract = void; +export type MlPluginSetup = SharedServices; +export type MlPluginStart = void; -export class MlServerPlugin implements Plugin { +export class MlServerPlugin implements Plugin { private log: Logger; private version: string; private mlLicense: MlServerLicense; @@ -63,19 +63,22 @@ export class MlServerPlugin implements Plugin ) { let lastMetrics: MonitoringOpsMetrics | null = null; - metrics$.subscribe(metrics => { + metrics$.subscribe(_metrics => { + const metrics: any = cloneDeep(_metrics); + // Ensure we only include the same data that Metricbeat collection would get + delete metrics.process.pid; + metrics.response_times = { + average: metrics.response_times.avg_in_millis, + max: metrics.response_times.max_in_millis, + }; + delete metrics.requests.statusCodes; lastMetrics = { ...metrics, timestamp: moment.utc().toISOString(), diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts index 63e1dbc400787..c66adfcabd671 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/collectors/get_settings_collector.ts @@ -12,9 +12,6 @@ import { MonitoringConfig } from '../../config'; * If so, get email from kibana.yml */ export async function getDefaultAdminEmail(config: MonitoringConfig) { - if (!config.cluster_alerts.email_notifications.enabled) { - return null; - } return config.cluster_alerts.email_notifications.email_address || null; } diff --git a/x-pack/plugins/monitoring/server/plugin.ts b/x-pack/plugins/monitoring/server/plugin.ts index ca5ef7f9db26b..d9500284b52dc 100644 --- a/x-pack/plugins/monitoring/server/plugin.ts +++ b/x-pack/plugins/monitoring/server/plugin.ts @@ -51,7 +51,6 @@ import { InfraPluginSetup } from '../../infra/server'; export interface LegacyAPI { getServerStatus: () => string; - infra: any; } interface PluginsSetup { @@ -189,8 +188,9 @@ export class Plugin { name: serverInfo.name, index: get(legacyConfig, 'kibana.index'), host: serverInfo.host, - transport_address: `${serverInfo.host}:${serverInfo.port}`, + locale: i18n.getLocale(), port: serverInfo.port.toString(), + transport_address: `${serverInfo.host}:${serverInfo.port}`, version: this.initializerContext.env.packageInfo.version, snapshot: snapshotRegex.test(this.initializerContext.env.packageInfo.version), }, @@ -267,9 +267,11 @@ export class Plugin { navLinkId: 'monitoring', app: ['monitoring', 'kibana'], catalogue: ['monitoring'], - privileges: {}, + privileges: null, reserved: { privilege: { + app: ['monitoring', 'kibana'], + catalogue: ['monitoring'], savedObject: { all: [], read: [], diff --git a/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.ts b/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.ts index fbea311cdeefa..d0898fda93a41 100644 --- a/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.ts +++ b/x-pack/plugins/remote_clusters/common/lib/cluster_serialization.ts @@ -153,13 +153,13 @@ export function serializeCluster(deserializedClusterObject: Cluster): ClusterPay cluster: { remote: { [name]: { - skip_unavailable: skipUnavailable !== undefined ? skipUnavailable : null, - mode: mode ?? null, - proxy_address: proxyAddress ?? null, - proxy_socket_connections: proxySocketConnections ?? null, - server_name: serverName ?? null, - seeds: seeds ?? null, - node_connections: nodeConnections ?? null, + skip_unavailable: typeof skipUnavailable === 'boolean' ? skipUnavailable : null, + mode: mode || null, + proxy_address: proxyAddress || null, + proxy_socket_connections: proxySocketConnections || null, + server_name: serverName || null, + seeds: seeds || null, + node_connections: nodeConnections || null, }, }, }, diff --git a/x-pack/plugins/reporting/config.ts b/x-pack/plugins/reporting/config.ts new file mode 100644 index 0000000000000..f1d6b1a8f248f --- /dev/null +++ b/x-pack/plugins/reporting/config.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const reportingPollConfig = { + jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, + jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, +}; diff --git a/x-pack/plugins/reporting/kibana.json b/x-pack/plugins/reporting/kibana.json index d330eb9b7872a..a7e2bd288f0b1 100644 --- a/x-pack/plugins/reporting/kibana.json +++ b/x-pack/plugins/reporting/kibana.json @@ -1,11 +1,7 @@ { - "configPath": [ "xpack", "reporting" ], "id": "reporting", "version": "8.0.0", "kibanaVersion": "kibana", - "optionalPlugins": [ - "usageCollection" - ], "requiredPlugins": [ "home", "management", @@ -15,6 +11,6 @@ "share", "kibanaLegacy" ], - "server": true, + "server": false, "ui": true } diff --git a/x-pack/plugins/reporting/public/plugin.tsx b/x-pack/plugins/reporting/public/plugin.tsx index ac46d84469513..08ba10ff69207 100644 --- a/x-pack/plugins/reporting/public/plugin.tsx +++ b/x-pack/plugins/reporting/public/plugin.tsx @@ -143,7 +143,8 @@ export class ReportingPublicPlugin implements Plugin { }, }); - uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); + uiActions.registerAction(action); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); share.register(csvReportingProvider({ apiClient, toasts, license$ })); share.register( diff --git a/x-pack/plugins/reporting/server/config/index.test.ts b/x-pack/plugins/reporting/server/config/index.test.ts deleted file mode 100644 index 08fe2c5861311..0000000000000 --- a/x-pack/plugins/reporting/server/config/index.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as Rx from 'rxjs'; -import { CoreSetup, Logger, PluginInitializerContext } from '../../../../../src/core/server'; -import { createConfig$ } from './'; - -interface KibanaServer { - host?: string; - port?: number; - protocol?: string; -} -interface ReportingKibanaServer { - hostname?: string; - port?: number; - protocol?: string; -} - -const makeMockInitContext = (config: { - encryptionKey?: string; - kibanaServer: ReportingKibanaServer; -}): PluginInitializerContext => - ({ - config: { create: () => Rx.of(config) }, - } as PluginInitializerContext); - -const makeMockCoreSetup = (serverInfo: KibanaServer): CoreSetup => - ({ http: { getServerInfo: () => serverInfo } } as any); - -describe('Reporting server createConfig$', () => { - let mockCoreSetup: CoreSetup; - let mockInitContext: PluginInitializerContext; - let mockLogger: Logger; - - beforeEach(() => { - mockCoreSetup = makeMockCoreSetup({ host: 'kibanaHost', port: 5601, protocol: 'http' }); - mockInitContext = makeMockInitContext({ - kibanaServer: {}, - }); - mockLogger = ({ warn: jest.fn() } as unknown) as Logger; - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('creates random encryption key and default config using host, protocol, and port from server info', async () => { - const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); - - expect(result.encryptionKey).toMatch(/\S{32,}/); - expect(result.kibanaServer).toMatchInlineSnapshot(` - Object { - "hostname": "kibanaHost", - "port": 5601, - "protocol": "http", - } - `); - expect((mockLogger.warn as any).mock.calls.length).toBe(1); - expect((mockLogger.warn as any).mock.calls[0]).toMatchObject([ - 'Generating a random key for xpack.reporting.encryptionKey. To prevent sessions from being invalidated on restart, please set xpack.reporting.encryptionKey in kibana.yml', - ]); - }); - - it('uses the encryption key', async () => { - mockInitContext = makeMockInitContext({ - encryptionKey: 'iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii', - kibanaServer: {}, - }); - const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); - - expect(result.encryptionKey).toMatch('iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii'); - expect((mockLogger.warn as any).mock.calls.length).toBe(0); - }); - - it('uses the encryption key, reporting kibanaServer settings to override server info', async () => { - mockInitContext = makeMockInitContext({ - encryptionKey: 'iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii', - kibanaServer: { - hostname: 'reportingHost', - port: 5677, - protocol: 'httpsa', - }, - }); - const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); - - expect(result).toMatchInlineSnapshot(` - Object { - "encryptionKey": "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii", - "kibanaServer": Object { - "hostname": "reportingHost", - "port": 5677, - "protocol": "httpsa", - }, - } - `); - expect((mockLogger.warn as any).mock.calls.length).toBe(0); - }); - - it('show warning when kibanaServer.hostName === "0"', async () => { - mockInitContext = makeMockInitContext({ - encryptionKey: 'aaaaaaaaaaaaabbbbbbbbbbbbaaaaaaaaa', - kibanaServer: { hostname: '0' }, - }); - const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); - - expect(result.kibanaServer).toMatchInlineSnapshot(` - Object { - "hostname": "0.0.0.0", - "port": 5601, - "protocol": "http", - } - `); - expect((mockLogger.warn as any).mock.calls.length).toBe(1); - expect((mockLogger.warn as any).mock.calls[0]).toMatchObject([ - `Found 'server.host: \"0\" in Kibana configuration. This is incompatible with Reporting. To enable Reporting to work, 'xpack.reporting.kibanaServer.hostname: 0.0.0.0' is being automatically ` + - `to the configuration. You can change the setting to 'server.host: 0.0.0.0' or add 'xpack.reporting.kibanaServer.hostname: 0.0.0.0' in kibana.yml to prevent this message.`, - ]); - }); -}); diff --git a/x-pack/plugins/reporting/server/config/index.ts b/x-pack/plugins/reporting/server/config/index.ts deleted file mode 100644 index ac51b39ae23b4..0000000000000 --- a/x-pack/plugins/reporting/server/config/index.ts +++ /dev/null @@ -1,85 +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 { i18n } from '@kbn/i18n/'; -import { TypeOf } from '@kbn/config-schema'; -import crypto from 'crypto'; -import { map } from 'rxjs/operators'; -import { PluginConfigDescriptor } from 'kibana/server'; -import { CoreSetup, Logger, PluginInitializerContext } from '../../../../../src/core/server'; -import { ConfigSchema, ConfigType } from './schema'; - -export function createConfig$(core: CoreSetup, context: PluginInitializerContext, logger: Logger) { - return context.config.create>().pipe( - map(config => { - // encryption key - let encryptionKey = config.encryptionKey; - if (encryptionKey === undefined) { - logger.warn( - i18n.translate('xpack.reporting.serverConfig.randomEncryptionKey', { - defaultMessage: - 'Generating a random key for xpack.reporting.encryptionKey. To prevent sessions from being invalidated on ' + - 'restart, please set xpack.reporting.encryptionKey in kibana.yml', - }) - ); - encryptionKey = crypto.randomBytes(16).toString('hex'); - } - - const { kibanaServer: reportingServer } = config; - const serverInfo = core.http.getServerInfo(); - - // kibanaServer.hostname, default to server.host, don't allow "0" - let kibanaServerHostname = reportingServer.hostname - ? reportingServer.hostname - : serverInfo.host; - if (kibanaServerHostname === '0') { - logger.warn( - i18n.translate('xpack.reporting.serverConfig.invalidServerHostname', { - defaultMessage: - `Found 'server.host: "0" in Kibana configuration. This is incompatible with Reporting. ` + - `To enable Reporting to work, '{configKey}: 0.0.0.0' is being automatically to the configuration. ` + - `You can change the setting to 'server.host: 0.0.0.0' or add '{configKey}: 0.0.0.0' in kibana.yml to prevent this message.`, - values: { configKey: 'xpack.reporting.kibanaServer.hostname' }, - }) - ); - kibanaServerHostname = '0.0.0.0'; - } - - // kibanaServer.port, default to server.port - const kibanaServerPort = reportingServer.port - ? reportingServer.port - : serverInfo.port; // prettier-ignore - - // kibanaServer.protocol, default to server.protocol - const kibanaServerProtocol = reportingServer.protocol - ? reportingServer.protocol - : serverInfo.protocol; - - return { - ...config, - encryptionKey, - kibanaServer: { - hostname: kibanaServerHostname, - port: kibanaServerPort, - protocol: kibanaServerProtocol, - }, - }; - }) - ); -} - -export const config: PluginConfigDescriptor = { - schema: ConfigSchema, - deprecations: ({ unused }) => [ - unused('capture.browser.chromium.maxScreenshotDimension'), - unused('capture.concurrency'), - unused('capture.settleTime'), - unused('capture.timeout'), - unused('kibanaApp'), - ], -}; - -export { ConfigSchema, ConfigType }; diff --git a/x-pack/plugins/reporting/server/config/schema.test.ts b/x-pack/plugins/reporting/server/config/schema.test.ts deleted file mode 100644 index d8fe6d1ff084a..0000000000000 --- a/x-pack/plugins/reporting/server/config/schema.test.ts +++ /dev/null @@ -1,103 +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 { ConfigSchema } from './schema'; - -describe('Reporting Config Schema', () => { - it(`context {"dev":false,"dist":false} produces correct config`, () => { - expect(ConfigSchema.validate({}, { dev: false, dist: false })).toMatchObject({ - capture: { - browser: { - autoDownload: true, - chromium: { disableSandbox: false, proxy: { enabled: false } }, - type: 'chromium', - }, - loadDelay: 3000, - maxAttempts: 1, - networkPolicy: { - enabled: true, - rules: [ - { allow: true, host: undefined, protocol: 'http:' }, - { allow: true, host: undefined, protocol: 'https:' }, - { allow: true, host: undefined, protocol: 'ws:' }, - { allow: true, host: undefined, protocol: 'wss:' }, - { allow: true, host: undefined, protocol: 'data:' }, - { allow: false, host: undefined, protocol: undefined }, - ], - }, - viewport: { height: 1200, width: 1950 }, - zoom: 2, - }, - csv: { - checkForFormulas: true, - enablePanelActionDownload: true, - maxSizeBytes: 10485760, - scroll: { duration: '30s', size: 500 }, - }, - encryptionKey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - index: '.reporting', - kibanaServer: {}, - poll: { - jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, - jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, - }, - queue: { - indexInterval: 'week', - pollEnabled: true, - pollInterval: 3000, - pollIntervalErrorMultiplier: 10, - timeout: 120000, - }, - roles: { allow: ['reporting_user'] }, - }); - }); - it(`context {"dev":false,"dist":true} produces correct config`, () => { - expect(ConfigSchema.validate({}, { dev: false, dist: true })).toMatchObject({ - capture: { - browser: { - autoDownload: false, - chromium: { disableSandbox: false, inspect: false, proxy: { enabled: false } }, - type: 'chromium', - }, - loadDelay: 3000, - maxAttempts: 3, - networkPolicy: { - enabled: true, - rules: [ - { allow: true, host: undefined, protocol: 'http:' }, - { allow: true, host: undefined, protocol: 'https:' }, - { allow: true, host: undefined, protocol: 'ws:' }, - { allow: true, host: undefined, protocol: 'wss:' }, - { allow: true, host: undefined, protocol: 'data:' }, - { allow: false, host: undefined, protocol: undefined }, - ], - }, - viewport: { height: 1200, width: 1950 }, - zoom: 2, - }, - csv: { - checkForFormulas: true, - enablePanelActionDownload: true, - maxSizeBytes: 10485760, - scroll: { duration: '30s', size: 500 }, - }, - index: '.reporting', - kibanaServer: {}, - poll: { - jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, - jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, - }, - queue: { - indexInterval: 'week', - pollEnabled: true, - pollInterval: 3000, - pollIntervalErrorMultiplier: 10, - timeout: 120000, - }, - roles: { allow: ['reporting_user'] }, - }); - }); -}); diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts deleted file mode 100644 index 0058b7a5096f0..0000000000000 --- a/x-pack/plugins/reporting/server/config/schema.ts +++ /dev/null @@ -1,174 +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 { schema, TypeOf } from '@kbn/config-schema'; -import moment from 'moment'; - -const KibanaServerSchema = schema.object({ - hostname: schema.maybe( - schema.string({ - validate(value) { - if (value === '0') { - return 'must not be "0" for the headless browser to correctly resolve the host'; - } - }, - hostname: true, - }) - ), - port: schema.maybe(schema.number()), - protocol: schema.maybe( - schema.string({ - validate(value) { - if (!/^https?$/.test(value)) { - return 'must be "http" or "https"'; - } - }, - }) - ), -}); - -const QueueSchema = schema.object({ - indexInterval: schema.string({ defaultValue: 'week' }), - pollEnabled: schema.boolean({ defaultValue: true }), - pollInterval: schema.number({ defaultValue: 3000 }), - pollIntervalErrorMultiplier: schema.number({ defaultValue: 10 }), - timeout: schema.number({ defaultValue: moment.duration(2, 'm').asMilliseconds() }), -}); - -const RulesSchema = schema.object({ - allow: schema.boolean(), - host: schema.maybe(schema.string()), - protocol: schema.maybe(schema.string()), -}); - -const CaptureSchema = schema.object({ - timeouts: schema.object({ - openUrl: schema.number({ defaultValue: 30000 }), - waitForElements: schema.number({ defaultValue: 30000 }), - renderComplete: schema.number({ defaultValue: 30000 }), - }), - networkPolicy: schema.object({ - enabled: schema.boolean({ defaultValue: true }), - rules: schema.arrayOf(RulesSchema, { - defaultValue: [ - { host: undefined, allow: true, protocol: 'http:' }, - { host: undefined, allow: true, protocol: 'https:' }, - { host: undefined, allow: true, protocol: 'ws:' }, - { host: undefined, allow: true, protocol: 'wss:' }, - { host: undefined, allow: true, protocol: 'data:' }, - { host: undefined, allow: false, protocol: undefined }, // Default action is to deny! - ], - }), - }), - zoom: schema.number({ defaultValue: 2 }), - viewport: schema.object({ - width: schema.number({ defaultValue: 1950 }), - height: schema.number({ defaultValue: 1200 }), - }), - loadDelay: schema.number({ - defaultValue: moment.duration(3, 's').asMilliseconds(), - }), // TODO: use schema.duration - browser: schema.object({ - autoDownload: schema.conditional( - schema.contextRef('dist'), - true, - schema.boolean({ defaultValue: false }), - schema.boolean({ defaultValue: true }) - ), - chromium: schema.object({ - inspect: schema.conditional( - schema.contextRef('dist'), - true, - schema.boolean({ defaultValue: false }), - schema.maybe(schema.never()) - ), - disableSandbox: schema.boolean({ defaultValue: false }), - proxy: schema.object({ - enabled: schema.boolean({ defaultValue: false }), - server: schema.conditional( - schema.siblingRef('enabled'), - true, - schema.uri({ scheme: ['http', 'https'] }), - schema.maybe(schema.never()) - ), - bypass: schema.conditional( - schema.siblingRef('enabled'), - true, - schema.arrayOf(schema.string({ hostname: true })), - schema.maybe(schema.never()) - ), - }), - userDataDir: schema.maybe(schema.string()), // FIXME unused? - }), - type: schema.string({ defaultValue: 'chromium' }), - }), - maxAttempts: schema.conditional( - schema.contextRef('dist'), - true, - schema.number({ defaultValue: 3 }), - schema.number({ defaultValue: 1 }) - ), -}); - -const CsvSchema = schema.object({ - checkForFormulas: schema.boolean({ defaultValue: true }), - enablePanelActionDownload: schema.boolean({ defaultValue: true }), - maxSizeBytes: schema.number({ - defaultValue: 1024 * 1024 * 10, // 10MB - }), // TODO: use schema.byteSize - scroll: schema.object({ - duration: schema.string({ - defaultValue: '30s', - validate(value) { - if (!/^[0-9]+(d|h|m|s|ms|micros|nanos)$/.test(value)) { - return 'must be a duration string'; - } - }, - }), - size: schema.number({ defaultValue: 500 }), - }), -}); - -const EncryptionKeySchema = schema.conditional( - schema.contextRef('dist'), - true, - schema.maybe(schema.string({ minLength: 32 })), - schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) }) -); - -const RolesSchema = schema.object({ - allow: schema.arrayOf(schema.string(), { defaultValue: ['reporting_user'] }), -}); - -const IndexSchema = schema.string({ defaultValue: '.reporting' }); - -const PollSchema = schema.object({ - jobCompletionNotifier: schema.object({ - interval: schema.number({ - defaultValue: moment.duration(10, 's').asMilliseconds(), - }), // TODO: use schema.duration - intervalErrorMultiplier: schema.number({ defaultValue: 5 }), - }), - jobsRefresh: schema.object({ - interval: schema.number({ - defaultValue: moment.duration(5, 's').asMilliseconds(), - }), // TODO: use schema.duration - intervalErrorMultiplier: schema.number({ defaultValue: 5 }), - }), -}); - -export const ConfigSchema = schema.object({ - kibanaServer: KibanaServerSchema, - queue: QueueSchema, - capture: CaptureSchema, - csv: CsvSchema, - encryptionKey: EncryptionKeySchema, - roles: RolesSchema, - index: IndexSchema, - poll: PollSchema, -}); - -export type ConfigType = TypeOf; diff --git a/x-pack/plugins/reporting/server/index.ts b/x-pack/plugins/reporting/server/index.ts deleted file mode 100644 index 2b1844cf2e10e..0000000000000 --- a/x-pack/plugins/reporting/server/index.ts +++ /dev/null @@ -1,14 +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 { PluginInitializerContext } from 'src/core/server'; -import { ReportingPlugin } from './plugin'; - -export { config, ConfigSchema } from './config'; -export { ConfigType, PluginsSetup } from './plugin'; - -export const plugin = (initializerContext: PluginInitializerContext) => - new ReportingPlugin(initializerContext); diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts deleted file mode 100644 index 53d821cffbb1f..0000000000000 --- a/x-pack/plugins/reporting/server/plugin.ts +++ /dev/null @@ -1,38 +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 { Observable } from 'rxjs'; -import { first } from 'rxjs/operators'; -import { CoreSetup, Logger, Plugin, PluginInitializerContext } from '../../../../src/core/server'; -import { ConfigType, createConfig$ } from './config'; - -export interface PluginsSetup { - /** @deprecated */ - __legacy: { - config$: Observable; - }; -} - -export class ReportingPlugin implements Plugin { - private readonly log: Logger; - - constructor(private readonly initializerContext: PluginInitializerContext) { - this.log = this.initializerContext.logger.get(); - } - - public async setup(core: CoreSetup): Promise { - return { - __legacy: { - config$: createConfig$(core, this.initializerContext, this.log).pipe(first()), - }, - }; - } - - public start() {} - public stop() {} -} - -export { ConfigType }; diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/breakdown.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/breakdown.ts similarity index 100% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/breakdown.ts rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/breakdown.ts diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/normalize_indices.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/normalize_indices.ts similarity index 100% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/normalize_indices.ts rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/normalize_indices.ts diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/normalize_times.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/normalize_times.ts similarity index 100% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/normalize_times.ts rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/normalize_times.ts diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/processed_search_response.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/processed_search_response.ts similarity index 100% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/processed_search_response.ts rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/processed_search_response.ts diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/search_response.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/search_response.ts similarity index 100% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/fixtures/search_response.ts rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/fixtures/search_response.ts diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/init_data.test.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/init_data.test.ts similarity index 100% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/init_data.test.ts rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/init_data.test.ts diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/profile_tree.test.tsx b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/profile_tree.test.tsx similarity index 89% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/profile_tree.test.tsx rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/profile_tree.test.tsx index 1286f30d69c26..64f77e8b4e52c 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/profile_tree.test.tsx +++ b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/profile_tree.test.tsx @@ -14,6 +14,7 @@ describe('ProfileTree', () => { onHighlight: () => {}, target: 'searches', data: searchResponse, + onDataInitError: jest.fn(), }; const init = registerTestBed(ProfileTree); await init(props); @@ -24,10 +25,12 @@ describe('ProfileTree', () => { const props: Props = { onHighlight: () => {}, target: 'searches', + onDataInitError: jest.fn(), data: [{}] as any, }; const init = registerTestBed(ProfileTree); await init(props); + expect(props.onDataInitError).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/unsafe_utils.test.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/unsafe_utils.test.ts similarity index 100% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/unsafe_utils.test.ts rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/unsafe_utils.test.ts diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/utils.test.ts b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/utils.test.ts similarity index 100% rename from x-pack/plugins/searchprofiler/public/application/components/profile_tree/__tests__/utils.test.ts rename to x-pack/plugins/searchprofiler/public/application/components/profile_tree/__jest__/utils.test.ts diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/profile_tree.tsx b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/profile_tree.tsx index 1dec8f0161c52..ade547a7d440f 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/profile_tree.tsx +++ b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/profile_tree.tsx @@ -17,9 +17,10 @@ export interface Props { target: Targets; data: ShardSerialized[] | null; onHighlight: (args: OnHighlightChangeArgs) => void; + onDataInitError: (error: Error) => void; } -export const ProfileTree = memo(({ data, target, onHighlight }: Props) => { +export const ProfileTree = memo(({ data, target, onHighlight, onDataInitError }: Props) => { if (!data || data.length === 0) { return null; } @@ -28,8 +29,7 @@ export const ProfileTree = memo(({ data, target, onHighlight }: Props) => { try { sortedIndices = initDataFor(target)(data); } catch (e) { - // eslint-disable-next-line no-console - console.error(e); + onDataInitError(e); return null; } diff --git a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/shard_details/shard_details.tsx b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/shard_details/shard_details.tsx index 5ca8ad4ecd979..ac2a2997515d5 100644 --- a/x-pack/plugins/searchprofiler/public/application/components/profile_tree/shard_details/shard_details.tsx +++ b/x-pack/plugins/searchprofiler/public/application/components/profile_tree/shard_details/shard_details.tsx @@ -18,10 +18,24 @@ interface Props { operations: Operation[]; } +const hasVisibleOperation = (ops: Operation[]): boolean => { + for (const op of ops) { + if (op.visible) { + return true; + } + if (op.children?.length && hasVisibleOperation(op.children)) { + return true; + } + } + return false; +}; + export const ShardDetails = ({ index, shard, operations }: Props) => { const { relative, time } = shard; - const [shardVisibility, setShardVisibility] = useState(false); + const [shardVisibility, setShardVisibility] = useState(() => + hasVisibleOperation(operations.map(op => op.treeRoot ?? op)) + ); return ( <> diff --git a/x-pack/plugins/searchprofiler/public/application/containers/main/main.tsx b/x-pack/plugins/searchprofiler/public/application/containers/main/main.tsx index aa6c20aa6a7f3..11dbc6b320531 100644 --- a/x-pack/plugins/searchprofiler/public/application/containers/main/main.tsx +++ b/x-pack/plugins/searchprofiler/public/application/containers/main/main.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import React, { useCallback } from 'react'; import { @@ -33,7 +34,7 @@ import { useProfilerActionContext, useProfilerReadContext } from '../../contexts import { hasAggregations, hasSearch } from '../../utils'; export const Main = () => { - const { getLicenseStatus } = useAppContext(); + const { getLicenseStatus, notifications } = useAppContext(); const { activeTab, @@ -42,8 +43,17 @@ export const Main = () => { pristine, profiling, } = useProfilerReadContext(); + const dispatch = useProfilerActionContext(); + const handleProfileTreeError = (e: Error) => { + notifications.addError(e, { + title: i18n.translate('xpack.searchProfiler.profileTreeErrorRenderTitle', { + defaultMessage: 'Profile data cannot be parsed.', + }), + }); + }; + const setActiveTab = useCallback( (target: Targets) => dispatch({ type: 'setActiveTab', value: target }), [dispatch] @@ -70,7 +80,12 @@ export const Main = () => { if (activeTab) { return (
- +
); } diff --git a/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts b/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts index 3d8bee1d62b27..435db4a98c552 100644 --- a/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts +++ b/x-pack/plugins/searchprofiler/public/application/hooks/use_request_profile.ts @@ -22,7 +22,9 @@ interface ReturnValue { const extractProfilerErrorMessage = (e: any): string | undefined => { if (e.body?.attributes?.error?.reason) { const { reason, line, col } = e.body.attributes.error; - return `${reason} at line: ${line - 1} col: ${col}`; + if (typeof line === 'number' && typeof col === 'number') { + return `${reason} at line: ${line - 1} col: ${col}`; + } } if (e.body?.message) { diff --git a/x-pack/plugins/searchprofiler/public/application/types.ts b/x-pack/plugins/searchprofiler/public/application/types.ts index 9866f8d5b1ccb..896af0851eb52 100644 --- a/x-pack/plugins/searchprofiler/public/application/types.ts +++ b/x-pack/plugins/searchprofiler/public/application/types.ts @@ -49,7 +49,14 @@ export interface Operation { parent: Operation | null; children: Operation[]; - // Only exists on top level + /** + * Only exists on top level. + * + * @remark + * For now, when we init profile data for rendering we take a top-level + * operation and designate it the root of the operations tree - this is not + * information we get from ES. + */ treeRoot?: Operation; depth?: number; diff --git a/x-pack/plugins/security/common/licensing/license_features.ts b/x-pack/plugins/security/common/licensing/license_features.ts index bef328f54de03..5184ab0e962bd 100644 --- a/x-pack/plugins/security/common/licensing/license_features.ts +++ b/x-pack/plugins/security/common/licensing/license_features.ts @@ -48,6 +48,11 @@ export interface SecurityLicenseFeatures { */ readonly allowRbac: boolean; + /** + * Indicates whether we allow sub-feature privileges. + */ + readonly allowSubFeaturePrivileges: boolean; + /** * Describes the layout of the login form if it's displayed. */ 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 40e8901970af8..dfc94042ef930 100644 --- a/x-pack/plugins/security/common/licensing/license_service.test.ts +++ b/x-pack/plugins/security/common/licensing/license_service.test.ts @@ -22,6 +22,7 @@ describe('license features', function() { allowRoleFieldLevelSecurity: false, layout: 'error-es-unavailable', allowRbac: false, + allowSubFeaturePrivileges: false, }); }); @@ -40,6 +41,7 @@ describe('license features', function() { allowRoleFieldLevelSecurity: false, layout: 'error-xpack-unavailable', allowRbac: false, + allowSubFeaturePrivileges: false, }); }); @@ -62,6 +64,7 @@ describe('license features', function() { "allowRbac": false, "allowRoleDocumentLevelSecurity": false, "allowRoleFieldLevelSecurity": false, + "allowSubFeaturePrivileges": false, "layout": "error-xpack-unavailable", "showLinks": false, "showLogin": true, @@ -79,6 +82,7 @@ describe('license features', function() { "allowRbac": false, "allowRoleDocumentLevelSecurity": false, "allowRoleFieldLevelSecurity": false, + "allowSubFeaturePrivileges": false, "showLinks": false, "showLogin": false, "showRoleMappingsManagement": false, @@ -90,7 +94,7 @@ describe('license features', function() { } }); - it('should show login page and other security elements, allow RBAC but forbid role mappings and document level security if license is basic.', () => { + it('should show login page and other security elements, allow RBAC but forbid role mappings, DLS, and sub-feature privileges if license is basic.', () => { const mockRawLicense = licensingMock.createLicense({ features: { security: { isEnabled: true, isAvailable: true } }, }); @@ -108,6 +112,7 @@ describe('license features', function() { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: true, + allowSubFeaturePrivileges: false, }); expect(getFeatureSpy).toHaveBeenCalledTimes(1); expect(getFeatureSpy).toHaveBeenCalledWith('security'); @@ -129,10 +134,11 @@ describe('license features', function() { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, + allowSubFeaturePrivileges: false, }); }); - it('should allow role mappings, but not DLS/FLS if license = gold', () => { + it('should allow role mappings and sub-feature privileges, but not DLS/FLS if license = gold', () => { const mockRawLicense = licensingMock.createLicense({ license: { mode: 'gold', type: 'gold' }, features: { security: { isEnabled: true, isAvailable: true } }, @@ -149,10 +155,11 @@ describe('license features', function() { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: true, + allowSubFeaturePrivileges: true, }); }); - it('should allow to login, allow RBAC, allow role mappings, and document level security if license >= platinum', () => { + it('should allow to login, allow RBAC, role mappings, sub-feature privileges, and DLS if license >= platinum', () => { const mockRawLicense = licensingMock.createLicense({ license: { mode: 'platinum', type: 'platinum' }, features: { security: { isEnabled: true, isAvailable: true } }, @@ -169,6 +176,7 @@ describe('license features', function() { allowRoleDocumentLevelSecurity: true, allowRoleFieldLevelSecurity: true, allowRbac: true, + allowSubFeaturePrivileges: true, }); }); }); diff --git a/x-pack/plugins/security/common/licensing/license_service.ts b/x-pack/plugins/security/common/licensing/license_service.ts index 2c2039c5e2e92..34bc44b88e40d 100644 --- a/x-pack/plugins/security/common/licensing/license_service.ts +++ b/x-pack/plugins/security/common/licensing/license_service.ts @@ -74,6 +74,7 @@ export class SecurityLicenseService { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, + allowSubFeaturePrivileges: false, layout: rawLicense !== undefined && !rawLicense?.isAvailable ? 'error-xpack-unavailable' @@ -90,16 +91,18 @@ export class SecurityLicenseService { allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, + allowSubFeaturePrivileges: false, }; } - const showRoleMappingsManagement = rawLicense.hasAtLeast('gold'); + const isLicenseGoldOrBetter = rawLicense.hasAtLeast('gold'); const isLicensePlatinumOrBetter = rawLicense.hasAtLeast('platinum'); return { showLogin: true, allowLogin: true, showLinks: true, - showRoleMappingsManagement, + showRoleMappingsManagement: isLicenseGoldOrBetter, + allowSubFeaturePrivileges: isLicenseGoldOrBetter, // Only platinum and trial licenses are compliant with field- and document-level security. allowRoleDocumentLevelSecurity: isLicensePlatinumOrBetter, allowRoleFieldLevelSecurity: isLicensePlatinumOrBetter, diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index 88da416cf715b..59d4908c67ffb 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -8,8 +8,8 @@ export { ApiKey, ApiKeyToInvalidate } from './api_key'; export { User, EditUser, getUserDisplayName } from './user'; export { AuthenticatedUser, canUserChangePassword } from './authenticated_user'; export { BuiltinESPrivileges } from './builtin_es_privileges'; -export { FeaturesPrivileges } from './features_privileges'; export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges'; +export { FeaturesPrivileges } from './features_privileges'; export { Role, RoleIndexPrivilege, @@ -22,7 +22,6 @@ export { prepareRoleClone, getExtendedRoleDeprecationNotice, } from './role'; -export { KibanaPrivileges } from './kibana_privileges'; export { InlineRoleTemplate, StoredRoleTemplate, diff --git a/x-pack/plugins/security/common/model/kibana_privileges/feature_privileges.ts b/x-pack/plugins/security/common/model/kibana_privileges/feature_privileges.ts deleted file mode 100644 index fd4cdf33028eb..0000000000000 --- a/x-pack/plugins/security/common/model/kibana_privileges/feature_privileges.ts +++ /dev/null @@ -1,36 +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 { FeaturesPrivileges } from '../features_privileges'; -import { RawKibanaFeaturePrivileges } from '../raw_kibana_privileges'; - -export class KibanaFeaturePrivileges { - constructor(private readonly featurePrivilegesMap: RawKibanaFeaturePrivileges) {} - - public getAllPrivileges(): FeaturesPrivileges { - return Object.entries(this.featurePrivilegesMap).reduce((acc, [featureId, privileges]) => { - return { - ...acc, - [featureId]: Object.keys(privileges), - }; - }, {}); - } - - public getPrivileges(featureId: string): string[] { - const featurePrivileges = this.featurePrivilegesMap[featureId]; - if (featurePrivileges == null) { - return []; - } - - return Object.keys(featurePrivileges); - } - - public getActions(featureId: string, privilege: string): string[] { - if (!this.featurePrivilegesMap[featureId]) { - return []; - } - return this.featurePrivilegesMap[featureId][privilege] || []; - } -} diff --git a/x-pack/plugins/security/common/model/kibana_privileges/global_privileges.ts b/x-pack/plugins/security/common/model/kibana_privileges/global_privileges.ts deleted file mode 100644 index ffe55b813217f..0000000000000 --- a/x-pack/plugins/security/common/model/kibana_privileges/global_privileges.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export class KibanaGlobalPrivileges { - constructor(private readonly globalPrivilegesMap: Record) {} - - public getAllPrivileges(): string[] { - return Object.keys(this.globalPrivilegesMap); - } - - public getActions(privilege: string): string[] { - return this.globalPrivilegesMap[privilege] || []; - } -} diff --git a/x-pack/plugins/security/common/model/kibana_privileges/index.ts b/x-pack/plugins/security/common/model/kibana_privileges/index.ts deleted file mode 100644 index ab9baa1356c4b..0000000000000 --- a/x-pack/plugins/security/common/model/kibana_privileges/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export { KibanaPrivileges } from './kibana_privileges'; diff --git a/x-pack/plugins/security/common/model/kibana_privileges/kibana_privileges.ts b/x-pack/plugins/security/common/model/kibana_privileges/kibana_privileges.ts deleted file mode 100644 index 61e5f083a7798..0000000000000 --- a/x-pack/plugins/security/common/model/kibana_privileges/kibana_privileges.ts +++ /dev/null @@ -1,26 +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 { RawKibanaPrivileges } from '../raw_kibana_privileges'; -import { KibanaFeaturePrivileges } from './feature_privileges'; -import { KibanaGlobalPrivileges } from './global_privileges'; -import { KibanaSpacesPrivileges } from './spaces_privileges'; - -export class KibanaPrivileges { - constructor(private readonly rawKibanaPrivileges: RawKibanaPrivileges) {} - - public getGlobalPrivileges() { - return new KibanaGlobalPrivileges(this.rawKibanaPrivileges.global); - } - - public getSpacesPrivileges() { - return new KibanaSpacesPrivileges(this.rawKibanaPrivileges.space); - } - - public getFeaturePrivileges() { - return new KibanaFeaturePrivileges(this.rawKibanaPrivileges.features); - } -} diff --git a/x-pack/plugins/security/common/model/kibana_privileges/spaces_privileges.ts b/x-pack/plugins/security/common/model/kibana_privileges/spaces_privileges.ts deleted file mode 100644 index 5c8b4196a2b55..0000000000000 --- a/x-pack/plugins/security/common/model/kibana_privileges/spaces_privileges.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export class KibanaSpacesPrivileges { - constructor(private readonly spacesPrivilegesMap: Record) {} - - public getAllPrivileges(): string[] { - return Object.keys(this.spacesPrivilegesMap); - } - - public getActions(privilege: string): string[] { - return this.spacesPrivilegesMap[privilege] || []; - } -} diff --git a/x-pack/plugins/security/public/account_management/account_management_app.ts b/x-pack/plugins/security/public/account_management/account_management_app.ts index 8a14a772a1eef..cd3ef34858b19 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.ts +++ b/x-pack/plugins/security/public/account_management/account_management_app.ts @@ -5,14 +5,14 @@ */ import { i18n } from '@kbn/i18n'; -import { CoreSetup, AppMountParameters } from 'src/core/public'; +import { StartServicesAccessor, ApplicationSetup, AppMountParameters } from 'src/core/public'; import { AuthenticationServiceSetup } from '../authentication'; import { UserAPIClient } from '../management'; interface CreateDeps { - application: CoreSetup['application']; + application: ApplicationSetup; authc: AuthenticationServiceSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const accountManagementApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/authentication/authentication_service.ts b/x-pack/plugins/security/public/authentication/authentication_service.ts index 7b88b0f8573ba..979f7095cf933 100644 --- a/x-pack/plugins/security/public/authentication/authentication_service.ts +++ b/x-pack/plugins/security/public/authentication/authentication_service.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ApplicationSetup, CoreSetup, HttpSetup } from 'src/core/public'; +import { ApplicationSetup, StartServicesAccessor, HttpSetup } from 'src/core/public'; import { AuthenticatedUser } from '../../common/model'; import { ConfigType } from '../config'; import { PluginStartDependencies } from '../plugin'; @@ -17,7 +17,7 @@ interface SetupParams { application: ApplicationSetup; config: ConfigType; http: HttpSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export interface AuthenticationServiceSetup { diff --git a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.ts b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.ts index b7f2615318791..2849111e7efeb 100644 --- a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.ts +++ b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.ts @@ -5,12 +5,17 @@ */ import { i18n } from '@kbn/i18n'; -import { CoreSetup, AppMountParameters, HttpSetup } from 'src/core/public'; +import { + StartServicesAccessor, + ApplicationSetup, + AppMountParameters, + HttpSetup, +} from 'src/core/public'; interface CreateDeps { - application: CoreSetup['application']; + application: ApplicationSetup; http: HttpSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const loggedOutApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/authentication/login/login_app.ts b/x-pack/plugins/security/public/authentication/login/login_app.ts index 1642aba51c1ae..1ecb5dcfd7990 100644 --- a/x-pack/plugins/security/public/authentication/login/login_app.ts +++ b/x-pack/plugins/security/public/authentication/login/login_app.ts @@ -5,13 +5,18 @@ */ import { i18n } from '@kbn/i18n'; -import { CoreSetup, AppMountParameters, HttpSetup } from 'src/core/public'; +import { + StartServicesAccessor, + AppMountParameters, + ApplicationSetup, + HttpSetup, +} from 'src/core/public'; import { ConfigType } from '../../config'; interface CreateDeps { - application: CoreSetup['application']; + application: ApplicationSetup; http: HttpSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; config: Pick; } diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts index 1bbe388a635e2..8e0ee73dfb613 100644 --- a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.ts @@ -5,13 +5,13 @@ */ import { i18n } from '@kbn/i18n'; -import { CoreSetup, AppMountParameters } from 'src/core/public'; +import { StartServicesAccessor, ApplicationSetup, AppMountParameters } from 'src/core/public'; import { AuthenticationServiceSetup } from '../authentication_service'; interface CreateDeps { - application: CoreSetup['application']; + application: ApplicationSetup; authc: AuthenticationServiceSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const overwrittenSessionApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx index 35de732b84ce9..272fc9cfc2fe6 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { i18n } from '@kbn/i18n'; -import { CoreSetup } from 'src/core/public'; +import { StartServicesAccessor } from 'src/core/public'; import { RegisterManagementAppArgs } from '../../../../../../src/plugins/management/public'; import { PluginStartDependencies } from '../../plugin'; import { APIKeysGridPage } from './api_keys_grid'; @@ -15,7 +15,7 @@ import { APIKeysAPIClient } from './api_keys_api_client'; import { DocumentationLinksService } from './documentation_links'; interface CreateParams { - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const apiKeysManagementApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/management/management_service.ts b/x-pack/plugins/security/public/management/management_service.ts index 5ad3681590fbf..7c4c470730ffe 100644 --- a/x-pack/plugins/security/public/management/management_service.ts +++ b/x-pack/plugins/security/public/management/management_service.ts @@ -6,7 +6,7 @@ import { Subscription } from 'rxjs'; import { i18n } from '@kbn/i18n'; -import { CoreSetup, FatalErrorsSetup } from 'src/core/public'; +import { StartServicesAccessor, FatalErrorsSetup } from 'src/core/public'; import { ManagementApp, ManagementSetup, @@ -25,7 +25,7 @@ interface SetupParams { license: SecurityLicense; authc: AuthenticationServiceSetup; fatalErrors: FatalErrorsSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } interface StartParams { diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx index 8e1ac8d7f6957..ea090520fdd46 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { HashRouter as Router, Route, Switch, useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { CoreSetup } from 'src/core/public'; +import { StartServicesAccessor } from 'src/core/public'; import { RegisterManagementAppArgs } from '../../../../../../src/plugins/management/public'; import { PluginStartDependencies } from '../../plugin'; import { RolesAPIClient } from '../roles'; @@ -18,7 +18,7 @@ import { RoleMappingsGridPage } from './role_mappings_grid'; import { EditRoleMappingPage } from './edit_role_mapping'; interface CreateParams { - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const roleMappingsManagementApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts new file mode 100644 index 0000000000000..68d352363d363 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts @@ -0,0 +1,208 @@ +/* + * 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 { Feature, FeatureConfig } from '../../../../../features/public'; + +export const createFeature = ( + config: Pick & { + excludeFromBaseAll?: boolean; + excludeFromBaseRead?: boolean; + } +) => { + const { excludeFromBaseAll, excludeFromBaseRead, ...rest } = config; + return new Feature({ + icon: 'discoverApp', + navLinkId: 'kibana:discover', + app: [], + catalogue: [], + privileges: { + all: { + excludeFromBasePrivileges: excludeFromBaseAll, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['read-ui', 'all-ui', `read-${config.id}`, `all-${config.id}`], + }, + read: { + excludeFromBasePrivileges: excludeFromBaseRead, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['read-ui', `read-${config.id}`], + }, + }, + ...rest, + }); +}; + +export const kibanaFeatures = [ + createFeature({ + id: 'no_sub_features', + name: 'Feature 1: No Sub Features', + }), + createFeature({ + id: 'with_sub_features', + name: 'Mutually Exclusive Sub Features', + subFeatures: [ + { + name: 'Cool Sub Feature', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'cool_all', + name: 'All', + includeIn: 'all', + savedObject: { + all: ['all-cool-type'], + read: ['read-cool-type'], + }, + ui: ['cool_read-ui', 'cool_all-ui'], + }, + { + id: 'cool_read', + name: 'Read', + includeIn: 'read', + savedObject: { + all: [], + read: ['read-cool-type'], + }, + ui: ['cool_read-ui'], + }, + ], + }, + { + groupType: 'independent', + privileges: [ + { + id: 'cool_toggle_1', + name: 'Cool toggle 1', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_toggle_1-ui'], + }, + { + id: 'cool_toggle_2', + name: 'Cool toggle 2', + includeIn: 'read', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_toggle_2-ui'], + }, + { + id: 'cool_excluded_toggle', + name: 'Cool excluded toggle', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_excluded_toggle-ui'], + }, + ], + }, + ], + }, + ], + }), + createFeature({ + id: 'with_excluded_sub_features', + name: 'Excluded Sub Features', + subFeatures: [ + { + name: 'Excluded Sub Feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool_toggle_1', + name: 'Cool toggle 1', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_toggle_1-ui'], + }, + ], + }, + ], + }, + ], + }), + createFeature({ + id: 'excluded_from_base', + name: 'Excluded from base', + excludeFromBaseAll: true, + excludeFromBaseRead: true, + subFeatures: [ + { + name: 'Cool Sub Feature', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'cool_all', + name: 'All', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_read-ui', 'cool_all-ui'], + }, + { + id: 'cool_read', + name: 'Read', + includeIn: 'read', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_read-ui'], + }, + ], + }, + { + groupType: 'independent', + privileges: [ + { + id: 'cool_toggle_1', + name: 'Cool toggle 2', + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_toggle_1-ui'], + }, + { + id: 'cool_toggle_2', + name: 'Cool toggle 2', + includeIn: 'read', + savedObject: { + all: [], + read: [], + }, + ui: ['cool_toggle_2-ui'], + }, + ], + }, + ], + }, + ], + }), +]; diff --git a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.ts new file mode 100644 index 0000000000000..98110a83103aa --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_privileges.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. + */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { Actions } from '../../../../server/authorization'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { privilegesFactory } from '../../../../server/authorization/privileges'; +import { Feature } from '../../../../../features/public'; +import { KibanaPrivileges } from '../model'; +import { SecurityLicenseFeatures } from '../../..'; + +export const createRawKibanaPrivileges = ( + features: Feature[], + { allowSubFeaturePrivileges = true } = {} +) => { + const featuresService = { + getFeatures: () => features, + }; + + const licensingService = { + getFeatures: () => ({ allowSubFeaturePrivileges } as SecurityLicenseFeatures), + }; + + return privilegesFactory( + new Actions('unit_test_version'), + featuresService, + licensingService + ).get(); +}; + +export const createKibanaPrivileges = ( + features: Feature[], + { allowSubFeaturePrivileges = true } = {} +) => { + return new KibanaPrivileges( + createRawKibanaPrivileges(features, { allowSubFeaturePrivileges }), + features + ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx index 23a3f327a2c5c..f1ee681331005 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx @@ -10,16 +10,10 @@ import { act } from '@testing-library/react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { Capabilities } from 'src/core/public'; import { Feature } from '../../../../../features/public'; -// These modules should be moved into a common directory -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Actions } from '../../../../server/authorization/actions'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { privilegesFactory } from '../../../../server/authorization/privileges'; import { Role } from '../../../../common/model'; import { DocumentationLinksService } from '../documentation_links'; import { EditRolePage } from './edit_role_page'; import { SimplePrivilegeSection } from './privileges/kibana/simple_privilege_section'; -import { SpaceAwarePrivilegeSection } from './privileges/kibana/space_aware_privilege_section'; import { TransformErrorSection } from './privileges/kibana/transform_error_section'; import { coreMock } from '../../../../../../../src/core/public/mocks'; @@ -28,10 +22,12 @@ import { licenseMock } from '../../../../common/licensing/index.mock'; import { userAPIClientMock } from '../../users/index.mock'; import { rolesAPIClientMock, indicesAPIClientMock, privilegesAPIClientMock } from '../index.mock'; import { Space } from '../../../../../spaces/public'; +import { SpaceAwarePrivilegeSection } from './privileges/kibana/space_aware_privilege_section'; +import { createRawKibanaPrivileges } from '../__fixtures__/kibana_privileges'; const buildFeatures = () => { return [ - { + new Feature({ id: 'feature1', name: 'Feature 1', icon: 'addDataApp', @@ -45,9 +41,17 @@ const buildFeatures = () => { read: [], }, }, + read: { + app: ['feature1App'], + ui: ['feature1-ui'], + savedObject: { + all: [], + read: [], + }, + }, }, - }, - { + }), + new Feature({ id: 'feature2', name: 'Feature 2', icon: 'addDataApp', @@ -61,17 +65,19 @@ const buildFeatures = () => { read: ['config'], }, }, + read: { + app: ['feature2App'], + ui: ['feature2-ui'], + savedObject: { + all: [], + read: ['config'], + }, + }, }, - }, + }), ] as Feature[]; }; -const buildRawKibanaPrivileges = () => { - return privilegesFactory(new Actions('unit_test_version'), { - getFeatures: () => buildFeatures(), - }).get(); -}; - const buildBuiltinESPrivileges = () => { return { cluster: ['all', 'manage', 'monitor'], @@ -144,7 +150,7 @@ function getProps({ userAPIClient.getUsers.mockResolvedValue([]); const privilegesAPIClient = privilegesAPIClientMock.create(); - privilegesAPIClient.getAll.mockResolvedValue(buildRawKibanaPrivileges()); + privilegesAPIClient.getAll.mockResolvedValue(createRawKibanaPrivileges(buildFeatures())); privilegesAPIClient.getBuiltIn.mockResolvedValue(buildBuiltinESPrivileges()); const license = licenseMock.create(); @@ -156,10 +162,6 @@ function getProps({ const { fatalErrors } = coreMock.createSetup(); const { http, docLinks, notifications } = coreMock.createStart(); http.get.mockImplementation(async (path: any) => { - if (path === '/api/features') { - return buildFeatures(); - } - if (path === '/api/spaces/space') { return buildSpaces(); } @@ -175,6 +177,7 @@ function getProps({ privilegesAPIClient, rolesAPIClient, userAPIClient, + getFeatures: () => Promise.resolve(buildFeatures()), notifications, docLinks: new DocumentationLinksService(docLinks), fatalErrors, @@ -200,10 +203,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(1); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); @@ -226,10 +226,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); @@ -240,10 +237,7 @@ describe('', () => { it('can render when creating a new role', async () => { const wrapper = mountWithIntl(); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0); @@ -275,10 +269,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0); @@ -301,10 +292,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0); @@ -333,10 +321,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(TransformErrorSection)).toHaveLength(1); expectReadOnlyFormButtons(wrapper); @@ -360,10 +345,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(1); expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1); @@ -387,10 +369,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0); expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1); @@ -403,10 +382,7 @@ describe('', () => { ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1); expectSaveFormButtons(wrapper); @@ -438,10 +414,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SimplePrivilegeSection)).toHaveLength(1); expectSaveFormButtons(wrapper); @@ -464,10 +437,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find('[data-test-subj="reservedRoleBadgeTooltip"]')).toHaveLength(0); @@ -497,10 +467,7 @@ describe('', () => { /> ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(TransformErrorSection)).toHaveLength(1); expectReadOnlyFormButtons(wrapper); @@ -522,10 +489,7 @@ describe('', () => { const wrapper = mountWithIntl(); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0); @@ -540,13 +504,17 @@ describe('', () => { ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await waitForRender(wrapper); expect(wrapper.find(SpaceAwarePrivilegeSection)).toHaveLength(1); expect(wrapper.find('[data-test-subj="userCannotManageSpacesCallout"]')).toHaveLength(0); expectSaveFormButtons(wrapper); }); }); + +async function waitForRender(wrapper: ReactWrapper) { + await act(async () => { + await nextTick(); + wrapper.update(); + }); +} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx index cd7766ef38748..f0d5abf89dd2e 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx @@ -37,11 +37,11 @@ import { IHttpFetchError, NotificationsStart, } from 'src/core/public'; +import { FeaturesPluginStart } from '../../../../../features/public'; +import { Feature } from '../../../../../features/common'; import { IndexPatternsContract } from '../../../../../../../src/plugins/data/public'; import { Space } from '../../../../../spaces/public'; -import { Feature } from '../../../../../features/public'; import { - KibanaPrivileges, RawKibanaPrivileges, Role, BuiltinESPrivileges, @@ -64,6 +64,7 @@ import { DocumentationLinksService } from '../documentation_links'; import { IndicesAPIClient } from '../indices_api_client'; import { RolesAPIClient } from '../roles_api_client'; import { PrivilegesAPIClient } from '../privileges_api_client'; +import { KibanaPrivileges } from '../model'; interface Props { action: 'edit' | 'clone'; @@ -73,6 +74,7 @@ interface Props { indicesAPIClient: PublicMethodsOf; rolesAPIClient: PublicMethodsOf; privilegesAPIClient: PublicMethodsOf; + getFeatures: FeaturesPluginStart['getFeatures']; docLinks: DocumentationLinksService; http: HttpStart; license: SecurityLicense; @@ -231,11 +233,13 @@ function useSpaces(http: HttpStart, fatalErrors: FatalErrorsSetup, spacesEnabled return spaces; } -function useFeatures(http: HttpStart, fatalErrors: FatalErrorsSetup) { +function useFeatures( + getFeatures: FeaturesPluginStart['getFeatures'], + fatalErrors: FatalErrorsSetup +) { const [features, setFeatures] = useState(null); useEffect(() => { - http - .get('/api/features') + getFeatures() .catch((err: IHttpFetchError) => { // Currently, the `/api/features` endpoint effectively requires the "Global All" kibana privilege (e.g., what // the `kibana_user` grants), because it returns information about all registered features (#35841). It's @@ -246,14 +250,15 @@ function useFeatures(http: HttpStart, fatalErrors: FatalErrorsSetup) { // 404 here, and respond in a way that still allows the UI to render itself. const unauthorizedForFeatures = err.response?.status === 404; if (unauthorizedForFeatures) { - return []; + return [] as Feature[]; } fatalErrors.add(err); - throw err; }) - .then(setFeatures); - }, [http, fatalErrors]); + .then(retrievedFeatures => { + setFeatures(retrievedFeatures); + }); + }, [fatalErrors, getFeatures]); return features; } @@ -268,6 +273,7 @@ export const EditRolePage: FunctionComponent = ({ rolesAPIClient, indicesAPIClient, privilegesAPIClient, + getFeatures, http, roleName, action, @@ -287,7 +293,7 @@ export const EditRolePage: FunctionComponent = ({ const indexPatternsTitles = useIndexPatternsTitles(indexPatterns, fatalErrors, notifications); const privileges = usePrivileges(privilegesAPIClient, fatalErrors); const spaces = useSpaces(http, fatalErrors, spacesEnabled); - const features = useFeatures(http, fatalErrors); + const features = useFeatures(getFeatures, fatalErrors); const [role, setRole] = useRole( rolesAPIClient, fatalErrors, @@ -425,11 +431,11 @@ export const EditRolePage: FunctionComponent = ({
{ it('returns true if no spaces are defined', () => { @@ -47,39 +47,3 @@ describe('isGlobalPrivilegeDefinition', () => { ).toEqual(false); }); }); - -describe('hasAssignedFeaturePrivileges', () => { - it('returns false if no feature privileges are defined', () => { - expect( - hasAssignedFeaturePrivileges({ - spaces: [], - base: [], - feature: {}, - }) - ).toEqual(false); - }); - - it('returns false if feature privileges are defined but not assigned', () => { - expect( - hasAssignedFeaturePrivileges({ - spaces: [], - base: [], - feature: { - foo: [], - }, - }) - ).toEqual(false); - }); - - it('returns true if feature privileges are defined and assigned', () => { - expect( - hasAssignedFeaturePrivileges({ - spaces: [], - base: [], - feature: { - foo: ['all'], - }, - }) - ).toEqual(true); - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.ts b/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.ts index 3fd8536951967..1fad9057665da 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privilege_utils.ts @@ -16,12 +16,3 @@ export function isGlobalPrivilegeDefinition(privilegeSpec: RoleKibanaPrivilege): } return privilegeSpec.spaces.includes('*'); } - -/** - * Determines if the passed privilege spec defines feature privileges. - * @param privilegeSpec - */ -export function hasAssignedFeaturePrivileges(privilegeSpec: RoleKibanaPrivilege): boolean { - const featureKeys = Object.keys(privilegeSpec.feature); - return featureKeys.length > 0 && featureKeys.some(key => privilegeSpec.feature[key].length > 0); -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/__snapshots__/kibana_privileges_region.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/__snapshots__/kibana_privileges_region.test.tsx.snap index 617335dc9fb34..a911455f95b5d 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/__snapshots__/kibana_privileges_region.test.tsx.snap +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/__snapshots__/kibana_privileges_region.test.tsx.snap @@ -5,33 +5,17 @@ exports[` renders without crashing 1`] = ` iconType="logoKibana" title="Kibana" > - ) { + const allExpanderButtons = findTestSubject(wrapper, 'expandFeaturePrivilegeRow'); + allExpanderButtons.forEach(button => button.simulate('click')); + + // each expanded row renders its own `EuiTableRow`, so there are 2 rows + // for each feature: one for the primary feature privilege, and one for the sub privilege form + const rows = wrapper.find(EuiTableRow); + + return rows.reduce((acc, row) => { + const subFeaturePrivileges = []; + const subFeatureForm = row.find(SubFeatureForm); + if (subFeatureForm.length > 0) { + const { featureId } = subFeatureForm.props(); + const independentPrivileges = (subFeatureForm.find(EuiCheckbox) as ReactWrapper< + EuiCheckboxProps + >).reduce((acc2, checkbox) => { + const { id: privilegeId, checked } = checkbox.props(); + return checked ? [...acc2, privilegeId] : acc2; + }, [] as string[]); + + const mutuallyExclusivePrivileges = (subFeatureForm.find(EuiButtonGroup) as ReactWrapper< + EuiButtonGroupProps + >).reduce((acc2, subPrivButtonGroup) => { + const { idSelected: selectedSubPrivilege } = subPrivButtonGroup.props(); + return selectedSubPrivilege && selectedSubPrivilege !== 'none' + ? [...acc2, selectedSubPrivilege] + : acc2; + }, [] as string[]); + + subFeaturePrivileges.push(...independentPrivileges, ...mutuallyExclusivePrivileges); + + return { + ...acc, + [featureId]: { + ...acc[featureId], + subFeaturePrivileges, + }, + }; + } else { + const buttonGroup = row.find(EuiButtonGroup); + const { name, idSelected } = buttonGroup.props(); + expect(name).toBeDefined(); + expect(idSelected).toBeDefined(); + + const featureId = name!.substr(`featurePrivilege_`.length); + const primaryFeaturePrivilege = idSelected!.substr(`${featureId}_`.length); + + return { + ...acc, + [featureId]: { + ...acc[featureId], + primaryFeaturePrivilege, + }, + }; + } + }, {} as Record); +} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/__snapshots__/feature_table.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/__snapshots__/feature_table.test.tsx.snap deleted file mode 100644 index 799ff205e2540..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/__snapshots__/feature_table.test.tsx.snap +++ /dev/null @@ -1,39 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`FeatureTable can render without spaces 1`] = ` - - - - , - "render": [Function], - }, - ] - } - items={Array []} - responsive={false} - tableLayout="fixed" -/> -`; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/change_all_privileges.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/change_all_privileges.tsx index c480f33b57899..2083778e53998 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/change_all_privileges.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/change_all_privileges.tsx @@ -7,9 +7,11 @@ import { EuiContextMenuItem, EuiContextMenuPanel, EuiLink, EuiPopover } from '@e import { FormattedMessage } from '@kbn/i18n/react'; import _ from 'lodash'; import React, { Component } from 'react'; +import { KibanaPrivilege } from '../../../../model'; +import { NO_PRIVILEGE_VALUE } from '../constants'; interface Props { onChange: (privilege: string) => void; - privileges: string[]; + privileges: KibanaPrivilege[]; disabled?: boolean; } @@ -24,7 +26,11 @@ export class ChangeAllPrivilegesControl extends Component { public render() { const button = ( - + { const items = this.props.privileges.map(privilege => { return ( { - this.onSelectPrivilege(privilege); + this.onSelectPrivilege(privilege.id); }} disabled={this.props.disabled} > - {_.capitalize(privilege)} + {_.capitalize(privilege.id)} ); }); + items.push( + { + this.onSelectPrivilege(NO_PRIVILEGE_VALUE); + }} + disabled={this.props.disabled} + > + {_.capitalize(NO_PRIVILEGE_VALUE)} + + ); + return ( { + return { + name: 'my_role', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana, }; - spacesPrivileges?: Array<{ - spaces: string[]; - base: string[]; - feature: FeaturesPrivileges; - }>; +}; + +interface TestConfig { + features: Feature[]; + role: Role; + privilegeIndex: number; + calculateDisplayedPrivileges: boolean; + canCustomizeSubFeaturePrivileges: boolean; } -const buildRole = (options: BuildRoleOpts = {}) => { - const role: Role = { - name: 'unit test role', - elasticsearch: { - indices: [], - cluster: [], - run_as: [], - }, - kibana: [], +const setup = (config: TestConfig) => { + const kibanaPrivileges = createKibanaPrivileges(config.features, { + allowSubFeaturePrivileges: config.canCustomizeSubFeaturePrivileges, + }); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, config.role); + const onChange = jest.fn(); + const onChangeAll = jest.fn(); + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = config.calculateDisplayedPrivileges + ? getDisplayedFeaturePrivileges(wrapper) + : undefined; + + return { + wrapper, + onChange, + onChangeAll, + displayedPrivileges, }; +}; + +describe('FeatureTable', () => { + [true, false].forEach(canCustomizeSubFeaturePrivileges => { + describe(`with sub feature privileges ${ + canCustomizeSubFeaturePrivileges ? 'allowed' : 'disallowed' + }`, () => { + it('renders with no granted privileges for an empty role', () => { + const role = createRole([ + { + spaces: [], + base: [], + feature: {}, + }, + ]); + + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges, + }); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + with_sub_features: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + }); + }); + + it('renders with all included privileges granted at the space when space base privilege is "all"', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: ['all'], + feature: {}, + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges, + }); + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + no_sub_features: { + primaryFeaturePrivilege: 'all', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'all', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + with_sub_features: { + primaryFeaturePrivilege: 'all', + ...(canCustomizeSubFeaturePrivileges + ? { + subFeaturePrivileges: [ + 'with_sub_features_cool_toggle_1', + 'with_sub_features_cool_toggle_2', + 'cool_all', + ], + } + : {}), + }, + }); + }); + + it('renders the most permissive primary feature privilege when multiple are assigned', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['read', 'minimal_all', 'all', 'minimal_read'], + }, + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges, + }); - if (options.globalPrivilege) { - role.kibana.push({ - spaces: ['*'], - ...options.globalPrivilege, + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + ...(canCustomizeSubFeaturePrivileges ? { subFeaturePrivileges: [] } : {}), + }, + with_sub_features: { + primaryFeaturePrivilege: 'all', + ...(canCustomizeSubFeaturePrivileges + ? { + subFeaturePrivileges: [ + 'with_sub_features_cool_toggle_1', + 'with_sub_features_cool_toggle_2', + 'cool_all', + ], + } + : {}), + }, + }); + }); + + it('allows all feature privileges to be toggled via "change all"', () => { + const role = createRole([ + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ]); + const { wrapper, onChangeAll } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 0, + calculateDisplayedPrivileges: false, + canCustomizeSubFeaturePrivileges, + }); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-read').simulate('click'); + + expect(onChangeAll).toHaveBeenCalledWith(['read']); + }); + + it('allows all feature privileges to be unassigned via "change all"', () => { + const role = createRole([ + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['all'], + no_sub_features: ['read'], + with_excluded_sub_features: ['all', 'something else'], + }, + }, + ]); + const { wrapper, onChangeAll } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 0, + calculateDisplayedPrivileges: false, + canCustomizeSubFeaturePrivileges, + }); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-none').simulate('click'); + + expect(onChangeAll).toHaveBeenCalledWith([]); + }); + }); + }); + + it('renders the most permissive sub-feature privilege when multiple are assigned in a mutually-exclusive group', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_all', 'cool_read'], + }, + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, }); - } - if (options.spacesPrivileges) { - role.kibana.push(...options.spacesPrivileges); - } + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + subFeaturePrivileges: [], + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + subFeaturePrivileges: [], + }, + with_sub_features: { + primaryFeaturePrivilege: 'read', + subFeaturePrivileges: ['cool_all'], + }, + }); + }); - return role; -}; + it('renders a row expander only for features with sub-features', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ]); + const { wrapper } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: false, + canCustomizeSubFeaturePrivileges: true, + }); -const buildFeatures = () => { - return []; -}; + kibanaFeatures.forEach(feature => { + const rowExpander = findTestSubject(wrapper, `expandFeaturePrivilegeRow-${feature.id}`); + if (!feature.subFeatures || feature.subFeatures.length === 0) { + expect(rowExpander).toHaveLength(0); + } else { + expect(rowExpander).toHaveLength(1); + } + }); + }); -describe('FeatureTable', () => { - it('can render without spaces', () => { - const role = buildRole({ - spacesPrivileges: [ + it('renders the when the row is expanded', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: {}, + }, + ]); + const { wrapper } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: false, + canCustomizeSubFeaturePrivileges: true, + }); + + expect(wrapper.find(FeatureTableExpandedRow)).toHaveLength(0); + + findTestSubject(wrapper, 'expandFeaturePrivilegeRow') + .first() + .simulate('click'); + + expect(wrapper.find(FeatureTableExpandedRow)).toHaveLength(1); + }); + + it('renders with sub-feature privileges granted when primary feature privilege is "all"', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: { + unit_test: ['all'], + }, + }, + ]); + const feature = createFeature({ + id: 'unit_test', + name: 'Unit Test Feature', + subFeatures: [ { - spaces: ['marketing', 'default'], - base: ['read'], - feature: { - feature1: ['all'], - }, + name: 'Some Sub Feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-toggle-1', + name: 'Sub Toggle 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-2'], + }, + ], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-option-1', + name: 'Sub Option 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-option-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-option-2'], + }, + ], + }, + ], + }, + ] as SubFeatureConfig[], + }); + + const { displayedPrivileges } = setup({ + role, + features: [feature], + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, + }); + expect(displayedPrivileges).toEqual({ + unit_test: { + primaryFeaturePrivilege: 'all', + subFeaturePrivileges: ['unit_test_sub-toggle-1', 'unit_test_sub-toggle-2', 'sub-option-1'], + }, + }); + }); + + it('renders with some sub-feature privileges granted when primary feature privilege is "read"', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: { + unit_test: ['read'], + }, + }, + ]); + const feature = createFeature({ + id: 'unit_test', + name: 'Unit Test Feature', + subFeatures: [ + { + name: 'Some Sub Feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-toggle-1', + name: 'Sub Toggle 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-2'], + }, + ], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-option-1', + name: 'Sub Option 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-option-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-option-2'], + }, + ], + }, + ], + }, + ] as SubFeatureConfig[], + }); + + const { displayedPrivileges } = setup({ + role, + features: [feature], + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, + }); + expect(displayedPrivileges).toEqual({ + unit_test: { + primaryFeaturePrivilege: 'read', + subFeaturePrivileges: ['unit_test_sub-toggle-2', 'sub-toggle-2'], + }, + }); + }); + + it('renders with excluded sub-feature privileges not granted when primary feature privilege is "all"', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: { + unit_test: ['all'], + }, + }, + ]); + const feature = createFeature({ + id: 'unit_test', + name: 'Unit Test Feature', + subFeatures: [ + { + name: 'Some Sub Feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-toggle-1', + name: 'Sub Toggle 1', + includeIn: 'none', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'none', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-2'], + }, + ], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-option-1', + name: 'Sub Option 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-option-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-option-2'], + }, + ], + }, + ], + }, + ] as SubFeatureConfig[], + }); + + const { displayedPrivileges } = setup({ + role, + features: [feature], + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, + }); + expect(displayedPrivileges).toEqual({ + unit_test: { + primaryFeaturePrivilege: 'all', + subFeaturePrivileges: ['unit_test_sub-toggle-2', 'sub-option-1'], + }, + }); + }); + + it('renders with excluded sub-feature privileges granted when explicitly assigned', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: { + unit_test: ['all', 'sub-toggle-1'], + }, + }, + ]); + const feature = createFeature({ + id: 'unit_test', + name: 'Unit Test Feature', + subFeatures: [ + { + name: 'Some Sub Feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-toggle-1', + name: 'Sub Toggle 1', + includeIn: 'none', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'none', + savedObject: { all: [], read: [] }, + ui: ['sub-toggle-2'], + }, + ], + }, + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'sub-option-1', + name: 'Sub Option 1', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['sub-option-1'], + }, + { + id: 'sub-toggle-2', + name: 'Sub Toggle 2', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['sub-option-2'], + }, + ], + }, + ], + }, + ] as SubFeatureConfig[], + }); + + const { displayedPrivileges } = setup({ + role, + features: [feature], + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, + }); + expect(displayedPrivileges).toEqual({ + unit_test: { + primaryFeaturePrivilege: 'all', + subFeaturePrivileges: ['unit_test_sub-toggle-1', 'unit_test_sub-toggle-2', 'sub-option-1'], + }, + }); + }); + + it('renders with all included sub-feature privileges granted at the space when primary feature privileges are granted', () => { + const role = createRole([ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['all'], }, - ], - }); - - const calculator = new KibanaPrivilegeCalculatorFactory(defaultPrivilegeDefinition).getInstance( - role - ); - - const wrapper = shallowWithIntl( - - ); - - expect(wrapper).toMatchSnapshot(); + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 1, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: true, + }); + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + subFeaturePrivileges: [], + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + subFeaturePrivileges: [], + }, + with_sub_features: { + primaryFeaturePrivilege: 'all', + subFeaturePrivileges: [ + 'with_sub_features_cool_toggle_1', + 'with_sub_features_cool_toggle_2', + 'cool_all', + ], + }, + }); }); - it('can render for a specific spaces entry', () => { - const role = buildRole(); - const calculator = new KibanaPrivilegeCalculatorFactory(defaultPrivilegeDefinition).getInstance( - role - ); - const wrapper = mountWithIntl( - - ); - - expect(wrapper.find(EuiInMemoryTable)).toHaveLength(1); + it('renders with no privileges granted when minimal feature privileges are assigned, and sub-feature privileges are disallowed', () => { + const role = createRole([ + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['minimal_all'], + }, + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: false, + }); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_sub_features: { + primaryFeaturePrivilege: 'none', + }, + }); + }); + + it('renders with no privileges granted when sub feature privileges are assigned, and sub-feature privileges are disallowed', () => { + const role = createRole([ + { + spaces: ['foo'], + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_all'], + }, + }, + ]); + const { displayedPrivileges } = setup({ + role, + features: kibanaFeatures, + privilegeIndex: 0, + calculateDisplayedPrivileges: true, + canCustomizeSubFeaturePrivileges: false, + }); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + primaryFeaturePrivilege: 'none', + }, + no_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_excluded_sub_features: { + primaryFeaturePrivilege: 'none', + }, + with_sub_features: { + primaryFeaturePrivilege: 'none', + }, + }); }); }); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx index 8283efe23260a..4610da95e9649 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx @@ -4,103 +4,112 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; -import React, { Component } from 'react'; import { EuiButtonGroup, - EuiIcon, EuiIconTip, EuiInMemoryTable, EuiText, - IconType, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Feature } from '../../../../../../../../features/public'; -import { FeaturesPrivileges, KibanaPrivileges, Role } from '../../../../../../../common/model'; -import { - AllowedPrivilege, - CalculatedPrivilege, - PrivilegeExplanation, -} from '../kibana_privilege_calculator'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { PrivilegeDisplay } from '../space_aware_privilege_section/privilege_display'; +import { i18n } from '@kbn/i18n'; +import _ from 'lodash'; +import React, { Component } from 'react'; +import { Role } from '../../../../../../../common/model'; import { ChangeAllPrivilegesControl } from './change_all_privileges'; +import { FeatureTableExpandedRow } from './feature_table_expanded_row'; +import { NO_PRIVILEGE_VALUE } from '../constants'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { FeatureTableCell } from '../feature_table_cell'; +import { KibanaPrivileges, SecuredFeature, KibanaPrivilege } from '../../../../model'; interface Props { role: Role; - features: Feature[]; - calculatedPrivileges: CalculatedPrivilege; - allowedPrivileges: AllowedPrivilege; - rankedFeaturePrivileges: FeaturesPrivileges; + privilegeCalculator: PrivilegeFormCalculator; kibanaPrivileges: KibanaPrivileges; - spacesIndex: number; + privilegeIndex: number; onChange: (featureId: string, privileges: string[]) => void; onChangeAll: (privileges: string[]) => void; + canCustomizeSubFeaturePrivileges: boolean; disabled?: boolean; } -interface TableFeature extends Feature { - hasAnyPrivilegeAssigned: boolean; +interface State { + expandedFeatures: string[]; } interface TableRow { - feature: TableFeature; + featureId: string; + feature: SecuredFeature; + inherited: KibanaPrivilege[]; + effective: KibanaPrivilege[]; role: Role; } -export class FeatureTable extends Component { +export class FeatureTable extends Component { public static defaultProps = { - spacesIndex: -1, + privilegeIndex: -1, showLocks: true, }; + constructor(props: Props) { + super(props); + this.state = { + expandedFeatures: [], + }; + } + public render() { - const { role, features, calculatedPrivileges, rankedFeaturePrivileges } = this.props; + const { role, kibanaPrivileges } = this.props; + + const featurePrivileges = kibanaPrivileges.getSecuredFeatures(); - const items: TableRow[] = features + const items: TableRow[] = featurePrivileges .sort((feature1, feature2) => { - if ( - Object.keys(feature1.privileges).length === 0 && - Object.keys(feature2.privileges).length > 0 - ) { + if (feature1.reserved && !feature2.reserved) { return 1; } - if ( - Object.keys(feature2.privileges).length === 0 && - Object.keys(feature1.privileges).length > 0 - ) { + if (feature2.reserved && !feature1.reserved) { return -1; } return 0; }) .map(feature => { - const calculatedFeaturePrivileges = calculatedPrivileges.feature[feature.id]; - const hasAnyPrivilegeAssigned = Boolean( - calculatedFeaturePrivileges && - calculatedFeaturePrivileges.actualPrivilege !== NO_PRIVILEGE_VALUE - ); return { - feature: { - ...feature, - hasAnyPrivilegeAssigned, - }, + featureId: feature.id, + feature, + inherited: [], + effective: [], role, }; }); - // TODO: This simply grabs the available privileges from the first feature we encounter. - // As of now, features can have 'all' and 'read' as available privileges. Once that assumption breaks, - // this will need updating. This is a simplifying measure to enable the new UI. - const availablePrivileges = Object.values(rankedFeaturePrivileges)[0]; - return ( { + return { + ...acc, + [featureId]: ( + f.id === featureId)!} + privilegeIndex={this.props.privilegeIndex} + onChange={this.props.onChange} + privilegeCalculator={this.props.privilegeCalculator} + selectedFeaturePrivileges={ + this.props.role.kibana[this.props.privilegeIndex].feature[featureId] ?? [] + } + disabled={this.props.disabled} + /> + ), + }; + }, {})} items={items} /> ); @@ -115,171 +124,157 @@ export class FeatureTable extends Component { } }; - private getColumns = (availablePrivileges: string[]) => [ - { - field: 'feature', - name: i18n.translate( - 'xpack.security.management.editRole.featureTable.enabledRoleFeaturesFeatureColumnTitle', - { defaultMessage: 'Feature' } - ), - render: (feature: TableFeature) => { - let tooltipElement = null; - if (feature.privilegesTooltip) { - const tooltipContent = ( - -

{feature.privilegesTooltip}

-
- ); - tooltipElement = ( - { + const basePrivileges = this.props.kibanaPrivileges.getBasePrivileges( + this.props.role.kibana[this.props.privilegeIndex] + ); + + const columns = []; + + if (this.props.canCustomizeSubFeaturePrivileges) { + columns.push({ + width: '30px', + isExpander: true, + field: 'featureId', + name: '', + render: (featureId: string, record: TableRow) => { + const { feature } = record; + const hasSubFeaturePrivileges = feature.getSubFeaturePrivileges().length > 0; + if (!hasSubFeaturePrivileges) { + return null; + } + return ( + this.toggleExpandedFeature(featureId)} + data-test-subj={`expandFeaturePrivilegeRow expandFeaturePrivilegeRow-${featureId}`} + aria-label={this.state.expandedFeatures.includes(featureId) ? 'Collapse' : 'Expand'} + iconType={this.state.expandedFeatures.includes(featureId) ? 'arrowUp' : 'arrowDown'} /> ); - } + }, + }); + } - return ( - - - {feature.name} {tooltipElement} - - ); + columns.push( + { + field: 'feature', + width: '200px', + name: i18n.translate( + 'xpack.security.management.editRole.featureTable.enabledRoleFeaturesFeatureColumnTitle', + { + defaultMessage: 'Feature', + } + ), + render: (feature: SecuredFeature) => { + return ; + }, }, - }, - { - field: 'privilege', - name: ( - - - {!this.props.disabled && ( - - )} - - ), - render: (roleEntry: Role, record: TableRow) => { - const { id: featureId, name: featureName, reserved, privileges } = record.feature; - - if (reserved && Object.keys(privileges).length === 0) { - return {reserved.description}; - } - - const featurePrivileges = this.props.kibanaPrivileges - .getFeaturePrivileges() - .getPrivileges(featureId); - - if (featurePrivileges.length === 0) { - return null; - } - - const enabledFeaturePrivileges = this.getEnabledFeaturePrivileges( - featurePrivileges, - featureId - ); - - const privilegeExplanation = this.getPrivilegeExplanation(featureId); - - const allowsNone = this.allowsNoneForPrivilegeAssignment(featureId); - - const actualPrivilegeValue = privilegeExplanation.actualPrivilege; - - const canChangePrivilege = - !this.props.disabled && (allowsNone || enabledFeaturePrivileges.length > 1); - - if (!canChangePrivilege) { - const assignedBasePrivilege = - this.props.role.kibana[this.props.spacesIndex].base.length > 0; - - const excludedFromBasePrivilegsTooltip = ( + { + field: 'privilege', + width: '200px', + name: ( + + {!this.props.disabled && ( + + )} + + ), + mobileOptions: { + // Table isn't responsive, so skip rendering this for mobile. isn't free... + header: false, + }, + render: (roleEntry: Role, record: TableRow) => { + const { feature } = record; + + if (feature.reserved) { + return {feature.reserved.description}; + } + + const primaryFeaturePrivileges = feature.getPrimaryFeaturePrivileges(); + + if (primaryFeaturePrivileges.length === 0) { + return null; + } + + const selectedPrivilegeId = this.props.privilegeCalculator.getDisplayedPrimaryFeaturePrivilegeId( + feature.id, + this.props.privilegeIndex ); + const options = primaryFeaturePrivileges.map(privilege => { + return { + id: `${feature.id}_${privilege.id}`, + label: privilege.name, + isDisabled: this.props.disabled, + }; + }); + + options.push({ + id: `${feature.id}_${NO_PRIVILEGE_VALUE}`, + label: 'None', + isDisabled: this.props.disabled, + }); + + let warningIcon = ; + if ( + this.props.privilegeCalculator.hasCustomizedSubFeaturePrivileges( + feature.id, + this.props.privilegeIndex + ) + ) { + warningIcon = ( + + } + /> + ); + } + return ( - + + {warningIcon} + + + + ); - } - - const options = availablePrivileges.map(priv => { - return { - id: `${featureId}_${priv}`, - label: _.capitalize(priv), - isDisabled: !enabledFeaturePrivileges.includes(priv), - }; - }); - - options.push({ - id: `${featureId}_${NO_PRIVILEGE_VALUE}`, - label: 'None', - isDisabled: !allowsNone, - }); - - return ( - - ); - }, - }, - ]; - - private getEnabledFeaturePrivileges = (featurePrivileges: string[], featureId: string) => { - const { allowedPrivileges } = this.props; - - if (this.isConfiguringGlobalPrivileges()) { - // Global feature privileges are not limited by effective privileges. - return featurePrivileges; - } - - const allowedFeaturePrivileges = allowedPrivileges.feature[featureId]; - if (allowedFeaturePrivileges == null) { - throw new Error('Unable to get enabled feature privileges for a feature without privileges'); - } - - return allowedFeaturePrivileges.privileges; - }; - - private getPrivilegeExplanation = (featureId: string): PrivilegeExplanation => { - const { calculatedPrivileges } = this.props; - const calculatedFeaturePrivileges = calculatedPrivileges.feature[featureId]; - if (calculatedFeaturePrivileges == null) { - throw new Error('Unable to get privilege explanation for a feature without privileges'); - } - - return calculatedFeaturePrivileges; + }, + } + ); + return columns; }; - private allowsNoneForPrivilegeAssignment = (featureId: string): boolean => { - const { allowedPrivileges } = this.props; - const allowedFeaturePrivileges = allowedPrivileges.feature[featureId]; - if (allowedFeaturePrivileges == null) { - throw new Error('Unable to determine if none is allowed for a feature without privileges'); + private toggleExpandedFeature = (featureId: string) => { + if (this.state.expandedFeatures.includes(featureId)) { + this.setState({ + expandedFeatures: this.state.expandedFeatures.filter(ef => ef !== featureId), + }); + } else { + this.setState({ + expandedFeatures: [...this.state.expandedFeatures, featureId], + }); } - - return allowedFeaturePrivileges.canUnassign; }; private onChangeAllFeaturePrivileges = (privilege: string) => { @@ -289,7 +284,4 @@ export class FeatureTable extends Component { this.props.onChangeAll([privilege]); } }; - - private isConfiguringGlobalPrivileges = () => - isGlobalPrivilegeDefinition(this.props.role.kibana[this.props.spacesIndex]); } diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.test.tsx new file mode 100644 index 0000000000000..8897d89a39926 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.test.tsx @@ -0,0 +1,199 @@ +/* + * 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 { Role } from '../../../../../../../common/model'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { FeatureTableExpandedRow } from './feature_table_expanded_row'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { act } from '@testing-library/react'; + +const createRole = (kibana: Role['kibana'] = []): Role => { + return { + name: 'my_role', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana, + }; +}; + +describe('FeatureTableExpandedRow', () => { + it('indicates sub-feature privileges are being customized if a minimal feature privilege is set', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + + const wrapper = mountWithIntl( + + ); + + expect( + wrapper.find('EuiSwitch[data-test-subj="customizeSubFeaturePrivileges"]').props() + ).toMatchObject({ + disabled: false, + checked: true, + }); + }); + + it('indicates sub-feature privileges are not being customized if a primary feature privilege is set', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + + const wrapper = mountWithIntl( + + ); + + expect( + wrapper.find('EuiSwitch[data-test-subj="customizeSubFeaturePrivileges"]').props() + ).toMatchObject({ + disabled: false, + checked: false, + }); + }); + + it('does not allow customizing if a primary privilege is not set', () => { + const role = createRole([ + { + base: [], + feature: {}, + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + + const wrapper = mountWithIntl( + + ); + + expect( + wrapper.find('EuiSwitch[data-test-subj="customizeSubFeaturePrivileges"]').props() + ).toMatchObject({ + disabled: true, + checked: false, + }); + }); + + it('switches to the minimal privilege when customizing privileges, including corresponding sub-feature privileges', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + act(() => { + findTestSubject(wrapper, 'customizeSubFeaturePrivileges').simulate('click'); + }); + + expect(onChange).toHaveBeenCalledWith('with_sub_features', [ + 'minimal_read', + 'cool_read', + 'cool_toggle_2', + ]); + }); + + it('switches to the primary privilege when not customizing privileges, removing any other privileges', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_read', 'cool_toggle_2'], + }, + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + act(() => { + findTestSubject(wrapper, 'customizeSubFeaturePrivileges').simulate('click'); + }); + + expect(onChange).toHaveBeenCalledWith('with_sub_features', ['read']); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.tsx new file mode 100644 index 0000000000000..fb302c2269485 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table_expanded_row.tsx @@ -0,0 +1,95 @@ +/* + * 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, { useState, useEffect } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexItem, EuiFlexGroup, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; +import { SubFeatureForm } from './sub_feature_form'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { SecuredFeature } from '../../../../model'; + +interface Props { + feature: SecuredFeature; + privilegeCalculator: PrivilegeFormCalculator; + privilegeIndex: number; + selectedFeaturePrivileges: string[]; + disabled?: boolean; + onChange: (featureId: string, featurePrivileges: string[]) => void; +} + +export const FeatureTableExpandedRow = ({ + feature, + onChange, + privilegeIndex, + privilegeCalculator, + selectedFeaturePrivileges, + disabled, +}: Props) => { + const [isCustomizing, setIsCustomizing] = useState(() => { + return feature + .getMinimalFeaturePrivileges() + .some(p => selectedFeaturePrivileges.includes(p.id)); + }); + + useEffect(() => { + const hasMinimalFeaturePrivilegeSelected = feature + .getMinimalFeaturePrivileges() + .some(p => selectedFeaturePrivileges.includes(p.id)); + + if (!hasMinimalFeaturePrivilegeSelected && isCustomizing) { + setIsCustomizing(false); + } + }, [feature, isCustomizing, selectedFeaturePrivileges]); + + const onCustomizeSubFeatureChange = (e: EuiSwitchEvent) => { + onChange( + feature.id, + privilegeCalculator.updateSelectedFeaturePrivilegesForCustomization( + feature.id, + privilegeIndex, + e.target.checked + ) + ); + setIsCustomizing(e.target.checked); + }; + + return ( + + + + } + checked={isCustomizing} + onChange={onCustomizeSubFeatureChange} + data-test-subj="customizeSubFeaturePrivileges" + disabled={ + disabled || + !privilegeCalculator.canCustomizeSubFeaturePrivileges(feature.id, privilegeIndex) + } + /> + + {feature.getSubFeatures().map(subFeature => { + return ( + + onChange(feature.id, updatedPrivileges)} + selectedFeaturePrivileges={selectedFeaturePrivileges} + disabled={disabled || !isCustomizing} + /> + + ); + })} + + ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx new file mode 100644 index 0000000000000..ba7eff601f4c1 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.test.tsx @@ -0,0 +1,237 @@ +/* + * 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 { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { SecuredSubFeature } from '../../../../model'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { Role } from '../../../../../../../common/model'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { SubFeatureForm } from './sub_feature_form'; +import { EuiCheckbox, EuiButtonGroup } from '@elastic/eui'; +import { act } from '@testing-library/react'; + +// Note: these tests are not concerned with the proper display of privileges, +// as that is verified by the feature_table and privilege_space_form tests. + +const createRole = (kibana: Role['kibana'] = []): Role => { + return { + name: 'my_role', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana, + }; +}; + +const featureId = 'with_sub_features'; +const subFeature = kibanaFeatures.find(kf => kf.id === featureId)!.subFeatures[0]; +const securedSubFeature = new SecuredSubFeature(subFeature.toRaw()); + +describe('SubFeatureForm', () => { + it('renders disabled elements when requested', () => { + const role = createRole([ + { + base: [], + feature: {}, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const wrapper = mountWithIntl( + + ); + + const checkboxes = wrapper.find(EuiCheckbox); + const buttonGroups = wrapper.find(EuiButtonGroup); + + expect(checkboxes.everyWhere(checkbox => checkbox.props().disabled === true)).toBe(true); + expect(buttonGroups.everyWhere(checkbox => checkbox.props().isDisabled === true)).toBe(true); + }); + + it('fires onChange when an independent privilege is selected', () => { + const role = createRole([ + { + base: [], + feature: {}, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + const checkbox = wrapper.find('EuiCheckbox[id="with_sub_features_cool_toggle_1"] input'); + + act(() => { + checkbox.simulate('change', { target: { checked: true } }); + }); + + expect(onChange).toHaveBeenCalledWith(['cool_toggle_1']); + }); + + it('fires onChange when an independent privilege is deselected', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['cool_toggle_1', 'cool_toggle_2'], + }, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + const checkbox = wrapper.find('EuiCheckbox[id="with_sub_features_cool_toggle_1"] input'); + + act(() => { + checkbox.simulate('change', { target: { checked: false } }); + }); + + expect(onChange).toHaveBeenCalledWith(['cool_toggle_2']); + }); + + it('fires onChange when a mutually exclusive privilege is selected', () => { + const role = createRole([ + { + base: [], + feature: {}, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + const button = wrapper.find(EuiButtonGroup); + + act(() => { + button.props().onChange('cool_all'); + }); + + expect(onChange).toHaveBeenCalledWith(['cool_all']); + }); + + it('fires onChange when switching between mutually exclusive options', () => { + const role = createRole([ + { + base: [], + feature: {}, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + const button = wrapper.find(EuiButtonGroup); + + act(() => { + button.props().onChange('cool_read'); + }); + + expect(onChange).toHaveBeenCalledWith(['cool_toggle_1', 'cool_read']); + }); + + it('fires onChange when a mutually exclusive privilege is deselected', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['cool_all'], + }, + spaces: [], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + const button = wrapper.find(EuiButtonGroup); + + act(() => { + button.props().onChange('none'); + }); + + expect(onChange).toHaveBeenCalledWith([]); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx new file mode 100644 index 0000000000000..d4b6721ddad05 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/sub_feature_form.tsx @@ -0,0 +1,134 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiText, EuiCheckbox, EuiButtonGroup } from '@elastic/eui'; + +import { NO_PRIVILEGE_VALUE } from '../constants'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { + SecuredSubFeature, + SubFeaturePrivilegeGroup, + SubFeaturePrivilege, +} from '../../../../model'; + +interface Props { + featureId: string; + subFeature: SecuredSubFeature; + selectedFeaturePrivileges: string[]; + privilegeCalculator: PrivilegeFormCalculator; + privilegeIndex: number; + onChange: (selectedPrivileges: string[]) => void; + disabled?: boolean; +} + +export const SubFeatureForm = (props: Props) => { + return ( + + + {props.subFeature.name} + + {props.subFeature.getPrivilegeGroups().map(renderPrivilegeGroup)} + + ); + + function renderPrivilegeGroup(privilegeGroup: SubFeaturePrivilegeGroup, index: number) { + switch (privilegeGroup.groupType) { + case 'independent': + return renderIndependentPrivilegeGroup(privilegeGroup, index); + case 'mutually_exclusive': + return renderMutuallyExclusivePrivilegeGroup(privilegeGroup, index); + default: + throw new Error(`Unsupported privilege group type: ${privilegeGroup.groupType}`); + } + } + + function renderIndependentPrivilegeGroup( + privilegeGroup: SubFeaturePrivilegeGroup, + index: number + ) { + return ( +
+ {privilegeGroup.privileges.map((privilege: SubFeaturePrivilege) => { + const isGranted = props.privilegeCalculator.isIndependentSubFeaturePrivilegeGranted( + props.featureId, + privilege.id, + props.privilegeIndex + ); + return ( + { + const { checked } = e.target; + if (checked) { + props.onChange([...props.selectedFeaturePrivileges, privilege.id]); + } else { + props.onChange(props.selectedFeaturePrivileges.filter(sp => sp !== privilege.id)); + } + }} + checked={isGranted} + disabled={props.disabled} + compressed={true} + /> + ); + })} +
+ ); + } + + function renderMutuallyExclusivePrivilegeGroup( + privilegeGroup: SubFeaturePrivilegeGroup, + index: number + ) { + const firstSelectedPrivilege = props.privilegeCalculator.getSelectedMutuallyExclusiveSubFeaturePrivilege( + props.featureId, + privilegeGroup, + props.privilegeIndex + ); + + const options = [ + ...privilegeGroup.privileges.map((privilege, privilegeIndex) => { + return { + id: privilege.id, + label: privilege.name, + isDisabled: props.disabled, + }; + }), + ]; + + options.push({ + id: NO_PRIVILEGE_VALUE, + label: 'None', + isDisabled: props.disabled, + }); + + return ( + { + // Deselect all privileges which belong to this mutually-exclusive group + const privilegesWithoutGroupEntries = props.selectedFeaturePrivileges.filter( + sp => !privilegeGroup.privileges.some(privilege => privilege.id === sp) + ); + // fire on-change with the newly selected privilege + if (selectedPrivilegeId === NO_PRIVILEGE_VALUE) { + props.onChange(privilegesWithoutGroupEntries); + } else { + props.onChange([...privilegesWithoutGroupEntries, selectedPrivilegeId]); + } + }} + /> + ); + } +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.test.tsx new file mode 100644 index 0000000000000..316818e4deed3 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.test.tsx @@ -0,0 +1,60 @@ +/* + * 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 { createFeature } from '../../../../__fixtures__/kibana_features'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { FeatureTableCell } from '.'; +import { SecuredFeature } from '../../../../model'; +import { EuiIcon, EuiIconTip } from '@elastic/eui'; + +describe('FeatureTableCell', () => { + it('renders an icon and feature name', () => { + const feature = createFeature({ + id: 'test-feature', + name: 'Test Feature', + }); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.text()).toMatchInlineSnapshot(`"Test Feature "`); + expect(wrapper.find(EuiIcon).props()).toMatchObject({ + type: feature.icon, + }); + expect(wrapper.find(EuiIconTip)).toHaveLength(0); + }); + + it('renders an icon and feature name with tooltip when configured', () => { + const feature = createFeature({ + id: 'test-feature', + name: 'Test Feature', + privilegesTooltip: 'This is my awesome tooltip content', + }); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.text()).toMatchInlineSnapshot(`"Test Feature "`); + expect( + wrapper + .find(EuiIcon) + .first() + .props() + ).toMatchObject({ + type: feature.icon, + }); + expect(wrapper.find(EuiIconTip).props().content).toMatchInlineSnapshot(` + +

+ This is my awesome tooltip content +

+
+ `); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.tsx new file mode 100644 index 0000000000000..9e4a3a8a99b56 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/feature_table_cell.tsx @@ -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 React from 'react'; +import { EuiText, EuiIconTip, EuiIcon, IconType } from '@elastic/eui'; +import { SecuredFeature } from '../../../../model'; + +interface Props { + feature: SecuredFeature; +} + +export const FeatureTableCell = ({ feature }: Props) => { + let tooltipElement = null; + if (feature.getPrivilegesTooltip()) { + const tooltipContent = ( + +

{feature.getPrivilegesTooltip()}

+
+ ); + tooltipElement = ( + + ); + } + + return ( + + + {feature.name} {tooltipElement} + + ); +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/components/index.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/index.ts similarity index 81% rename from x-pack/plugins/dashboard_enhanced/public/components/index.ts rename to x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/index.ts index b9a64a3cc17e6..8f084fcc37c50 100644 --- a/x-pack/plugins/dashboard_enhanced/public/components/index.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table_cell/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './dashboard_drilldown_config'; +export { FeatureTableCell } from './feature_table_cell'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/build_role.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/build_role.ts deleted file mode 100644 index 70e48dcdc37f8..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/build_role.ts +++ /dev/null @@ -1,38 +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 { FeaturesPrivileges, Role } from '../../../../../../../../common/model'; - -export interface BuildRoleOpts { - spacesPrivileges?: Array<{ - spaces: string[]; - base: string[]; - feature: FeaturesPrivileges; - }>; -} - -export const buildRole = (options: BuildRoleOpts = {}) => { - const role: Role = { - name: 'unit test role', - elasticsearch: { - indices: [], - cluster: [], - run_as: [], - }, - kibana: [], - }; - - if (options.spacesPrivileges) { - role.kibana.push(...options.spacesPrivileges); - } else { - role.kibana.push({ - spaces: [], - base: [], - feature: {}, - }); - } - - return role; -}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/common_allowed_privileges.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/common_allowed_privileges.ts deleted file mode 100644 index ddab7eff6835e..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/common_allowed_privileges.ts +++ /dev/null @@ -1,60 +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. - */ - -export const unrestrictedBasePrivileges = { - base: { - privileges: ['all', 'read'], - canUnassign: true, - }, -}; -export const unrestrictedFeaturePrivileges = { - feature: { - feature1: { - privileges: ['all', 'read'], - canUnassign: true, - }, - feature2: { - privileges: ['all', 'read'], - canUnassign: true, - }, - feature3: { - privileges: ['all'], - canUnassign: true, - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: true, - }, - }, -}; - -export const fullyRestrictedBasePrivileges = { - base: { - privileges: ['all'], - canUnassign: false, - }, -}; - -export const fullyRestrictedFeaturePrivileges = { - feature: { - feature1: { - privileges: ['all'], - canUnassign: false, - }, - feature2: { - privileges: ['all'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: false, - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, -}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/default_privilege_definition.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/default_privilege_definition.ts deleted file mode 100644 index 0c794b68f95da..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/default_privilege_definition.ts +++ /dev/null @@ -1,43 +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 { KibanaPrivileges } from '../../../../../../../../common/model'; - -export const defaultPrivilegeDefinition = new KibanaPrivileges({ - global: { - all: ['api:/*', 'ui:/*'], - read: ['ui:/feature1/foo', 'ui:/feature2/foo', 'ui:/feature3/foo/*', 'ui:/feature4/foo'], - }, - space: { - all: [ - 'api:/feature1/*', - 'ui:/feature1/*', - 'api:/feature2/*', - 'ui:/feature2/*', - 'ui:/feature3/foo', - 'ui:/feature3/foo/*', - 'ui:/feature4/foo', - ], - read: ['ui:/feature1/foo', 'ui:/feature2/foo', 'ui:/feature3/foo/bar', 'ui:/feature4/foo'], - }, - features: { - feature1: { - all: ['ui:/feature1/foo', 'ui:/feature1/bar'], - read: ['ui:/feature1/foo'], - }, - feature2: { - all: ['ui:/feature2/foo', 'api:/feature2/bar'], - read: ['ui:/feature2/foo'], - }, - feature3: { - all: ['ui:/feature3/foo', 'ui:/feature3/foo/*'], - }, - feature4: { - all: ['somethingObscure:/feature4/foo', 'ui:/feature4/foo'], - read: ['ui:/feature4/foo'], - }, - }, - reserved: {}, -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/index.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/index.ts deleted file mode 100644 index 253dcaed9f19e..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/__fixtures__/index.ts +++ /dev/null @@ -1,9 +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. - */ - -export { defaultPrivilegeDefinition } from './default_privilege_definition'; -export { buildRole, BuildRoleOpts } from './build_role'; -export * from './common_allowed_privileges'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/index.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/index.ts deleted file mode 100644 index 056a4d3022fc5..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/index.ts +++ /dev/null @@ -1,8 +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. - */ - -export { KibanaPrivilegeCalculatorFactory } from './kibana_privileges_calculator_factory'; -export * from './kibana_privilege_calculator_types'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.test.ts deleted file mode 100644 index 2a1c42838a83d..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.test.ts +++ /dev/null @@ -1,313 +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 { KibanaPrivileges, Role } from '../../../../../../../common/model'; -import { - buildRole, - defaultPrivilegeDefinition, - fullyRestrictedBasePrivileges, - fullyRestrictedFeaturePrivileges, - unrestrictedBasePrivileges, - unrestrictedFeaturePrivileges, -} from './__fixtures__'; -import { KibanaAllowedPrivilegesCalculator } from './kibana_allowed_privileges_calculator'; -import { KibanaPrivilegeCalculatorFactory } from './kibana_privileges_calculator_factory'; - -const buildAllowedPrivilegesCalculator = ( - role: Role, - kibanaPrivilege: KibanaPrivileges = defaultPrivilegeDefinition -) => { - return new KibanaAllowedPrivilegesCalculator(kibanaPrivilege, role); -}; - -const buildEffectivePrivilegesCalculator = ( - role: Role, - kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition -) => { - const factory = new KibanaPrivilegeCalculatorFactory(kibanaPrivileges); - return factory.getInstance(role); -}; - -describe('AllowedPrivileges', () => { - it('allows all privileges when none are currently assigned', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivilegesCalculator(role); - const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); - - const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( - effectivePrivileges.calculateEffectivePrivileges(true) - ); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...unrestrictedFeaturePrivileges, - }, - { - ...unrestrictedBasePrivileges, - ...unrestrictedFeaturePrivileges, - }, - ]); - }); - - it('allows all global base privileges, but just "all" for everything else when global is set to "all"', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivilegesCalculator(role); - const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); - - const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( - effectivePrivileges.calculateEffectivePrivileges(true) - ); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...fullyRestrictedFeaturePrivileges, - }, - { - ...fullyRestrictedBasePrivileges, - ...fullyRestrictedFeaturePrivileges, - }, - ]); - }); - - it(`allows feature privileges to be set to "all" or "read" when global base is "read"`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivilegesCalculator(role); - const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); - - const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( - effectivePrivileges.calculateEffectivePrivileges(true) - ); - - const expectedFeaturePrivileges = { - feature: { - feature1: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature2: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: true, // feature 3 has no "read" privilege governed by global "all" - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }; - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...expectedFeaturePrivileges, - }, - { - base: { - privileges: ['all', 'read'], - canUnassign: false, - }, - ...expectedFeaturePrivileges, - }, - ]); - }); - - it(`allows feature privileges to be set to "all" or "read" when space base is "read"`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: {}, - }, - { - spaces: ['foo'], - base: ['read'], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivilegesCalculator(role); - const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); - - const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( - effectivePrivileges.calculateEffectivePrivileges(true) - ); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...unrestrictedFeaturePrivileges, - }, - { - base: { - privileges: ['all', 'read'], - canUnassign: true, - }, - feature: { - feature1: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature2: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: true, // feature 3 has no "read" privilege governed by space "all" - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }, - ]); - }); - - it(`allows space base privilege to be set to "all" or "read" when space base is already "all"`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['foo'], - base: ['all'], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivilegesCalculator(role); - const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); - - const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( - effectivePrivileges.calculateEffectivePrivileges(true) - ); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - feature: { - feature1: { - privileges: ['all'], - canUnassign: false, - }, - feature2: { - privileges: ['all'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: false, - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }, - ]); - }); - - it(`restricts space feature privileges when global feature privileges are set`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['all'], - feature2: ['read'], - feature4: ['all'], - }, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivilegesCalculator(role); - const allowedPrivilegesCalculator = buildAllowedPrivilegesCalculator(role); - - const result = allowedPrivilegesCalculator.calculateAllowedPrivileges( - effectivePrivileges.calculateEffectivePrivileges(true) - ); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...unrestrictedFeaturePrivileges, - }, - { - base: { - privileges: ['all', 'read'], - canUnassign: true, - }, - feature: { - feature1: { - privileges: ['all'], - canUnassign: false, - }, - feature2: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: true, // feature 3 has no "read" privilege governed by space "all" - }, - feature4: { - privileges: ['all'], - canUnassign: false, - }, - }, - }, - ]); - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts deleted file mode 100644 index cea25649c43ff..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_allowed_privileges_calculator.ts +++ /dev/null @@ -1,155 +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 { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import { - areActionsFullyCovered, - compareActions, -} from '../../../../../../../common/privilege_calculator_utils'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { - AllowedPrivilege, - CalculatedPrivilege, - PRIVILEGE_SOURCE, -} from './kibana_privilege_calculator_types'; - -export class KibanaAllowedPrivilegesCalculator { - // reference to the global privilege definition - private globalPrivilege: RoleKibanaPrivilege; - - // list of privilege actions that comprise the global base privilege - private readonly assignedGlobalBaseActions: string[]; - - constructor(private readonly kibanaPrivileges: KibanaPrivileges, private readonly role: Role) { - this.globalPrivilege = this.locateGlobalPrivilege(role); - this.assignedGlobalBaseActions = this.globalPrivilege.base[0] - ? kibanaPrivileges.getGlobalPrivileges().getActions(this.globalPrivilege.base[0]) - : []; - } - - public calculateAllowedPrivileges( - effectivePrivileges: CalculatedPrivilege[] - ): AllowedPrivilege[] { - const { kibana = [] } = this.role; - return kibana.map((privilegeSpec, index) => - this.calculateAllowedPrivilege(privilegeSpec, effectivePrivileges[index]) - ); - } - - private calculateAllowedPrivilege( - privilegeSpec: RoleKibanaPrivilege, - effectivePrivileges: CalculatedPrivilege - ): AllowedPrivilege { - const result: AllowedPrivilege = { - base: { - privileges: [], - canUnassign: true, - }, - feature: {}, - }; - - if (isGlobalPrivilegeDefinition(privilegeSpec)) { - // nothing can impede global privileges - result.base.canUnassign = true; - result.base.privileges = this.kibanaPrivileges.getGlobalPrivileges().getAllPrivileges(); - } else { - // space base privileges are restricted based on the assigned global privileges - const spacePrivileges = this.kibanaPrivileges.getSpacesPrivileges().getAllPrivileges(); - result.base.canUnassign = this.assignedGlobalBaseActions.length === 0; - result.base.privileges = spacePrivileges.filter(privilege => { - // always allowed to assign the calculated effective privilege - if (privilege === effectivePrivileges.base.actualPrivilege) { - return true; - } - - const privilegeActions = this.getBaseActions(PRIVILEGE_SOURCE.SPACE_BASE, privilege); - return !areActionsFullyCovered(this.assignedGlobalBaseActions, privilegeActions); - }); - } - - const allFeaturePrivileges = this.kibanaPrivileges.getFeaturePrivileges().getAllPrivileges(); - result.feature = Object.entries(allFeaturePrivileges).reduce( - (acc, [featureId, featurePrivileges]) => { - return { - ...acc, - [featureId]: this.getAllowedFeaturePrivileges( - effectivePrivileges, - featureId, - featurePrivileges - ), - }; - }, - {} - ); - - return result; - } - - private getAllowedFeaturePrivileges( - effectivePrivileges: CalculatedPrivilege, - featureId: string, - candidateFeaturePrivileges: string[] - ): { privileges: string[]; canUnassign: boolean } { - const effectiveFeaturePrivilegeExplanation = effectivePrivileges.feature[featureId]; - if (effectiveFeaturePrivilegeExplanation == null) { - throw new Error('To calculate allowed feature privileges, we need the effective privileges'); - } - - const effectiveFeatureActions = this.getFeatureActions( - featureId, - effectiveFeaturePrivilegeExplanation.actualPrivilege - ); - - const privileges = []; - if (effectiveFeaturePrivilegeExplanation.actualPrivilege !== NO_PRIVILEGE_VALUE) { - // Always allowed to assign the calculated effective privilege - privileges.push(effectiveFeaturePrivilegeExplanation.actualPrivilege); - } - - privileges.push( - ...candidateFeaturePrivileges.filter(privilegeId => { - const candidateActions = this.getFeatureActions(featureId, privilegeId); - return compareActions(effectiveFeatureActions, candidateActions) > 0; - }) - ); - - const result = { - privileges: privileges.sort(), - canUnassign: effectiveFeaturePrivilegeExplanation.actualPrivilege === NO_PRIVILEGE_VALUE, - }; - - return result; - } - - private getBaseActions(source: PRIVILEGE_SOURCE, privilegeId: string) { - switch (source) { - case PRIVILEGE_SOURCE.GLOBAL_BASE: - return this.assignedGlobalBaseActions; - case PRIVILEGE_SOURCE.SPACE_BASE: - return this.kibanaPrivileges.getSpacesPrivileges().getActions(privilegeId); - default: - throw new Error( - `Cannot get base actions for unsupported privilege source ${PRIVILEGE_SOURCE[source]}` - ); - } - } - - private getFeatureActions(featureId: string, privilegeId: string): string[] { - return this.kibanaPrivileges.getFeaturePrivileges().getActions(featureId, privilegeId); - } - - private locateGlobalPrivilege(role: Role) { - const spacePrivileges = role.kibana; - return ( - spacePrivileges.find(privileges => isGlobalPrivilegeDefinition(privileges)) || { - spaces: [] as string[], - base: [] as string[], - feature: {}, - } - ); - } -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.test.ts deleted file mode 100644 index 8d30061b92c6f..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.test.ts +++ /dev/null @@ -1,321 +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 { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { buildRole, defaultPrivilegeDefinition } from './__fixtures__'; -import { KibanaBasePrivilegeCalculator } from './kibana_base_privilege_calculator'; -import { PRIVILEGE_SOURCE, PrivilegeExplanation } from './kibana_privilege_calculator_types'; - -const buildEffectiveBasePrivilegeCalculator = ( - role: Role, - kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition -) => { - const globalPrivilegeSpec = - role.kibana.find(k => isGlobalPrivilegeDefinition(k)) || - ({ - spaces: ['*'], - base: [], - feature: {}, - } as RoleKibanaPrivilege); - - const globalActions = globalPrivilegeSpec.base[0] - ? kibanaPrivileges.getGlobalPrivileges().getActions(globalPrivilegeSpec.base[0]) - : []; - - return new KibanaBasePrivilegeCalculator(kibanaPrivileges, globalPrivilegeSpec, globalActions); -}; - -describe('getMostPermissiveBasePrivilege', () => { - describe('without ignoring assigned', () => { - it('returns "none" when no privileges are granted', () => { - const role = buildRole(); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[0], - false - ); - - expect(result).toEqual({ - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - } as PrivilegeExplanation); - }); - - defaultPrivilegeDefinition - .getGlobalPrivileges() - .getAllPrivileges() - .forEach(globalBasePrivilege => { - it(`returns "${globalBasePrivilege}" when assigned directly at the global privilege`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [globalBasePrivilege], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[0], - false - ); - - expect(result).toEqual({ - actualPrivilege: globalBasePrivilege, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - } as PrivilegeExplanation); - }); - }); - - defaultPrivilegeDefinition - .getSpacesPrivileges() - .getAllPrivileges() - .forEach(spaceBasePrivilege => { - it(`returns "${spaceBasePrivilege}" when assigned directly at the space base privilege`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['foo'], - base: [spaceBasePrivilege], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[0], - false - ); - - expect(result).toEqual({ - actualPrivilege: spaceBasePrivilege, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: true, - } as PrivilegeExplanation); - }); - }); - - it('returns the global privilege when no space base is defined', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[1], - false - ); - - expect(result).toEqual({ - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - } as PrivilegeExplanation); - }); - - it('returns the global privilege when it supercedes the space privilege', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['foo'], - base: ['read'], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[1], - false - ); - - expect(result).toEqual({ - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - } as PrivilegeExplanation); - }); - }); - - describe('ignoring assigned', () => { - it('returns "none" when no privileges are granted', () => { - const role = buildRole(); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[0], - true - ); - - expect(result).toEqual({ - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - } as PrivilegeExplanation); - }); - - defaultPrivilegeDefinition - .getGlobalPrivileges() - .getAllPrivileges() - .forEach(globalBasePrivilege => { - it(`returns "none" when "${globalBasePrivilege}" assigned directly at the global privilege`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [globalBasePrivilege], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[0], - true - ); - - expect(result).toEqual({ - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - } as PrivilegeExplanation); - }); - }); - - defaultPrivilegeDefinition - .getSpacesPrivileges() - .getAllPrivileges() - .forEach(spaceBasePrivilege => { - it(`returns "none" when "${spaceBasePrivilege}" when assigned directly at the space base privilege`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['foo'], - base: [spaceBasePrivilege], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[0], - true - ); - - expect(result).toEqual({ - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: true, - } as PrivilegeExplanation); - }); - }); - - it('returns the global privilege when no space base is defined', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[1], - true - ); - - expect(result).toEqual({ - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - } as PrivilegeExplanation); - }); - - it('returns the global privilege when it supercedes the space privilege, without indicating override', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['foo'], - base: ['read'], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[1], - true - ); - - expect(result).toEqual({ - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - } as PrivilegeExplanation); - }); - - it('returns the global privilege even though it would ordinarly be overriden by space base privilege', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['foo'], - base: ['all'], - feature: {}, - }, - ], - }); - const effectiveBasePrivilegesCalculator = buildEffectiveBasePrivilegeCalculator(role); - const result: PrivilegeExplanation = effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - role.kibana[1], - true - ); - - expect(result).toEqual({ - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - } as PrivilegeExplanation); - }); - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.ts deleted file mode 100644 index 9fefea637e168..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_base_privilege_calculator.ts +++ /dev/null @@ -1,98 +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 { KibanaPrivileges, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import { compareActions } from '../../../../../../../common/privilege_calculator_utils'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { PRIVILEGE_SOURCE, PrivilegeExplanation } from './kibana_privilege_calculator_types'; - -export class KibanaBasePrivilegeCalculator { - constructor( - private readonly kibanaPrivileges: KibanaPrivileges, - private readonly globalPrivilege: RoleKibanaPrivilege, - private readonly assignedGlobalBaseActions: string[] - ) {} - - public getMostPermissiveBasePrivilege( - privilegeSpec: RoleKibanaPrivilege, - ignoreAssigned: boolean - ): PrivilegeExplanation { - const assignedPrivilege = privilegeSpec.base[0] || NO_PRIVILEGE_VALUE; - - // If this is the global privilege definition, then there is nothing to supercede it. - if (isGlobalPrivilegeDefinition(privilegeSpec)) { - if (assignedPrivilege === NO_PRIVILEGE_VALUE || ignoreAssigned) { - return { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }; - } - return { - actualPrivilege: assignedPrivilege, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }; - } - - // Otherwise, check to see if the global privilege supercedes this one. - const baseActions = [ - ...this.kibanaPrivileges.getSpacesPrivileges().getActions(assignedPrivilege), - ]; - - const globalSupercedes = - this.hasAssignedGlobalBasePrivilege() && - (compareActions(this.assignedGlobalBaseActions, baseActions) < 0 || ignoreAssigned); - - if (globalSupercedes) { - const wasDirectlyAssigned = !ignoreAssigned && baseActions.length > 0; - - return { - actualPrivilege: this.globalPrivilege.base[0], - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - ...this.buildSupercededFields( - wasDirectlyAssigned, - assignedPrivilege, - PRIVILEGE_SOURCE.SPACE_BASE - ), - }; - } - - if (!ignoreAssigned) { - return { - actualPrivilege: assignedPrivilege, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: true, - }; - } - - return { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: true, - }; - } - - private hasAssignedGlobalBasePrivilege() { - return this.assignedGlobalBaseActions.length > 0; - } - - private buildSupercededFields( - isSuperceding: boolean, - supersededPrivilege?: string, - supersededPrivilegeSource?: PRIVILEGE_SOURCE - ) { - if (!isSuperceding) { - return {}; - } - return { - supersededPrivilege, - supersededPrivilegeSource, - }; - } -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.test.ts deleted file mode 100644 index 887fffa1b0cbc..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.test.ts +++ /dev/null @@ -1,959 +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 { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { buildRole, BuildRoleOpts, defaultPrivilegeDefinition } from './__fixtures__'; -import { KibanaBasePrivilegeCalculator } from './kibana_base_privilege_calculator'; -import { KibanaFeaturePrivilegeCalculator } from './kibana_feature_privilege_calculator'; -import { PRIVILEGE_SOURCE } from './kibana_privilege_calculator_types'; - -const buildEffectiveBasePrivilegeCalculator = ( - role: Role, - kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition -) => { - const globalPrivilegeSpec = - role.kibana.find(k => isGlobalPrivilegeDefinition(k)) || - ({ - spaces: ['*'], - base: [], - feature: {}, - } as RoleKibanaPrivilege); - - const globalActions = globalPrivilegeSpec.base[0] - ? kibanaPrivileges.getGlobalPrivileges().getActions(globalPrivilegeSpec.base[0]) - : []; - - return new KibanaBasePrivilegeCalculator(kibanaPrivileges, globalPrivilegeSpec, globalActions); -}; - -const buildEffectiveFeaturePrivilegeCalculator = ( - role: Role, - kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition -) => { - const globalPrivilegeSpec = - role.kibana.find(k => isGlobalPrivilegeDefinition(k)) || - ({ - spaces: ['*'], - base: [], - feature: {}, - } as RoleKibanaPrivilege); - - const globalActions = globalPrivilegeSpec.base[0] - ? kibanaPrivileges.getGlobalPrivileges().getActions(globalPrivilegeSpec.base[0]) - : []; - - const rankedFeaturePrivileges = kibanaPrivileges.getFeaturePrivileges().getAllPrivileges(); - - return new KibanaFeaturePrivilegeCalculator( - kibanaPrivileges, - globalPrivilegeSpec, - globalActions, - rankedFeaturePrivileges - ); -}; - -interface TestOpts { - only?: boolean; - role?: BuildRoleOpts; - privilegeIndex?: number; - ignoreAssigned?: boolean; - result: Record; - feature?: string; -} - -function runTest( - description: string, - { - role: roleOpts = {}, - result = {}, - privilegeIndex = 0, - ignoreAssigned = false, - only = false, - feature = 'feature1', - }: TestOpts -) { - const fn = only ? it.only : it; - fn(description, () => { - const role = buildRole(roleOpts); - const basePrivilegeCalculator = buildEffectiveBasePrivilegeCalculator(role); - const featurePrivilegeCalculator = buildEffectiveFeaturePrivilegeCalculator(role); - - const baseExplanation = basePrivilegeCalculator.getMostPermissiveBasePrivilege( - role.kibana[privilegeIndex], - // If calculations wish to ignoreAssigned, then we still need to know what the real effective base privilege is - // without ignoring assigned, in order to calculate the correct feature privileges. - false - ); - - const actualResult = featurePrivilegeCalculator.getMostPermissiveFeaturePrivilege( - role.kibana[privilegeIndex], - baseExplanation, - feature, - ignoreAssigned - ); - - expect(actualResult).toEqual(result); - }); -} - -describe('getMostPermissiveFeaturePrivilege', () => { - describe('for global feature privileges, without ignoring assigned', () => { - runTest('returns "none" when no privileges are granted', { - result: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }); - - runTest('returns "read" when assigned directly to the feature', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }); - - runTest('returns "read" when assigned as the global base privilege', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - ], - }, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }); - - runTest( - 'returns "all" when assigned as the global base privilege, which overrides assigned feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: { - feature1: ['read'], - }, - }, - ], - }, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - }, - } - ); - - runTest( - 'returns "all" when assigned as the feature privilege, which does not override assigned global base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: { - feature1: ['all'], - }, - }, - ], - }, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - } - ); - }); - - describe('for global feature privileges, ignoring assigned', () => { - runTest('returns "none" when no privileges are granted', { - ignoreAssigned: true, - result: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }); - - runTest('returns "none" when "read" is assigned directly to the feature', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - ignoreAssigned: true, - result: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }); - - runTest('returns "read" when assigned as the global base privilege', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - ], - }, - ignoreAssigned: true, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }); - - runTest( - 'returns "all" when assigned as the global base privilege, which overrides assigned feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: { - feature1: ['read'], - }, - }, - ], - }, - ignoreAssigned: true, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "read" when "all" assigned as the feature privilege, which does not override assigned global base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: { - feature1: ['all'], - }, - }, - ], - }, - ignoreAssigned: true, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - } - ); - }); - - describe('for space feature privileges, without ignoring assigned', () => { - runTest('returns "none" when no privileges are granted', { - role: { - spacesPrivileges: [ - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - result: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - }, - }); - - runTest('returns "read" when assigned directly to the feature', { - role: { - spacesPrivileges: [ - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: true, - }, - }); - - runTest('returns "read" when assigned as the global base privilege', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }); - - runTest( - 'returns "all" when assigned as the global base privilege, which overrides assigned global feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: { - feature1: ['read'], - }, - }, - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the global base privilege, which overrides assigned space feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - }, - } - ); - - runTest( - 'returns "all" when assigned as the global feature privilege, which does not override assigned global base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: { - feature1: ['all'], - }, - }, - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the space feature privilege, which does not override assigned global base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['all'], - }, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: true, - }, - } - ); - - runTest( - 'returns "all" when assigned as the space base privilege, which does not override assigned global base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['marketing'], - base: ['all'], - feature: {}, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the global base privilege, which overrides assigned space feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - }, - } - ); - - runTest('returns "all" when assigned everywhere, without indicating override', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: { - feature1: ['all'], - }, - }, - { - spaces: ['marketing'], - base: ['all'], - feature: { - feature1: ['all'], - }, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: false, - }, - }); - - runTest('returns "all" when assigned at global feature, overriding space feature', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['all'], - }, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - }, - }); - - describe('feature with "all" excluded from base privileges', () => { - runTest('returns "read" when "all" assigned as the global base privilege', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - feature: 'feature4', - privilegeIndex: 1, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }); - - runTest( - 'returns "read" when "all" assigned as the global base privilege, which does not override assigned space feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature4: ['read'], - }, - }, - ], - }, - feature: 'feature4', - privilegeIndex: 1, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the feature privilege, which is more permissive than the base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature4: ['all'], - }, - }, - ], - }, - feature: 'feature4', - privilegeIndex: 1, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: true, - }, - } - ); - }); - }); - - describe('for space feature privileges, ignoring assigned', () => { - runTest('returns "none" when no privileges are granted', { - role: { - spacesPrivileges: [ - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - ignoreAssigned: true, - result: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - }, - }); - - runTest('returns "none" when "read" assigned directly to the feature', { - role: { - spacesPrivileges: [ - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - ignoreAssigned: true, - result: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - }, - }); - - runTest('returns "read" when assigned as the global base privilege', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }); - - runTest( - 'returns "all" when assigned as the global base privilege, which overrides assigned global feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: { - feature1: ['read'], - }, - }, - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the global base privilege, which normally overrides assigned space feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the global feature privilege, which does not override assigned global base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: { - feature1: ['all'], - }, - }, - { - spaces: ['marketing'], - base: [], - feature: {}, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "read" when "all" assigned as the space feature privilege, which normally overrides assigned global base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['all'], - }, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the space base privilege, which does not override assigned global base privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['marketing'], - base: ['all'], - feature: {}, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: false, - }, - } - ); - - runTest( - 'returns "all" when assigned as the global base privilege, which normally overrides assigned space feature privilege', - { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - } - ); - - runTest('returns "all" when assigned everywhere, without indicating override', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: { - feature1: ['all'], - }, - }, - { - spaces: ['marketing'], - base: ['all'], - feature: { - feature1: ['all'], - }, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: false, - }, - }); - - runTest('returns "all" when assigned at global feature, normally overriding space feature', { - role: { - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['all'], - }, - }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - }, - }, - ], - }, - privilegeIndex: 1, - ignoreAssigned: true, - result: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: false, - }, - }); - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts deleted file mode 100644 index 1ca87871aa892..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_feature_privilege_calculator.ts +++ /dev/null @@ -1,209 +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 { - FeaturesPrivileges, - KibanaPrivileges, - RoleKibanaPrivilege, -} from '../../../../../../../common/model'; -import { areActionsFullyCovered } from '../../../../../../../common/privilege_calculator_utils'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { - PRIVILEGE_SOURCE, - PrivilegeExplanation, - PrivilegeScenario, -} from './kibana_privilege_calculator_types'; - -export class KibanaFeaturePrivilegeCalculator { - constructor( - private readonly kibanaPrivileges: KibanaPrivileges, - private readonly globalPrivilege: RoleKibanaPrivilege, - private readonly assignedGlobalBaseActions: string[], - private readonly rankedFeaturePrivileges: FeaturesPrivileges - ) {} - - public getMostPermissiveFeaturePrivilege( - privilegeSpec: RoleKibanaPrivilege, - basePrivilegeExplanation: PrivilegeExplanation, - featureId: string, - ignoreAssigned: boolean - ): PrivilegeExplanation { - const scenarios = this.buildFeaturePrivilegeScenarios( - privilegeSpec, - basePrivilegeExplanation, - featureId, - ignoreAssigned - ); - - const featurePrivileges = this.rankedFeaturePrivileges[featureId] || []; - - // inspect feature privileges in ranked order (most permissive -> least permissive) - for (const featurePrivilege of featurePrivileges) { - const actions = this.kibanaPrivileges - .getFeaturePrivileges() - .getActions(featureId, featurePrivilege); - - // check if any of the scenarios satisfy the privilege - first one wins. - for (const scenario of scenarios) { - if (areActionsFullyCovered(scenario.actions, actions)) { - return { - actualPrivilege: featurePrivilege, - actualPrivilegeSource: scenario.actualPrivilegeSource, - isDirectlyAssigned: scenario.isDirectlyAssigned, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: - scenario.directlyAssignedFeaturePrivilegeMorePermissiveThanBase, - ...this.buildSupercededFields( - !scenario.isDirectlyAssigned, - scenario.supersededPrivilege, - scenario.supersededPrivilegeSource - ), - }; - } - } - } - - const isGlobal = isGlobalPrivilegeDefinition(privilegeSpec); - return { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: isGlobal - ? PRIVILEGE_SOURCE.GLOBAL_FEATURE - : PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - }; - } - - private buildFeaturePrivilegeScenarios( - privilegeSpec: RoleKibanaPrivilege, - basePrivilegeExplanation: PrivilegeExplanation, - featureId: string, - ignoreAssigned: boolean - ): PrivilegeScenario[] { - const scenarios: PrivilegeScenario[] = []; - - const isGlobalPrivilege = isGlobalPrivilegeDefinition(privilegeSpec); - - const assignedGlobalFeaturePrivilege = this.getAssignedFeaturePrivilege( - this.globalPrivilege, - featureId - ); - - const assignedFeaturePrivilege = this.getAssignedFeaturePrivilege(privilegeSpec, featureId); - const hasAssignedFeaturePrivilege = - !ignoreAssigned && assignedFeaturePrivilege !== NO_PRIVILEGE_VALUE; - - scenarios.push({ - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - actions: [...this.assignedGlobalBaseActions], - ...this.buildSupercededFields( - hasAssignedFeaturePrivilege, - assignedFeaturePrivilege, - isGlobalPrivilege ? PRIVILEGE_SOURCE.GLOBAL_FEATURE : PRIVILEGE_SOURCE.SPACE_FEATURE - ), - }); - - if (!isGlobalPrivilege || !ignoreAssigned) { - scenarios.push({ - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - actions: this.getFeatureActions(featureId, assignedGlobalFeaturePrivilege), - isDirectlyAssigned: isGlobalPrivilege && hasAssignedFeaturePrivilege, - ...this.buildSupercededFields( - hasAssignedFeaturePrivilege && !isGlobalPrivilege, - assignedFeaturePrivilege, - PRIVILEGE_SOURCE.SPACE_FEATURE - ), - }); - } - - if (isGlobalPrivilege) { - return this.rankScenarios(scenarios); - } - - // Otherwise, this is a space feature privilege - - const includeSpaceBaseScenario = - basePrivilegeExplanation.actualPrivilegeSource === PRIVILEGE_SOURCE.SPACE_BASE || - basePrivilegeExplanation.supersededPrivilegeSource === PRIVILEGE_SOURCE.SPACE_BASE; - - const spaceBasePrivilege = - basePrivilegeExplanation.supersededPrivilege || basePrivilegeExplanation.actualPrivilege; - - if (includeSpaceBaseScenario) { - scenarios.push({ - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: false, - actions: this.getBaseActions(PRIVILEGE_SOURCE.SPACE_BASE, spaceBasePrivilege), - ...this.buildSupercededFields( - hasAssignedFeaturePrivilege, - assignedFeaturePrivilege, - PRIVILEGE_SOURCE.SPACE_FEATURE - ), - }); - } - - if (!ignoreAssigned) { - const actions = this.getFeatureActions( - featureId, - this.getAssignedFeaturePrivilege(privilegeSpec, featureId) - ); - const directlyAssignedFeaturePrivilegeMorePermissiveThanBase = !areActionsFullyCovered( - this.assignedGlobalBaseActions, - actions - ); - scenarios.push({ - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase, - actions, - }); - } - - return this.rankScenarios(scenarios); - } - - private rankScenarios(scenarios: PrivilegeScenario[]): PrivilegeScenario[] { - return scenarios.sort( - (scenario1, scenario2) => scenario1.actualPrivilegeSource - scenario2.actualPrivilegeSource - ); - } - - private getBaseActions(source: PRIVILEGE_SOURCE, privilegeId: string) { - switch (source) { - case PRIVILEGE_SOURCE.GLOBAL_BASE: - return this.assignedGlobalBaseActions; - case PRIVILEGE_SOURCE.SPACE_BASE: - return this.kibanaPrivileges.getSpacesPrivileges().getActions(privilegeId); - default: - throw new Error( - `Cannot get base actions for unsupported privilege source ${PRIVILEGE_SOURCE[source]}` - ); - } - } - - private getFeatureActions(featureId: string, privilegeId: string) { - return this.kibanaPrivileges.getFeaturePrivileges().getActions(featureId, privilegeId); - } - - private getAssignedFeaturePrivilege(privilegeSpec: RoleKibanaPrivilege, featureId: string) { - const featureEntry = privilegeSpec.feature[featureId] || []; - return featureEntry[0] || NO_PRIVILEGE_VALUE; - } - - private buildSupercededFields( - isSuperceding: boolean, - supersededPrivilege?: string, - supersededPrivilegeSource?: PRIVILEGE_SOURCE - ) { - if (!isSuperceding) { - return {}; - } - return { - supersededPrivilege, - supersededPrivilegeSource, - }; - } -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.test.ts deleted file mode 100644 index 4c44c077f0336..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.test.ts +++ /dev/null @@ -1,940 +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 { KibanaPrivileges, Role } from '../../../../../../../common/model'; -import { NO_PRIVILEGE_VALUE } from '../constants'; -import { - buildRole, - defaultPrivilegeDefinition, - fullyRestrictedBasePrivileges, - fullyRestrictedFeaturePrivileges, - unrestrictedBasePrivileges, - unrestrictedFeaturePrivileges, -} from './__fixtures__'; -import { - AllowedPrivilege, - PRIVILEGE_SOURCE, - PrivilegeExplanation, -} from './kibana_privilege_calculator_types'; -import { KibanaPrivilegeCalculatorFactory } from './kibana_privileges_calculator_factory'; - -const buildEffectivePrivileges = ( - role: Role, - kibanaPrivileges: KibanaPrivileges = defaultPrivilegeDefinition -) => { - const factory = new KibanaPrivilegeCalculatorFactory(kibanaPrivileges); - return factory.getInstance(role); -}; - -interface BuildExpectedFeaturePrivilegesOption { - features: string[]; - privilegeExplanation: PrivilegeExplanation; -} - -const buildExpectedFeaturePrivileges = (options: BuildExpectedFeaturePrivilegesOption[]) => { - return { - feature: options.reduce((acc1, option) => { - return { - ...acc1, - ...option.features.reduce((acc2, featureId) => { - return { - ...acc2, - [featureId]: option.privilegeExplanation, - }; - }, {}), - }; - }, {}), - }; -}; - -describe('calculateEffectivePrivileges', () => { - it(`returns an empty array for an empty role`, () => { - const role = buildRole(); - role.kibana = []; - - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - expect(calculatedPrivileges).toHaveLength(0); - }); - - it(`calculates "none" for all privileges when nothing is assigned`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['foo', 'bar'], - base: [], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3', 'feature4'], - privilegeExplanation: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - }, - }, - ]), - }, - ]); - }); - - describe(`with global base privilege of "all"`, () => { - it(`calculates global feature privilege of all for features 1-3 and read for feature 4`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - ]); - }); - - it(`calculates space base and feature privilege of all for features 1-3 and read for feature 4`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - const calculatedSpacePrivileges = calculatedPrivileges[1]; - - expect(calculatedSpacePrivileges).toEqual({ - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }); - }); - - describe(`and with feature privileges assigned`, () => { - it('returns the base privileges when they are more permissive', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: { - feature1: ['read'], - feature2: ['read'], - feature3: ['read'], - feature4: ['read'], - }, - }, - { - spaces: ['foo'], - base: [], - feature: { - feature1: ['read'], - feature2: ['read'], - feature3: ['read'], - feature4: ['read'], - }, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: false, - }, - }, - ]), - }, - ]); - }); - }); - }); - - describe(`with global base privilege of "read"`, () => { - it(`it calculates space base and feature privileges when none are provided`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature3'], - privilegeExplanation: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature3'], - privilegeExplanation: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - ]); - }); - - describe('and with feature privileges assigned', () => { - it('returns the feature privileges when they are more permissive', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: { - feature1: ['all'], - feature2: ['all'], - feature3: ['all'], - feature4: ['all'], - }, - }, - { - spaces: ['foo'], - base: [], - feature: { - feature1: ['all'], - feature2: ['all'], - feature3: ['all'], - feature4: ['all'], - }, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3', 'feature4'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3', 'feature4'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: true, - }, - }, - ]), - }, - ]); - }); - }); - }); - - describe('with both global and space base privileges assigned', () => { - it(`does not override space base of "all" when global base is "read"`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['foo'], - base: ['all'], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature3'], - privilegeExplanation: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - ]); - }); - - it(`calculates "all" for space base and space features when superceded by global "all"`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['foo'], - base: ['read'], - feature: {}, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - ]); - }); - - it(`does not override feature privileges when they are more permissive`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['foo'], - base: ['read'], - feature: { - feature1: ['all'], - feature2: ['all'], - feature3: ['all'], - feature4: ['all'], - }, - }, - ], - }); - const effectivePrivileges = buildEffectivePrivileges(role); - const calculatedPrivileges = effectivePrivileges.calculateEffectivePrivileges(); - - expect(calculatedPrivileges).toEqual([ - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: true, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - { - features: ['feature3'], - privilegeExplanation: { - actualPrivilege: NO_PRIVILEGE_VALUE, - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_FEATURE, - isDirectlyAssigned: true, - }, - }, - { - features: ['feature4'], - privilegeExplanation: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - }, - }, - ]), - }, - { - base: { - actualPrivilege: 'read', - actualPrivilegeSource: PRIVILEGE_SOURCE.GLOBAL_BASE, - isDirectlyAssigned: false, - supersededPrivilege: 'read', - supersededPrivilegeSource: PRIVILEGE_SOURCE.SPACE_BASE, - }, - ...buildExpectedFeaturePrivileges([ - { - features: ['feature1', 'feature2', 'feature3', 'feature4'], - privilegeExplanation: { - actualPrivilege: 'all', - actualPrivilegeSource: PRIVILEGE_SOURCE.SPACE_FEATURE, - isDirectlyAssigned: true, - directlyAssignedFeaturePrivilegeMorePermissiveThanBase: true, - }, - }, - ]), - }, - ]); - }); - }); -}); - -describe('calculateAllowedPrivileges', () => { - it('allows all privileges when none are currently assigned', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - - const privilegeCalculator = buildEffectivePrivileges(role); - - const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...unrestrictedFeaturePrivileges, - }, - { - ...unrestrictedBasePrivileges, - ...unrestrictedFeaturePrivileges, - }, - ]); - }); - - it('allows all global base privileges, but just "all" for everything else when global is set to "all"', () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - - const privilegeCalculator = buildEffectivePrivileges(role); - - const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...fullyRestrictedFeaturePrivileges, - }, - { - ...fullyRestrictedBasePrivileges, - ...fullyRestrictedFeaturePrivileges, - }, - ]); - }); - - it(`allows feature privileges to be set to "all" or "read" when global base is "read"`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: ['read'], - feature: {}, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - - const privilegeCalculator = buildEffectivePrivileges(role); - - const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); - - const expectedFeaturePrivileges = { - feature: { - feature1: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature2: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: true, // feature 3 has no "read" privilege governed by global "all" - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }; - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...expectedFeaturePrivileges, - }, - { - base: { - privileges: ['all', 'read'], - canUnassign: false, - }, - ...expectedFeaturePrivileges, - }, - ]); - }); - - it(`allows feature privileges to be set to "all" or "read" when space base is "read"`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: {}, - }, - { - spaces: ['foo'], - base: ['read'], - feature: {}, - }, - ], - }); - - const privilegeCalculator = buildEffectivePrivileges(role); - - const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...unrestrictedFeaturePrivileges, - }, - { - base: { - privileges: ['all', 'read'], - canUnassign: true, - }, - feature: { - feature1: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature2: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: true, // feature 3 has no "read" privilege governed by space "all" - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }, - ]); - }); - - it(`allows space base privilege to be set to "all" or "read" when space base is already "all"`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['foo'], - base: ['all'], - feature: {}, - }, - ], - }); - - const privilegeCalculator = buildEffectivePrivileges(role); - - const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - feature: { - feature1: { - privileges: ['all'], - canUnassign: false, - }, - feature2: { - privileges: ['all'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: false, - }, - feature4: { - privileges: ['all', 'read'], - canUnassign: false, - }, - }, - }, - ]); - }); - - it(`restricts space feature privileges when global feature privileges are set`, () => { - const role = buildRole({ - spacesPrivileges: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['all'], - feature2: ['read'], - feature4: ['all'], - }, - }, - { - spaces: ['foo'], - base: [], - feature: {}, - }, - ], - }); - - const privilegeCalculator = buildEffectivePrivileges(role); - - const result: AllowedPrivilege[] = privilegeCalculator.calculateAllowedPrivileges(); - - expect(result).toEqual([ - { - ...unrestrictedBasePrivileges, - ...unrestrictedFeaturePrivileges, - }, - { - base: { - privileges: ['all', 'read'], - canUnassign: true, - }, - feature: { - feature1: { - privileges: ['all'], - canUnassign: false, - }, - feature2: { - privileges: ['all', 'read'], - canUnassign: false, - }, - feature3: { - privileges: ['all'], - canUnassign: true, // feature 3 has no "read" privilege governed by space "all" - }, - feature4: { - privileges: ['all'], - canUnassign: false, - }, - }, - }, - ]); - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.ts deleted file mode 100644 index c3bf12b6aef5f..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator.ts +++ /dev/null @@ -1,113 +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 { - FeaturesPrivileges, - KibanaPrivileges, - Role, - RoleKibanaPrivilege, -} from '../../../../../../../common/model'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { KibanaAllowedPrivilegesCalculator } from './kibana_allowed_privileges_calculator'; -import { KibanaBasePrivilegeCalculator } from './kibana_base_privilege_calculator'; -import { KibanaFeaturePrivilegeCalculator } from './kibana_feature_privilege_calculator'; -import { AllowedPrivilege, CalculatedPrivilege } from './kibana_privilege_calculator_types'; - -export class KibanaPrivilegeCalculator { - private allowedPrivilegesCalculator: KibanaAllowedPrivilegesCalculator; - - private effectiveBasePrivilegesCalculator: KibanaBasePrivilegeCalculator; - - private effectiveFeaturePrivilegesCalculator: KibanaFeaturePrivilegeCalculator; - - constructor( - private readonly kibanaPrivileges: KibanaPrivileges, - private readonly role: Role, - public readonly rankedFeaturePrivileges: FeaturesPrivileges - ) { - const globalPrivilege = this.locateGlobalPrivilege(role); - - const assignedGlobalBaseActions: string[] = globalPrivilege.base[0] - ? kibanaPrivileges.getGlobalPrivileges().getActions(globalPrivilege.base[0]) - : []; - - this.allowedPrivilegesCalculator = new KibanaAllowedPrivilegesCalculator( - kibanaPrivileges, - role - ); - - this.effectiveBasePrivilegesCalculator = new KibanaBasePrivilegeCalculator( - kibanaPrivileges, - globalPrivilege, - assignedGlobalBaseActions - ); - - this.effectiveFeaturePrivilegesCalculator = new KibanaFeaturePrivilegeCalculator( - kibanaPrivileges, - globalPrivilege, - assignedGlobalBaseActions, - rankedFeaturePrivileges - ); - } - - public calculateEffectivePrivileges(ignoreAssigned: boolean = false): CalculatedPrivilege[] { - const { kibana = [] } = this.role; - return kibana.map(privilegeSpec => - this.calculateEffectivePrivilege(privilegeSpec, ignoreAssigned) - ); - } - - public calculateAllowedPrivileges(): AllowedPrivilege[] { - const effectivePrivs = this.calculateEffectivePrivileges(true); - return this.allowedPrivilegesCalculator.calculateAllowedPrivileges(effectivePrivs); - } - - private calculateEffectivePrivilege( - privilegeSpec: RoleKibanaPrivilege, - ignoreAssigned: boolean - ): CalculatedPrivilege { - const result: CalculatedPrivilege = { - base: this.effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege( - privilegeSpec, - ignoreAssigned - ), - feature: {}, - reserved: privilegeSpec._reserved, - }; - - // If calculations wish to ignoreAssigned, then we still need to know what the real effective base privilege is - // without ignoring assigned, in order to calculate the correct feature privileges. - const effectiveBase = ignoreAssigned - ? this.effectiveBasePrivilegesCalculator.getMostPermissiveBasePrivilege(privilegeSpec, false) - : result.base; - - const allFeaturePrivileges = this.kibanaPrivileges.getFeaturePrivileges().getAllPrivileges(); - result.feature = Object.keys(allFeaturePrivileges).reduce((acc, featureId) => { - return { - ...acc, - [featureId]: this.effectiveFeaturePrivilegesCalculator.getMostPermissiveFeaturePrivilege( - privilegeSpec, - effectiveBase, - featureId, - ignoreAssigned - ), - }; - }, {}); - - return result; - } - - private locateGlobalPrivilege(role: Role) { - const spacePrivileges = role.kibana; - return ( - spacePrivileges.find(privileges => isGlobalPrivilegeDefinition(privileges)) || { - spaces: [] as string[], - base: [] as string[], - feature: {}, - } - ); - } -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator_types.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator_types.ts deleted file mode 100644 index aeaf12d02210a..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privilege_calculator_types.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/** - * Describes the source of a privilege. - */ -export enum PRIVILEGE_SOURCE { - /** Privilege is assigned directly to the entity */ - SPACE_FEATURE = 10, - - /** Privilege is derived from space base privilege */ - SPACE_BASE = 20, - - /** Privilege is derived from global feature privilege */ - GLOBAL_FEATURE = 30, - - /** Privilege is derived from global base privilege */ - GLOBAL_BASE = 40, -} - -export interface PrivilegeExplanation { - actualPrivilege: string; - actualPrivilegeSource: PRIVILEGE_SOURCE; - isDirectlyAssigned: boolean; - supersededPrivilege?: string; - supersededPrivilegeSource?: PRIVILEGE_SOURCE; - directlyAssignedFeaturePrivilegeMorePermissiveThanBase?: boolean; -} - -export interface CalculatedPrivilege { - base: PrivilegeExplanation; - feature: { - [featureId: string]: PrivilegeExplanation | undefined; - }; - reserved: undefined | string[]; -} - -export interface PrivilegeScenario { - actualPrivilegeSource: PRIVILEGE_SOURCE; - isDirectlyAssigned: boolean; - supersededPrivilege?: string; - supersededPrivilegeSource?: PRIVILEGE_SOURCE; - actions: string[]; - directlyAssignedFeaturePrivilegeMorePermissiveThanBase?: boolean; -} - -export interface AllowedPrivilege { - base: { - privileges: string[]; - canUnassign: boolean; - }; - feature: { - [featureId: string]: - | { - privileges: string[]; - canUnassign: boolean; - } - | undefined; - }; -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts deleted file mode 100644 index febdb64b93d61..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privilege_calculator/kibana_privileges_calculator_factory.ts +++ /dev/null @@ -1,81 +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 { - FeaturesPrivileges, - KibanaPrivileges, - Role, - copyRole, -} from '../../../../../../../common/model'; -import { compareActions } from '../../../../../../../common/privilege_calculator_utils'; -import { KibanaPrivilegeCalculator } from './kibana_privilege_calculator'; - -export class KibanaPrivilegeCalculatorFactory { - /** All feature privileges, sorted from most permissive => least permissive. */ - public readonly rankedFeaturePrivileges: FeaturesPrivileges; - - constructor(private readonly kibanaPrivileges: KibanaPrivileges) { - this.rankedFeaturePrivileges = {}; - const featurePrivilegeSet = kibanaPrivileges.getFeaturePrivileges().getAllPrivileges(); - - Object.entries(featurePrivilegeSet).forEach(([featureId, privileges]) => { - this.rankedFeaturePrivileges[featureId] = privileges.sort((privilege1, privilege2) => { - const privilege1Actions = kibanaPrivileges - .getFeaturePrivileges() - .getActions(featureId, privilege1); - const privilege2Actions = kibanaPrivileges - .getFeaturePrivileges() - .getActions(featureId, privilege2); - return compareActions(privilege1Actions, privilege2Actions); - }); - }); - } - - /** - * Creates an KibanaPrivilegeCalculator instance for the specified role. - * @param role - */ - public getInstance(role: Role) { - const roleCopy = copyRole(role); - - this.sortPrivileges(roleCopy); - return new KibanaPrivilegeCalculator( - this.kibanaPrivileges, - roleCopy, - this.rankedFeaturePrivileges - ); - } - - private sortPrivileges(role: Role) { - role.kibana.forEach(privilege => { - privilege.base.sort((privilege1, privilege2) => { - const privilege1Actions = this.kibanaPrivileges - .getSpacesPrivileges() - .getActions(privilege1); - - const privilege2Actions = this.kibanaPrivileges - .getSpacesPrivileges() - .getActions(privilege2); - - return compareActions(privilege1Actions, privilege2Actions); - }); - - Object.entries(privilege.feature).forEach(([featureId, featurePrivs]) => { - featurePrivs.sort((privilege1, privilege2) => { - const privilege1Actions = this.kibanaPrivileges - .getFeaturePrivileges() - .getActions(featureId, privilege1); - - const privilege2Actions = this.kibanaPrivileges - .getFeaturePrivileges() - .getActions(featureId, privilege2); - - return compareActions(privilege1Actions, privilege2Actions); - }); - }); - }); - } -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.test.tsx index 6487179b1d6e5..8fea0e02f3c8d 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.test.tsx @@ -6,12 +6,13 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { KibanaPrivileges, Role } from '../../../../../../common/model'; +import { Role } from '../../../../../../common/model'; import { RoleValidator } from '../../validate_role'; import { KibanaPrivilegesRegion } from './kibana_privileges_region'; import { SimplePrivilegeSection } from './simple_privilege_section'; -import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section'; import { TransformErrorSection } from './transform_error_section'; +import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section'; +import { KibanaPrivileges } from '../../../model'; const buildProps = (customProps = {}) => { return { @@ -39,12 +40,15 @@ const buildProps = (customProps = {}) => { }, ], features: [], - kibanaPrivileges: new KibanaPrivileges({ - global: {}, - space: {}, - features: {}, - reserved: {}, - }), + kibanaPrivileges: new KibanaPrivileges( + { + global: {}, + space: {}, + features: {}, + reserved: {}, + }, + [] + ), intl: null as any, uiCapabilities: { navLinks: {}, @@ -57,6 +61,7 @@ const buildProps = (customProps = {}) => { editable: true, onChange: jest.fn(), validator: new RoleValidator(), + canCustomizeSubFeaturePrivileges: true, ...customProps, }; }; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.tsx index a4e287632c764..284bcb29f9b6e 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/kibana_privileges_region.tsx @@ -7,21 +7,20 @@ import React, { Component } from 'react'; import { Capabilities } from 'src/core/public'; import { Space } from '../../../../../../../spaces/public'; -import { Feature } from '../../../../../../../features/public'; -import { KibanaPrivileges, Role } from '../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from './kibana_privilege_calculator'; +import { Role } from '../../../../../../common/model'; import { RoleValidator } from '../../validate_role'; import { CollapsiblePanel } from '../../collapsible_panel'; import { SimplePrivilegeSection } from './simple_privilege_section'; import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section'; import { TransformErrorSection } from './transform_error_section'; +import { KibanaPrivileges } from '../../../model'; interface Props { role: Role; spacesEnabled: boolean; + canCustomizeSubFeaturePrivileges: boolean; spaces?: Space[]; uiCapabilities: Capabilities; - features: Feature[]; editable: boolean; kibanaPrivileges: KibanaPrivileges; onChange: (role: Role) => void; @@ -42,31 +41,28 @@ export class KibanaPrivilegesRegion extends Component { kibanaPrivileges, role, spacesEnabled, + canCustomizeSubFeaturePrivileges, spaces = [], uiCapabilities, onChange, editable, validator, - features, } = this.props; if (role._transform_error && role._transform_error.includes('kibana')) { return ; } - const privilegeCalculatorFactory = new KibanaPrivilegeCalculatorFactory(kibanaPrivileges); - if (spacesEnabled) { return ( ); @@ -74,11 +70,10 @@ export class KibanaPrivilegesRegion extends Component { return ( ); } diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/index.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/index.ts new file mode 100644 index 0000000000000..121d615c1fc35 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { PrivilegeFormCalculator } from './privilege_form_calculator'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.test.ts new file mode 100644 index 0000000000000..edf2af918fd04 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.test.ts @@ -0,0 +1,833 @@ +/* + * 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 { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { Role } from '../../../../../../../common/model'; +import { PrivilegeFormCalculator } from './privilege_form_calculator'; + +const createRole = (kibana: Role['kibana'] = []): Role => { + return { + name: 'unit test role', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana, + }; +}; + +describe('PrivilegeFormCalculator', () => { + describe('#getBasePrivilege', () => { + it(`returns undefined when no base privilege is assigned`, () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getBasePrivilege(0)).toBeUndefined(); + }); + + it(`ignores unknown base privileges`, () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['unknown'], + feature: {}, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getBasePrivilege(0)).toBeUndefined(); + }); + + it(`returns the assigned base privilege`, () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read'], + feature: {}, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getBasePrivilege(0)).toMatchObject({ + id: 'read', + }); + }); + + it(`returns the most permissive base privilege when multiple are assigned`, () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read', 'all'], + feature: {}, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getBasePrivilege(0)).toMatchObject({ + id: 'all', + }); + }); + }); + + describe('#getDisplayedPrimaryFeaturePrivilegeId', () => { + it('returns undefined when no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getDisplayedPrimaryFeaturePrivilegeId('with_sub_features', 0) + ).toBeUndefined(); + }); + + it('returns the effective privilege id when a base privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getDisplayedPrimaryFeaturePrivilegeId('with_sub_features', 0)).toEqual( + 'all' + ); + }); + + it('returns the most permissive assigned primary feature privilege id', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read', 'all', 'minimal_read'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getDisplayedPrimaryFeaturePrivilegeId('with_sub_features', 0)).toEqual( + 'all' + ); + }); + + it('returns the primary version of the minimal privilege id when assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getDisplayedPrimaryFeaturePrivilegeId('with_sub_features', 0)).toEqual( + 'read' + ); + }); + }); + + describe('#hasCustomizedSubFeaturePrivileges', () => { + it('returns false when no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns false when there are no sub-feature privileges assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns false when the assigned sub-features are also granted by other assigned privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all', 'cool_all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns true when the assigned sub-features are not also granted by other assigned privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read', 'cool_all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(true); + }); + + it('returns true when a minimal primary feature privilege is assigned, whose corresponding primary grants sub-feature privileges which are not assigned ', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(true); + }); + + it('returns false when a minimal primary feature privilege is assigned, whose corresponding primary grants sub-feature privileges which are all assigned ', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_read', 'cool_toggle_2'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns true when a minimal primary feature privilege is assigned, whose corresponding primary does not grant all assigned sub-feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [ + 'minimal_read', + 'cool_read', + 'cool_toggle_2', + 'cool_excluded_toggle', + ], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(true); + }); + + it('returns false when a base privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.hasCustomizedSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + }); + + describe('#getEffectivePrimaryFeaturePrivilege', () => { + it('returns undefined when no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0) + ).toBeUndefined(); + }); + + it('returns the most permissive feature privilege granted by the assigned base privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read'], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)).toMatchObject({ + id: 'read', + }); + }); + + it('returns the most permissive feature privilege granted by the assigned feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read'], + feature: { + with_sub_features: ['read', 'all', 'minimal_all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)).toMatchObject({ + id: 'all', + }); + }); + + it('prefers `read` primary over `mininal_all`', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_all', 'read'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)).toMatchObject({ + id: 'read', + }); + }); + + it('returns the minimal primary feature privilege when assigned and not superseded', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0)).toMatchObject({ + id: 'minimal_all', + }); + }); + + it('ignores unknown privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['unknown'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getEffectivePrimaryFeaturePrivilege('with_sub_features', 0) + ).toBeUndefined(); + }); + }); + + describe('#isIndependentSubFeaturePrivilegeGranted', () => { + it('returns false when no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.isIndependentSubFeaturePrivilegeGranted('with_sub_features', 'cool_toggle_1', 0) + ).toEqual(false); + }); + + it('returns false when an excluded sub-feature privilege is not directly assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.isIndependentSubFeaturePrivilegeGranted( + 'with_sub_features', + 'cool_excluded_toggle', + 0 + ) + ).toEqual(false); + }); + + it('returns true when an excluded sub-feature privilege is directly assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: ['all', 'cool_excluded_toggle'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.isIndependentSubFeaturePrivilegeGranted( + 'with_sub_features', + 'cool_excluded_toggle', + 0 + ) + ).toEqual(true); + }); + + it('returns true when a sub-feature privilege is directly assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: ['all', 'cool_toggle_1'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.isIndependentSubFeaturePrivilegeGranted('with_sub_features', 'cool_toggle_1', 0) + ).toEqual(true); + }); + + it('returns true when a sub-feature privilege is inherited', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.isIndependentSubFeaturePrivilegeGranted('with_sub_features', 'cool_toggle_1', 0) + ).toEqual(true); + }); + }); + + describe('#getSelectedMutuallyExclusiveSubFeaturePrivilege', () => { + it('returns undefined when no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + const coolSubFeature = feature.getSubFeatures().find(sf => sf.name === 'Cool Sub Feature')!; + const subFeatureGroup = coolSubFeature + .getPrivilegeGroups() + .find(pg => pg.groupType === 'mutually_exclusive')!; + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getSelectedMutuallyExclusiveSubFeaturePrivilege( + 'with_sub_features', + subFeatureGroup, + 0 + ) + ).toBeUndefined(); + }); + + it('returns the inherited privilege when not directly assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + const coolSubFeature = feature.getSubFeatures().find(sf => sf.name === 'Cool Sub Feature')!; + const subFeatureGroup = coolSubFeature + .getPrivilegeGroups() + .find(pg => pg.groupType === 'mutually_exclusive')!; + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getSelectedMutuallyExclusiveSubFeaturePrivilege( + 'with_sub_features', + subFeatureGroup, + 0 + ) + ).toMatchObject({ + id: 'cool_all', + }); + }); + + it('returns the the most permissive effective privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all', 'cool_read', 'cool_all'], + }, + spaces: ['foo'], + }, + ]); + + const feature = kibanaPrivileges.getSecuredFeature('with_sub_features'); + const coolSubFeature = feature.getSubFeatures().find(sf => sf.name === 'Cool Sub Feature')!; + const subFeatureGroup = coolSubFeature + .getPrivilegeGroups() + .find(pg => pg.groupType === 'mutually_exclusive')!; + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect( + calculator.getSelectedMutuallyExclusiveSubFeaturePrivilege( + 'with_sub_features', + subFeatureGroup, + 0 + ) + ).toMatchObject({ + id: 'cool_all', + }); + }); + }); + + describe('#canCustomizeSubFeaturePrivileges', () => { + it('returns false if no privileges are assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.canCustomizeSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns false if a base privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.canCustomizeSubFeaturePrivileges('with_sub_features', 0)).toEqual(false); + }); + + it('returns true if a minimal privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['foo'], + }, + ]); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.canCustomizeSubFeaturePrivileges('with_sub_features', 0)).toEqual(true); + }); + + it('returns true if a primary feature privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + ]); + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + expect(calculator.canCustomizeSubFeaturePrivileges('with_sub_features', 0)).toEqual(true); + }); + }); + + describe('#updateSelectedFeaturePrivilegesForCustomization', () => { + it('returns the privileges unmodified if no primary feature privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['some-privilege'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect( + calculator.updateSelectedFeaturePrivilegesForCustomization('with_sub_features', 0, true) + ).toEqual(['some-privilege']); + + expect( + calculator.updateSelectedFeaturePrivilegesForCustomization('with_sub_features', 0, false) + ).toEqual(['some-privilege']); + }); + + it('switches to the minimal privilege when customizing, but explicitly grants the sub-feature privileges which were originally inherited', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect( + calculator.updateSelectedFeaturePrivilegesForCustomization('with_sub_features', 0, true) + ).toEqual(['minimal_read', 'cool_read', 'cool_toggle_2']); + }); + + it('switches to the non-minimal privilege when customizing, removing all other privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_read', 'cool_toggle_2'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect( + calculator.updateSelectedFeaturePrivilegesForCustomization('with_sub_features', 0, false) + ).toEqual(['read']); + }); + }); + + describe('#hasSupersededInheritedPrivileges', () => { + // More exhaustive testing is done at the UI layer: `privilege_space_table.test.tsx` + it('returns false for the global privilege definition', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + { + base: ['all'], + feature: { + with_sub_features: ['read'], + }, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect(calculator.hasSupersededInheritedPrivileges(1)).toEqual(false); + }); + + it('returns false when the global privilege is not more permissive', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect(calculator.hasSupersededInheritedPrivileges(0)).toEqual(false); + }); + + it('returns true when the global feature privilege is more permissive', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect(calculator.hasSupersededInheritedPrivileges(0)).toEqual(true); + }); + + it('returns true when the global base privilege is more permissive', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read'], + feature: {}, + spaces: ['foo'], + }, + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect(calculator.hasSupersededInheritedPrivileges(0)).toEqual(true); + }); + + it('returns false when only the global base privilege is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: {}, + spaces: ['foo'], + }, + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role); + + expect(calculator.hasSupersededInheritedPrivileges(0)).toEqual(false); + }); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts new file mode 100644 index 0000000000000..8cff37f4bd4b0 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts @@ -0,0 +1,303 @@ +/* + * 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 { Role } from '../../../../../../../common/model'; +import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import { KibanaPrivileges, SubFeaturePrivilegeGroup } from '../../../../model'; + +/** + * Calculator responsible for determining the displayed and effective privilege values for the following interfaces: + * - and children + * - and children + */ +export class PrivilegeFormCalculator { + constructor(private readonly kibanaPrivileges: KibanaPrivileges, private readonly role: Role) {} + + /** + * Returns the assigned base privilege. + * If more than one base privilege is assigned, the most permissive privilege will be returned. + * If no base privileges are assigned, then this will return `undefined`. + * + * @param privilegeIndex the index of the kibana privileges role component + */ + public getBasePrivilege(privilegeIndex: number) { + const entry = this.role.kibana[privilegeIndex]; + + const basePrivileges = this.kibanaPrivileges.getBasePrivileges(entry); + return basePrivileges.find(bp => entry.base.includes(bp.id)); + } + + /** + * Returns the ID of the *displayed* Primary Feature Privilege for the indicated feature and privilege index. + * If the effective primary feature privilege is a "minimal" version, then this returns the corresponding non-minimal version. + * + * @example + * The following kibana privilege entry will return `read`: + * ```ts + * const entry = { + * base: [], + * feature: { + * some_feature: ['minimal_read'], + * } + * } + * ``` + * + * @param featureId the feature id to get the Primary Feature KibanaPrivilege for. + * @param privilegeIndex the index of the kibana privileges role component + */ + public getDisplayedPrimaryFeaturePrivilegeId(featureId: string, privilegeIndex: number) { + return this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex)?.id; + } + + /** + * Determines if the indicated feature has sub-feature privilege assignments which differ from the "displayed" primary feature privilege. + * + * @param featureId the feature id + * @param privilegeIndex the index of the kibana privileges role component + */ + public hasCustomizedSubFeaturePrivileges(featureId: string, privilegeIndex: number) { + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + + const displayedPrimary = this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex); + + const formPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([ + this.role.kibana[privilegeIndex], + ]); + + return feature.getSubFeaturePrivileges().some(sfp => { + const isGranted = formPrivileges.grantsPrivilege(sfp); + const isGrantedByDisplayedPrimary = displayedPrimary?.grantsPrivilege(sfp) ?? isGranted; + + return isGranted !== isGrantedByDisplayedPrimary; + }); + } + + /** + * Returns the most permissive effective Primary Feature KibanaPrivilege, including the minimal versions. + * + * @param featureId the feature id + * @param privilegeIndex the index of the kibana privileges role component + */ + public getEffectivePrimaryFeaturePrivilege(featureId: string, privilegeIndex: number) { + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + + const basePrivilege = this.getBasePrivilege(privilegeIndex); + + const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex); + + return feature + .getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true }) + .find(fp => { + return selectedFeaturePrivileges.includes(fp.id) || basePrivilege?.grantsPrivilege(fp); + }); + } + + /** + * Determines if the indicated sub-feature privilege is granted. + * + * @param featureId the feature id + * @param privilegeId the sub feature privilege id + * @param privilegeIndex the index of the kibana privileges role component + */ + public isIndependentSubFeaturePrivilegeGranted( + featureId: string, + privilegeId: string, + privilegeIndex: number + ) { + const kibanaPrivilege = this.role.kibana[privilegeIndex]; + + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + const subFeaturePrivilege = feature + .getSubFeaturePrivileges() + .find(ap => ap.id === privilegeId)!; + + const assignedPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([ + kibanaPrivilege, + ]); + + return assignedPrivileges.grantsPrivilege(subFeaturePrivilege); + } + + /** + * Returns the most permissive effective privilege within the indicated mutually-exclusive sub feature privilege group. + * + * @param featureId the feature id + * @param subFeatureGroup the mutually-exclusive sub feature group + * @param privilegeIndex the index of the kibana privileges role component + */ + public getSelectedMutuallyExclusiveSubFeaturePrivilege( + featureId: string, + subFeatureGroup: SubFeaturePrivilegeGroup, + privilegeIndex: number + ) { + const kibanaPrivilege = this.role.kibana[privilegeIndex]; + const assignedPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([ + kibanaPrivilege, + ]); + + return subFeatureGroup.privileges.find(p => { + return assignedPrivileges.grantsPrivilege(p); + }); + } + + /** + * Determines if the indicated feature is capable of having its sub-feature privileges customized. + * + * @param featureId the feature id + * @param privilegeIndex the index of the kibana privileges role component + */ + public canCustomizeSubFeaturePrivileges(featureId: string, privilegeIndex: number) { + const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex); + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + + return feature + .getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true }) + .some(apfp => selectedFeaturePrivileges.includes(apfp.id)); + } + + /** + * Returns an updated set of feature privileges based on the toggling of the "Customize sub-feature privileges" control. + * + * @param featureId the feature id + * @param privilegeIndex the index of the kibana privileges role component + * @param willBeCustomizing flag indicating if this feature is about to have its sub-feature privileges customized or not + */ + public updateSelectedFeaturePrivilegesForCustomization( + featureId: string, + privilegeIndex: number, + willBeCustomizing: boolean + ) { + const primary = this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex); + const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex); + + if (!primary) { + return selectedFeaturePrivileges; + } + + const nextPrivileges = []; + + if (willBeCustomizing) { + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + + const startingPrivileges = feature + .getSubFeaturePrivileges() + .filter(ap => primary.grantsPrivilege(ap)) + .map(p => p.id); + + nextPrivileges.push(primary.getMinimalPrivilegeId(), ...startingPrivileges); + } else { + nextPrivileges.push(primary.id); + } + + return nextPrivileges; + } + + /** + * Determines if the indicated privilege entry is less permissive than the configured "global" entry for the role. + * @param privilegeIndex the index of the kibana privileges role component + */ + public hasSupersededInheritedPrivileges(privilegeIndex: number) { + const global = this.locateGlobalPrivilege(this.role); + + const entry = this.role.kibana[privilegeIndex]; + + if (isGlobalPrivilegeDefinition(entry) || !global) { + return false; + } + + const globalPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([ + global, + ]); + + const formPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([entry]); + + const hasAssignedBasePrivileges = this.kibanaPrivileges + .getBasePrivileges(entry) + .some(base => entry.base.includes(base.id)); + + const featuresWithDirectlyAssignedPrivileges = this.kibanaPrivileges + .getSecuredFeatures() + .filter(feature => + feature + .getAllPrivileges() + .some(privilege => entry.feature[feature.id]?.includes(privilege.id)) + ); + + const hasSupersededBasePrivileges = + hasAssignedBasePrivileges && + this.kibanaPrivileges + .getBasePrivileges(entry) + .some( + privilege => + globalPrivileges.grantsPrivilege(privilege) && + !formPrivileges.grantsPrivilege(privilege) + ); + + const hasSupersededFeaturePrivileges = featuresWithDirectlyAssignedPrivileges.some(feature => + feature + .getAllPrivileges() + .some(fp => globalPrivileges.grantsPrivilege(fp) && !formPrivileges.grantsPrivilege(fp)) + ); + + return hasSupersededBasePrivileges || hasSupersededFeaturePrivileges; + } + + /** + * Returns the *displayed* Primary Feature Privilege for the indicated feature and privilege index. + * If the effective primary feature privilege is a "minimal" version, then this returns the corresponding non-minimal version. + * + * @example + * The following kibana privilege entry will return `read`: + * ```ts + * const entry = { + * base: [], + * feature: { + * some_feature: ['minimal_read'], + * } + * } + * ``` + * + * @param featureId the feature id to get the Primary Feature KibanaPrivilege for. + * @param privilegeIndex the index of the kibana privileges role component + */ + private getDisplayedPrimaryFeaturePrivilege(featureId: string, privilegeIndex: number) { + const feature = this.kibanaPrivileges.getSecuredFeature(featureId); + + const basePrivilege = this.getBasePrivilege(privilegeIndex); + + const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex); + + return feature.getPrimaryFeaturePrivileges().find(fp => { + const correspondingMinimalPrivilegeId = fp.getMinimalPrivilegeId(); + + const correspendingMinimalPrivilege = feature + .getMinimalFeaturePrivileges() + .find(mp => mp.id === correspondingMinimalPrivilegeId)!; + + // There are two cases where the minimal privileges aren't available: + // 1. The feature has no registered sub-features + // 2. Sub-feature privileges cannot be customized. When this is the case, the minimal privileges aren't registered with ES, + // so they end up represented in the UI as an empty privilege. Empty privileges cannot be granted other privileges, so if we + // encounter a minimal privilege that isn't granted by it's correspending primary, then we know we've encountered this scenario. + const hasMinimalPrivileges = + feature.subFeatures.length > 0 && fp.grantsPrivilege(correspendingMinimalPrivilege); + return ( + selectedFeaturePrivileges.includes(fp.id) || + (hasMinimalPrivileges && + selectedFeaturePrivileges.includes(correspondingMinimalPrivilegeId)) || + basePrivilege?.grantsPrivilege(fp) + ); + }); + } + + private getSelectedFeaturePrivileges(featureId: string, privilegeIndex: number) { + return this.role.kibana[privilegeIndex].feature[featureId] ?? []; + } + + private locateGlobalPrivilege(role: Role) { + return role.kibana.find(entry => isGlobalPrivilegeDefinition(entry)); + } +} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/__fixtures__/index.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/__fixtures__/index.ts new file mode 100644 index 0000000000000..63b38b6967575 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/__fixtures__/index.ts @@ -0,0 +1,129 @@ +/* + * 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 { ReactWrapper } from 'enzyme'; + +import { EuiTableRow } from '@elastic/eui'; + +import { findTestSubject } from 'test_utils/find_test_subject'; +import { Role, RoleKibanaPrivilege } from '../../../../../../../../common/model'; +import { PrivilegeSummaryExpandedRow } from '../privilege_summary_expanded_row'; +import { FeatureTableCell } from '../../feature_table_cell'; + +interface DisplayedFeaturePrivileges { + [featureId: string]: { + [spaceGroup: string]: { + primaryFeaturePrivilege: string; + subFeaturesPrivileges: { + [subFeatureName: string]: string[]; + }; + hasCustomizedSubFeaturePrivileges: boolean; + }; + }; +} + +const getSpaceKey = (entry: RoleKibanaPrivilege) => entry.spaces.join(', '); + +export function getDisplayedFeaturePrivileges( + wrapper: ReactWrapper, + role: Role +): DisplayedFeaturePrivileges { + const allExpanderButtons = findTestSubject(wrapper, 'expandPrivilegeSummaryRow'); + allExpanderButtons.forEach(button => button.simulate('click')); + + // each expanded row renders its own `EuiTableRow`, so there are 2 rows + // for each feature: one for the primary feature privilege, and one for the sub privilege form + const rows = wrapper.find(EuiTableRow); + + return rows.reduce((acc, row) => { + const expandedRow = row.find(PrivilegeSummaryExpandedRow); + if (expandedRow.length > 0) { + return { + ...acc, + ...getDisplayedSubFeaturePrivileges(acc, expandedRow, role), + }; + } else { + const feature = row.find(FeatureTableCell).props().feature; + + const primaryFeaturePrivileges = findTestSubject(row, 'privilegeColumn'); + + expect(primaryFeaturePrivileges).toHaveLength(role.kibana.length); + + acc[feature.id] = acc[feature.id] ?? {}; + + primaryFeaturePrivileges.forEach((primary, index) => { + const key = getSpaceKey(role.kibana[index]); + + acc[feature.id][key] = { + ...acc[feature.id][key], + primaryFeaturePrivilege: primary.text().trim(), + hasCustomizedSubFeaturePrivileges: + findTestSubject(primary, 'additionalPrivilegesGranted').length > 0, + }; + }); + + return acc; + } + }, {} as DisplayedFeaturePrivileges); +} + +function getDisplayedSubFeaturePrivileges( + displayedFeatures: DisplayedFeaturePrivileges, + expandedRow: ReactWrapper, + role: Role +) { + const { feature } = expandedRow.props(); + + const subFeatureEntries = findTestSubject(expandedRow as ReactWrapper, 'subFeatureEntry'); + + displayedFeatures[feature.id] = displayedFeatures[feature.id] ?? {}; + + subFeatureEntries.forEach(subFeatureEntry => { + const subFeatureName = findTestSubject(subFeatureEntry, 'subFeatureName').text(); + + const entryElements = findTestSubject(subFeatureEntry as ReactWrapper, 'entry', '|='); + + expect(entryElements).toHaveLength(role.kibana.length); + + role.kibana.forEach((entry, index) => { + const key = getSpaceKey(entry); + const element = findTestSubject(expandedRow as ReactWrapper, `entry-${index}`); + + const independentPrivileges = element + .find('EuiFlexGroup[data-test-subj="independentPrivilege"]') + .reduce((acc2, flexGroup) => { + const privilegeName = findTestSubject(flexGroup, 'privilegeName').text(); + const isGranted = flexGroup.exists('EuiIconTip[type="check"]'); + if (isGranted) { + return [...acc2, privilegeName]; + } + return acc2; + }, [] as string[]); + + const mutuallyExclusivePrivileges = element + .find('EuiFlexGroup[data-test-subj="mutexPrivilege"]') + .reduce((acc2, flexGroup) => { + const privilegeName = findTestSubject(flexGroup, 'privilegeName').text(); + const isGranted = flexGroup.exists('EuiIconTip[type="check"]'); + + if (isGranted) { + return [...acc2, privilegeName]; + } + return acc2; + }, [] as string[]); + + displayedFeatures[feature.id][key] = { + ...displayedFeatures[feature.id][key], + subFeaturesPrivileges: { + ...displayedFeatures[feature.id][key].subFeaturesPrivileges, + [subFeatureName]: [...independentPrivileges, ...mutuallyExclusivePrivileges], + }, + }; + }); + }); + + return displayedFeatures; +} diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/index.ts similarity index 81% rename from x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts rename to x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/index.ts index b9a64a3cc17e6..5f7dc0d99654e 100644 --- a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './dashboard_drilldown_config'; +export { PrivilegeSummary } from './privilege_summary'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.test.tsx new file mode 100644 index 0000000000000..85144d37ce754 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { PrivilegeSummary } from '.'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { PrivilegeSummaryTable } from './privilege_summary_table'; + +const createRole = (roleKibanaPrivileges: RoleKibanaPrivilege[]) => ({ + name: 'some-role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: roleKibanaPrivileges, +}); + +const spaces = [ + { + id: 'default', + name: 'Default Space', + disabledFeatures: [], + }, +]; + +describe('PrivilegeSummary', () => { + it('initially renders a button', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const role = createRole([ + { + base: ['all'], + feature: {}, + spaces: ['default'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + expect(findTestSubject(wrapper, 'viewPrivilegeSummaryButton')).toHaveLength(1); + expect(wrapper.find(PrivilegeSummaryTable)).toHaveLength(0); + }); + + it('clicking the button renders the privilege summary table', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const role = createRole([ + { + base: ['all'], + feature: {}, + spaces: ['default'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + findTestSubject(wrapper, 'viewPrivilegeSummaryButton').simulate('click'); + expect(wrapper.find(PrivilegeSummaryTable)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.tsx new file mode 100644 index 0000000000000..e0889d91d759a --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary.tsx @@ -0,0 +1,73 @@ +/* + * 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, { useState, Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiModal, + EuiButtonEmpty, + EuiOverlayMask, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiButton, +} from '@elastic/eui'; +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { Role } from '../../../../../../../common/model'; +import { PrivilegeSummaryTable } from './privilege_summary_table'; +import { KibanaPrivileges } from '../../../../model'; + +interface Props { + role: Role; + spaces: Space[]; + kibanaPrivileges: KibanaPrivileges; + canCustomizeSubFeaturePrivileges: boolean; +} +export const PrivilegeSummary = (props: Props) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + + setIsOpen(true)} data-test-subj="viewPrivilegeSummaryButton"> + + + {isOpen && ( + + setIsOpen(false)} maxWidth={false}> + + + + + + + + + + setIsOpen(false)}> + + + + + + )} + + ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.test.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.test.ts new file mode 100644 index 0000000000000..6163a6ec7ba23 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.test.ts @@ -0,0 +1,338 @@ +/* + * 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 { Role } from '../../../../../../../common/model'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { PrivilegeSummaryCalculator } from './privilege_summary_calculator'; + +const createRole = (kibana: Role['kibana'] = []): Role => { + return { + name: 'unit test role', + elasticsearch: { cluster: [], indices: [], run_as: [] }, + kibana, + }; +}; +describe('PrivilegeSummaryCalculator', () => { + describe('#getEffectiveFeaturePrivileges', () => { + it('returns an empty privilege set when nothing is assigned', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: [], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + }); + }); + + it('calculates effective privileges when inherited from the global privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: {}, + spaces: ['foo'], + }, + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: ['cool_all', 'cool_read', 'cool_toggle_1', 'cool_toggle_2'], + }, + }); + }); + + it('calculates effective privileges when there are non-superseded sub-feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['cool_excluded_toggle'], + }, + spaces: ['foo'], + }, + { + base: ['all'], + feature: { + with_sub_features: [], + }, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: true, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [ + 'cool_all', + 'cool_read', + 'cool_toggle_1', + 'cool_toggle_2', + 'cool_excluded_toggle', + ], + }, + }); + }); + + it('calculates privileges for all features for a space entry', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['read'], + feature: { + excluded_from_base: ['all'], + no_sub_features: ['read'], + with_excluded_sub_features: ['all'], + with_sub_features: ['minimal_read', 'cool_excluded_toggle'], + }, + spaces: ['foo'], + }, + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: ['cool_all', 'cool_read', 'cool_toggle_1', 'cool_toggle_2'], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: true, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [ + 'cool_all', + 'cool_read', + 'cool_toggle_1', + 'cool_toggle_2', + 'cool_excluded_toggle', + ], + }, + }); + }); + + it('calculates privileges for all features for a global entry', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: ['cool_all', 'cool_read', 'cool_toggle_1', 'cool_toggle_2'], + }, + }); + }); + + it('calculates privileges for a single feature at a space entry', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_excluded_sub_features: ['all'], + }, + spaces: ['foo'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + }); + }); + + it('calculates privileges for a single feature at the global entry', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + const role = createRole([ + { + base: [], + feature: { + with_excluded_sub_features: ['all'], + }, + spaces: ['*'], + }, + ]); + + const calculator = new PrivilegeSummaryCalculator(kibanaPrivileges, role); + expect(calculator.getEffectiveFeaturePrivileges(role.kibana[0])).toEqual({ + excluded_from_base: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + no_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + with_excluded_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: expect.objectContaining({ + id: 'all', + }), + subFeature: [], + }, + with_sub_features: { + hasCustomizedSubFeaturePrivileges: false, + primary: undefined, + subFeature: [], + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.ts new file mode 100644 index 0000000000000..27ed8c443045a --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_calculator.ts @@ -0,0 +1,109 @@ +/* + * 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 { Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import { KibanaPrivileges, PrimaryFeaturePrivilege, SecuredFeature } from '../../../../model'; +import { PrivilegeCollection } from '../../../../model/privilege_collection'; + +export interface EffectiveFeaturePrivileges { + [featureId: string]: { + primary?: PrimaryFeaturePrivilege; + subFeature: string[]; + hasCustomizedSubFeaturePrivileges: boolean; + }; +} +export class PrivilegeSummaryCalculator { + constructor(private readonly kibanaPrivileges: KibanaPrivileges, private readonly role: Role) {} + + public getEffectiveFeaturePrivileges(entry: RoleKibanaPrivilege): EffectiveFeaturePrivileges { + const assignedPrivileges = this.collectAssignedPrivileges(entry); + + const features = this.kibanaPrivileges.getSecuredFeatures(); + + return features.reduce((acc, feature) => { + const displayedPrimaryFeaturePrivilege = this.getDisplayedPrimaryFeaturePrivilege( + assignedPrivileges, + feature + ); + + const effectiveSubPrivileges = feature + .getSubFeaturePrivileges() + .filter(ap => assignedPrivileges.grantsPrivilege(ap)); + + const hasCustomizedSubFeaturePrivileges = this.hasCustomizedSubFeaturePrivileges( + feature, + displayedPrimaryFeaturePrivilege, + entry + ); + + return { + ...acc, + [feature.id]: { + primary: displayedPrimaryFeaturePrivilege, + hasCustomizedSubFeaturePrivileges, + subFeature: effectiveSubPrivileges.map(p => p.id), + }, + }; + }, {} as EffectiveFeaturePrivileges); + } + + private hasCustomizedSubFeaturePrivileges( + feature: SecuredFeature, + displayedPrimaryFeaturePrivilege: PrimaryFeaturePrivilege | undefined, + entry: RoleKibanaPrivilege + ) { + const formPrivileges = this.collectAssignedPrivileges(entry); + + return feature.getSubFeaturePrivileges().some(sfp => { + const isGranted = formPrivileges.grantsPrivilege(sfp); + const isGrantedByDisplayedPrimary = + displayedPrimaryFeaturePrivilege?.grantsPrivilege(sfp) ?? isGranted; + + // if displayed primary is derived from base, then excluded sub-feature-privs should not count. + return isGranted !== isGrantedByDisplayedPrimary; + }); + } + + private getDisplayedPrimaryFeaturePrivilege( + assignedPrivileges: PrivilegeCollection, + feature: SecuredFeature + ) { + const primaryFeaturePrivileges = feature.getPrimaryFeaturePrivileges(); + const minimalPrimaryFeaturePrivileges = feature.getMinimalFeaturePrivileges(); + + const hasMinimalPrivileges = feature.subFeatures.length > 0; + + const effectivePrivilege = primaryFeaturePrivileges.find(pfp => { + const isPrimaryGranted = assignedPrivileges.grantsPrivilege(pfp); + if (!isPrimaryGranted && hasMinimalPrivileges) { + const correspondingMinimal = minimalPrimaryFeaturePrivileges.find( + mpfp => mpfp.id === pfp.getMinimalPrivilegeId() + )!; + + return assignedPrivileges.grantsPrivilege(correspondingMinimal); + } + return isPrimaryGranted; + }); + + return effectivePrivilege; + } + + private collectAssignedPrivileges(entry: RoleKibanaPrivilege) { + if (isGlobalPrivilegeDefinition(entry)) { + return this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([entry]); + } + + const globalPrivilege = this.locateGlobalPrivilege(this.role); + return this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges( + globalPrivilege ? [globalPrivilege, entry] : [entry] + ); + } + + private locateGlobalPrivilege(role: Role) { + return role.kibana.find(entry => isGlobalPrivilegeDefinition(entry)); + } +} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_expanded_row.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_expanded_row.tsx new file mode 100644 index 0000000000000..3283f7a58a27c --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_expanded_row.tsx @@ -0,0 +1,131 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIconTip } from '@elastic/eui'; +import { SecuredFeature, SubFeaturePrivilegeGroup, SubFeaturePrivilege } from '../../../../model'; +import { EffectiveFeaturePrivileges } from './privilege_summary_calculator'; + +interface Props { + feature: SecuredFeature; + effectiveFeaturePrivileges: Array; +} + +export const PrivilegeSummaryExpandedRow = (props: Props) => { + return ( + + {props.feature.getSubFeatures().map(subFeature => { + return ( + + + + + {subFeature.name} + + + {props.effectiveFeaturePrivileges.map((privs, index) => { + return ( + + {subFeature.getPrivilegeGroups().map(renderPrivilegeGroup(privs.subFeature))} + + ); + })} + + + ); + })} + + ); + + function renderPrivilegeGroup(effectiveSubFeaturePrivileges: string[]) { + return (privilegeGroup: SubFeaturePrivilegeGroup, index: number) => { + switch (privilegeGroup.groupType) { + case 'independent': + return renderIndependentPrivilegeGroup( + effectiveSubFeaturePrivileges, + privilegeGroup, + index + ); + case 'mutually_exclusive': + return renderMutuallyExclusivePrivilegeGroup( + effectiveSubFeaturePrivileges, + privilegeGroup, + index + ); + default: + throw new Error(`Unsupported privilege group type: ${privilegeGroup.groupType}`); + } + }; + } + + function renderIndependentPrivilegeGroup( + effectiveSubFeaturePrivileges: string[], + privilegeGroup: SubFeaturePrivilegeGroup, + index: number + ) { + return ( +
+ {privilegeGroup.privileges.map((privilege: SubFeaturePrivilege) => { + const isGranted = effectiveSubFeaturePrivileges.includes(privilege.id); + return ( + + + + + + + {privilege.name} + + + + ); + })} +
+ ); + } + + function renderMutuallyExclusivePrivilegeGroup( + effectiveSubFeaturePrivileges: string[], + privilegeGroup: SubFeaturePrivilegeGroup, + index: number + ) { + const firstSelectedPrivilege = privilegeGroup.privileges.find(p => + effectiveSubFeaturePrivileges.includes(p.id) + )?.name; + + return ( + + + + + + + {firstSelectedPrivilege ?? 'None'} + + + + ); + } +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.test.tsx new file mode 100644 index 0000000000000..0498f099b536b --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.test.tsx @@ -0,0 +1,922 @@ +/* + * 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 { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { PrivilegeSummaryTable } from './privilege_summary_table'; +import { RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { getDisplayedFeaturePrivileges } from './__fixtures__'; + +const createRole = (roleKibanaPrivileges: RoleKibanaPrivilege[]) => ({ + name: 'some-role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: roleKibanaPrivileges, +}); + +const spaces = [ + { + id: 'default', + name: 'Default Space', + disabledFeatures: [], + }, + { + id: 'space-1', + name: 'First Space', + disabledFeatures: [], + }, + { + id: 'space-2', + name: 'Second Space', + disabledFeatures: [], + }, +]; + +const maybeExpectSubFeaturePrivileges = (expect: boolean, subFeaturesPrivileges: unknown) => { + return expect ? { subFeaturesPrivileges } : {}; +}; + +const expectNoPrivileges = (displayedPrivileges: any, expectSubFeatures: boolean) => { + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(expectSubFeatures, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(expectSubFeatures, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(expectSubFeatures, { + 'Cool Sub Feature': [], + }), + }, + }, + }); +}; + +describe('PrivilegeSummaryTable', () => { + [true, false].forEach(allowSubFeaturePrivileges => { + describe(`when sub feature privileges are ${ + allowSubFeaturePrivileges ? 'allowed' : 'disallowed' + }`, () => { + it('ignores unknown base privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['idk_what_this_means'], + feature: {}, + spaces: ['*'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expectNoPrivileges(displayedPrivileges, allowSubFeaturePrivileges); + }); + + it('ignores unknown feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['this_doesnt_exist_either'], + }, + spaces: ['*'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expectNoPrivileges(displayedPrivileges, allowSubFeaturePrivileges); + }); + + it('ignores unknown features', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: [], + feature: { + unknown_feature: ['this_doesnt_exist_either'], + }, + spaces: ['*'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expectNoPrivileges(displayedPrivileges, allowSubFeaturePrivileges); + }); + + it('renders effective privileges for the global base privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['all'], + feature: {}, + spaces: ['*'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for a global feature privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['all'], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['*'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for the space base privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['all'], + feature: {}, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + }, + with_excluded_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for a space feature privilege', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + }, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + }, + }, + with_excluded_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: allowSubFeaturePrivileges ? 'Read' : 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + }); + }); + + it('renders effective privileges for global base + space base privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['read'], + feature: {}, + spaces: ['*'], + }, + { + base: ['all'], + feature: {}, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'Read'], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for global base + space feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['read'], + feature: {}, + spaces: ['*'], + }, + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_all'], + }, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'Read'], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for global feature + space base privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_all'], + }, + spaces: ['*'], + }, + { + base: ['read'], + feature: {}, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: allowSubFeaturePrivileges ? 'Read' : 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['All'], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for global feature + space feature privileges', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['minimal_read', 'cool_all'], + }, + spaces: ['*'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['default', 'space-1'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: allowSubFeaturePrivileges ? 'Read' : 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['All'], + }), + }, + 'default, space-1': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + }, + }); + }); + + it('renders effective privileges for a complex setup', () => { + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures, { + allowSubFeaturePrivileges, + }); + + const role = createRole([ + { + base: ['read'], + feature: {}, + spaces: ['*'], + }, + { + base: ['read', 'all'], + feature: {}, + spaces: ['default'], + }, + { + base: [], + feature: { + with_sub_features: ['minimal_read'], + with_excluded_sub_features: ['all', 'cool_toggle_1'], + no_sub_features: ['all'], + excluded_from_base: ['minimal_all', 'cool_toggle_1'], + }, + spaces: ['space-1', 'space-2'], + }, + ]); + + const wrapper = mountWithIntl( + + ); + + const displayedPrivileges = getDisplayedFeaturePrivileges(wrapper, role); + + expect(displayedPrivileges).toEqual({ + excluded_from_base: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + default: { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': [], + }), + }, + 'space-1, space-2': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: allowSubFeaturePrivileges ? 'All' : 'None', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2'], + }), + }, + }, + no_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + }, + default: { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + 'space-1, space-2': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + }, + }, + with_excluded_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + default: { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': [], + }), + }, + 'space-1, space-2': { + hasCustomizedSubFeaturePrivileges: allowSubFeaturePrivileges, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Excluded Sub Feature': ['Cool toggle 1'], + }), + }, + }, + with_sub_features: { + '*': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'Read'], + }), + }, + default: { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'All', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 1', 'Cool toggle 2', 'All'], + }), + }, + 'space-1, space-2': { + hasCustomizedSubFeaturePrivileges: false, + primaryFeaturePrivilege: 'Read', + ...maybeExpectSubFeaturePrivileges(allowSubFeaturePrivileges, { + 'Cool Sub Feature': ['Cool toggle 2', 'Read'], + }), + }, + }, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx new file mode 100644 index 0000000000000..e04ca36b6d193 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx @@ -0,0 +1,174 @@ +/* + * 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, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiInMemoryTable, + EuiBasicTableColumn, + EuiButtonIcon, + EuiIcon, + EuiIconTip, +} from '@elastic/eui'; +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import { FeatureTableCell } from '../feature_table_cell'; +import { SpaceColumnHeader } from './space_column_header'; +import { PrivilegeSummaryExpandedRow } from './privilege_summary_expanded_row'; +import { SecuredFeature, KibanaPrivileges } from '../../../../model'; +import { + PrivilegeSummaryCalculator, + EffectiveFeaturePrivileges, +} from './privilege_summary_calculator'; + +interface Props { + role: Role; + spaces: Space[]; + kibanaPrivileges: KibanaPrivileges; + canCustomizeSubFeaturePrivileges: boolean; +} + +function getColumnKey(entry: RoleKibanaPrivilege) { + return `privilege_entry_${entry.spaces.join('|')}`; +} + +export const PrivilegeSummaryTable = (props: Props) => { + const [expandedFeatures, setExpandedFeatures] = useState([]); + + const calculator = new PrivilegeSummaryCalculator(props.kibanaPrivileges, props.role); + + const toggleExpandedFeature = (featureId: string) => { + if (expandedFeatures.includes(featureId)) { + setExpandedFeatures(expandedFeatures.filter(ef => ef !== featureId)); + } else { + setExpandedFeatures([...expandedFeatures, featureId]); + } + }; + + const featureColumn: EuiBasicTableColumn = { + name: 'Feature', + field: 'feature', + render: (feature: any) => { + return ; + }, + }; + const rowExpanderColumn: EuiBasicTableColumn = { + align: 'right', + width: '40px', + isExpander: true, + field: 'featureId', + name: '', + render: (featureId: string, record: any) => { + const feature = record.feature as SecuredFeature; + const hasSubFeaturePrivileges = feature.getSubFeaturePrivileges().length > 0; + if (!hasSubFeaturePrivileges) { + return null; + } + return ( + toggleExpandedFeature(featureId)} + data-test-subj={`expandPrivilegeSummaryRow`} + aria-label={expandedFeatures.includes(featureId) ? 'Collapse' : 'Expand'} + iconType={expandedFeatures.includes(featureId) ? 'arrowUp' : 'arrowDown'} + /> + ); + }, + }; + + const rawKibanaPrivileges = [...props.role.kibana].sort((entry1, entry2) => { + if (isGlobalPrivilegeDefinition(entry1)) { + return -1; + } + if (isGlobalPrivilegeDefinition(entry2)) { + return 1; + } + return 0; + }); + const privilegeColumns = rawKibanaPrivileges.map(entry => { + const key = getColumnKey(entry); + return { + name: , + field: key, + render: (kibanaPrivilege: EffectiveFeaturePrivileges, record: { featureId: string }) => { + const { primary, hasCustomizedSubFeaturePrivileges } = kibanaPrivilege[record.featureId]; + let iconTip = null; + if (hasCustomizedSubFeaturePrivileges) { + iconTip = ( + + + + } + /> + ); + } else { + iconTip = ; + } + return ( + + {primary?.name ?? 'None'} {iconTip} + + ); + }, + }; + }); + + const columns: Array> = []; + if (props.canCustomizeSubFeaturePrivileges) { + columns.push(rowExpanderColumn); + } + columns.push(featureColumn, ...privilegeColumns); + + const privileges = rawKibanaPrivileges.reduce((acc, entry) => { + return { + ...acc, + [getColumnKey(entry)]: calculator.getEffectiveFeaturePrivileges(entry), + }; + }, {} as Record); + + const items = props.kibanaPrivileges.getSecuredFeatures().map(feature => { + return { + feature, + featureId: feature.id, + ...privileges, + }; + }); + + return ( + { + return { + 'data-test-subj': `summaryTableRow-${record.featureId}`, + }; + }} + itemIdToExpandedRowMap={expandedFeatures.reduce((acc, featureId) => { + return { + ...acc, + [featureId]: ( + p[featureId])} + /> + ), + }; + }, {})} + /> + ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.test.tsx new file mode 100644 index 0000000000000..b691056528498 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.test.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { SpaceColumnHeader } from './space_column_header'; +import { SpacesPopoverList } from '../../../spaces_popover_list'; +import { SpaceAvatar } from '../../../../../../../../spaces/public'; + +const spaces = [ + { + id: '*', + name: 'Global', + disabledFeatures: [], + }, + { + id: 'space-1', + name: 'Space 1', + disabledFeatures: [], + }, + { + id: 'space-2', + name: 'Space 2', + disabledFeatures: [], + }, + { + id: 'space-3', + name: 'Space 3', + disabledFeatures: [], + }, + { + id: 'space-4', + name: 'Space 4', + disabledFeatures: [], + }, + { + id: 'space-5', + name: 'Space 5', + disabledFeatures: [], + }, +]; + +describe('SpaceColumnHeader', () => { + it('renders the Global privilege definition with a special label and popover control', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(SpacesPopoverList)).toHaveLength(1); + // Snapshot includes space avatar (The first "G"), followed by the "Global" label, + // followed by the (all spaces) text as part of the SpacesPopoverList + expect(wrapper.text()).toMatchInlineSnapshot(`"G Global(all spaces)"`); + }); + + it('renders a placeholder space when the requested space no longer exists', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(SpacesPopoverList)).toHaveLength(0); + + const avatars = wrapper.find(SpaceAvatar); + expect(avatars).toHaveLength(3); + + expect(wrapper.text()).toMatchInlineSnapshot(`"S1 m S3 "`); + }); + + it('renders a space privilege definition with an avatar for each space in the group', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(SpacesPopoverList)).toHaveLength(0); + + const avatars = wrapper.find(SpaceAvatar); + expect(avatars).toHaveLength(4); + + expect(wrapper.text()).toMatchInlineSnapshot(`"S1 S2 S3 S4 "`); + }); + + it('renders a space privilege definition with an avatar for the first 4 spaces in the group, with the popover control showing the rest', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(SpacesPopoverList)).toHaveLength(1); + + const avatars = wrapper.find(SpaceAvatar); + expect(avatars).toHaveLength(4); + + expect(wrapper.text()).toMatchInlineSnapshot(`"S1 S2 S3 S4 +1 more"`); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.tsx new file mode 100644 index 0000000000000..8ed9bb449b595 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/space_column_header.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { Space, SpaceAvatar } from '../../../../../../../../spaces/public'; +import { RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import { SpacesPopoverList } from '../../../spaces_popover_list'; + +interface Props { + spaces: Space[]; + entry: RoleKibanaPrivilege; +} + +const SPACES_DISPLAY_COUNT = 4; + +export const SpaceColumnHeader = (props: Props) => { + const isGlobal = isGlobalPrivilegeDefinition(props.entry); + const entrySpaces = props.entry.spaces.map(spaceId => { + return ( + props.spaces.find(s => s.id === spaceId) ?? { + id: spaceId, + name: spaceId, + disabledFeatures: [], + } + ); + }); + return ( +
+ {entrySpaces.slice(0, SPACES_DISPLAY_COUNT).map(space => { + return ( + + {' '} + {isGlobal && ( + + +
+ s.id !== '*')} + buttonText={i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeMatrix.showAllSpacesLink', + { + defaultMessage: '(all spaces)', + } + )} + /> +
+ )} +
+ ); + })} + {entrySpaces.length > SPACES_DISPLAY_COUNT && ( + +
+ +
+ )} +
+ ); +}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/__snapshots__/simple_privilege_section.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/__snapshots__/simple_privilege_section.test.tsx.snap index 4d8f590f286ae..7873e47d2e0ff 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/__snapshots__/simple_privilege_section.test.tsx.snap +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/__snapshots__/simple_privilege_section.test.tsx.snap @@ -2,153 +2,159 @@ exports[` renders without crashing 1`] = ` - - -

- } - title={ -

- -

- } + - - + +

+ +

+
+ + + - + hasChildLabel={true} + hasEmptyLabelSpace={false} + label={ + + } + labelType="label" + > + + + + +

+ +

+ , + "inputDisplay": -
-

- -

- , - "inputDisplay": - - , - "value": "none", - }, - Object { - "dropdownDisplay": - + , + "value": "none", + }, + Object { + "dropdownDisplay": + + + +

+ +

+
, + "inputDisplay": -
-

- -

- , - "inputDisplay": - - , - "value": "custom", - }, - Object { - "dropdownDisplay": - + , + "value": "custom", + }, + Object { + "dropdownDisplay": + + + +

+ +

+
, + "inputDisplay": -
-

- -

- , - "inputDisplay": - - , - "value": "read", - }, - Object { - "dropdownDisplay": - + , + "value": "read", + }, + Object { + "dropdownDisplay": + + + +

+ +

+
, + "inputDisplay": -
-

- -

- , - "inputDisplay": - - , - "value": "all", - }, - ] - } - valueOfSelected="none" - /> -
-
+ , + "value": "all", + }, + ] + } + valueOfSelected="none" + /> + + +
`; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx index db1e3cfd61621..7ecf32ee45b85 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.test.tsx @@ -7,24 +7,53 @@ import { EuiButtonGroup, EuiButtonGroupProps, EuiComboBox, EuiSuperSelect } from '@elastic/eui'; import React from 'react'; import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { Feature } from '../../../../../../../../features/public'; -import { KibanaPrivileges, Role } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; +import { Role } from '../../../../../../../common/model'; import { SimplePrivilegeSection } from './simple_privilege_section'; import { UnsupportedSpacePrivilegesWarning } from './unsupported_space_privileges_warning'; +import { KibanaPrivileges, SecuredFeature } from '../../../../model'; const buildProps = (customProps: any = {}) => { - const kibanaPrivileges = new KibanaPrivileges({ - features: { - feature1: { - all: ['*'], - read: ['read'], + const features = [ + new SecuredFeature({ + id: 'feature1', + name: 'Feature 1', + app: ['app'], + icon: 'spacesApp', + privileges: { + all: { + app: ['app'], + savedObject: { + all: ['foo'], + read: [], + }, + ui: ['app-ui'], + }, + read: { + app: ['app'], + savedObject: { + all: [], + read: [], + }, + ui: ['app-ui'], + }, }, + }), + ] as SecuredFeature[]; + + const kibanaPrivileges = new KibanaPrivileges( + { + features: { + feature1: { + all: ['*'], + read: ['read'], + }, + }, + global: {}, + space: {}, + reserved: {}, }, - global: {}, - space: {}, - reserved: {}, - }); + features + ); const role = { name: '', @@ -40,34 +69,9 @@ const buildProps = (customProps: any = {}) => { return { editable: true, kibanaPrivileges, - privilegeCalculatorFactory: new KibanaPrivilegeCalculatorFactory(kibanaPrivileges), - features: [ - { - id: 'feature1', - name: 'Feature 1', - app: ['app'], - icon: 'spacesApp', - privileges: { - all: { - app: ['app'], - savedObject: { - all: ['foo'], - read: [], - }, - ui: ['app-ui'], - }, - read: { - app: ['app'], - savedObject: { - all: [], - read: [], - }, - ui: ['app-ui'], - }, - }, - }, - ] as Feature[], + features, onChange: jest.fn(), + canCustomizeSubFeaturePrivileges: true, ...customProps, role, }; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx index 2221fc6bab279..d68d43e8089c7 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/simple_privilege_section/simple_privilege_section.tsx @@ -6,34 +6,28 @@ import { EuiComboBox, - EuiDescribedFormGroup, EuiFormRow, EuiSuperSelect, EuiText, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component, Fragment } from 'react'; - -import { Feature } from '../../../../../../../../features/public'; -import { - KibanaPrivileges, - Role, - RoleKibanaPrivilege, - copyRole, -} from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; +import { Role, RoleKibanaPrivilege, copyRole } from '../../../../../../../common/model'; import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; import { CUSTOM_PRIVILEGE_VALUE, NO_PRIVILEGE_VALUE } from '../constants'; import { FeatureTable } from '../feature_table'; import { UnsupportedSpacePrivilegesWarning } from './unsupported_space_privileges_warning'; +import { KibanaPrivileges } from '../../../../model'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; interface Props { role: Role; kibanaPrivileges: KibanaPrivileges; - privilegeCalculatorFactory: KibanaPrivilegeCalculatorFactory; - features: Feature[]; onChange: (role: Role) => void; editable: boolean; + canCustomizeSubFeaturePrivileges: boolean; } interface State { @@ -58,20 +52,14 @@ export class SimplePrivilegeSection extends Component { public render() { const kibanaPrivilege = this.getDisplayedBasePrivilege(); - const privilegeCalculator = this.props.privilegeCalculatorFactory.getInstance(this.props.role); - - const calculatedPrivileges = privilegeCalculator.calculateEffectivePrivileges()[ - this.state.globalPrivsIndex - ]; - - const allowedPrivileges = privilegeCalculator.calculateAllowedPrivileges()[ - this.state.globalPrivsIndex - ]; + const reservedPrivileges = this.props.role.kibana[this.state.globalPrivsIndex]?._reserved ?? []; - const hasReservedPrivileges = - calculatedPrivileges && - calculatedPrivileges.reserved != null && - calculatedPrivileges.reserved.length > 0; + const title = ( + + ); const description = (

@@ -84,162 +72,159 @@ export class SimplePrivilegeSection extends Component { return ( - - - - } - description={description} - > - - {hasReservedPrivileges ? ( - ({ - label: privilege, - }))} - isDisabled - /> - ) : ( - - - - ), - dropdownDisplay: ( - - + + + + {description} + + + + + {reservedPrivileges.length > 0 ? ( + ({ label: rp }))} + isDisabled + /> + ) : ( + - -

- -

- - ), - }, - { - value: CUSTOM_PRIVILEGE_VALUE, - inputDisplay: ( - - - - ), - dropdownDisplay: ( - - + + ), + dropdownDisplay: ( + + + + +

+ +

+
+ ), + }, + { + value: CUSTOM_PRIVILEGE_VALUE, + inputDisplay: ( + -
-

+ + ), + dropdownDisplay: ( + + + + +

+ +

+ + ), + }, + { + value: 'read', + inputDisplay: ( + -

-
- ), - }, - { - value: 'read', - inputDisplay: ( - - - - ), - dropdownDisplay: ( - - - - -

- -

-
- ), - }, - { - value: 'all', - inputDisplay: ( - - - - ), - dropdownDisplay: ( - - + + ), + dropdownDisplay: ( + + + + +

+ +

+
+ ), + }, + { + value: 'all', + inputDisplay: ( + -
-

- -

- - ), - }, - ]} - hasDividers - valueOfSelected={kibanaPrivilege} - /> - )} - - {this.state.isCustomizingGlobalPrivilege && ( - - isGlobalPrivilegeDefinition(k))} - /> + + ), + dropdownDisplay: ( + + + + +

+ +

+
+ ), + }, + ]} + hasDividers + valueOfSelected={kibanaPrivilege} + /> + )}
- )} - {this.maybeRenderSpacePrivilegeWarning()} - + {this.state.isCustomizingGlobalPrivilege && ( + + + isGlobalPrivilegeDefinition(k) + )} + canCustomizeSubFeaturePrivileges={this.props.canCustomizeSubFeaturePrivileges} + /> + + )} + {this.maybeRenderSpacePrivilegeWarning()} + + ); } @@ -295,7 +280,7 @@ export class SimplePrivilegeSection extends Component { const form = this.locateGlobalPrivilege(role) || this.createGlobalPrivilegeEntry(role); if (privileges.length > 0) { - this.props.features.forEach(feature => { + this.props.kibanaPrivileges.getSecuredFeatures().forEach(feature => { form.feature[feature.id] = [...privileges]; }); } else { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/index.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/index.ts deleted file mode 100644 index 09e449f61356f..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/index.ts +++ /dev/null @@ -1,7 +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. - */ - -export { rawKibanaPrivileges } from './raw_kibana_privileges'; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/raw_kibana_privileges.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/raw_kibana_privileges.ts deleted file mode 100644 index 428836c9f181b..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__fixtures__/raw_kibana_privileges.ts +++ /dev/null @@ -1,38 +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 { RawKibanaPrivileges } from '../../../../../../../../common/model'; - -export const rawKibanaPrivileges: RawKibanaPrivileges = { - global: { - all: [ - 'normal-feature-all', - 'normal-feature-read', - 'just-global-all', - 'all-privilege-excluded-from-base-read', - ], - read: ['normal-feature-read', 'all-privilege-excluded-from-base-read'], - }, - space: { - all: ['normal-feature-all', 'normal-feature-read', 'all-privilege-excluded-from-base-read'], - read: ['normal-feature-read', 'all-privilege-excluded-from-base-read'], - }, - reserved: {}, - features: { - normal: { - all: ['normal-feature-all', 'normal-feature-read'], - read: ['normal-feature-read'], - }, - bothPrivilegesExcludedFromBase: { - all: ['both-privileges-excluded-from-base-all', 'both-privileges-excluded-from-base-read'], - read: ['both-privileges-excluded-from-base-read'], - }, - allPrivilegeExcludedFromBase: { - all: ['all-privilege-excluded-from-base-all', 'all-privilege-excluded-from-base-read'], - read: ['all-privilege-excluded-from-base-read'], - }, - }, -}; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_display.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_display.test.tsx.snap deleted file mode 100644 index a3fbdebee7eba..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_display.test.tsx.snap +++ /dev/null @@ -1,118 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PrivilegeDisplay renders a superceded privilege 1`] = ` - -`; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_space_form.test.tsx.snap b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_space_form.test.tsx.snap deleted file mode 100644 index 8d10e27df9694..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/__snapshots__/privilege_space_form.test.tsx.snap +++ /dev/null @@ -1,497 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` renders without crashing 1`] = ` - - - - -

- -

-
-
- - - - - - - - - - -

- -

- , - "inputDisplay": - - , - "value": "basePrivilege_custom", - }, - Object { - "disabled": false, - "dropdownDisplay": - - - -

- -

-
, - "inputDisplay": - - , - "value": "basePrivilege_read", - }, - Object { - "dropdownDisplay": - - - -

- -

-
, - "inputDisplay": - - , - "value": "basePrivilege_all", - }, - ] - } - valueOfSelected="basePrivilege_custom" - /> -
- - -

- Customize by feature -

-
- - -

- Increase privilege levels on a per feature basis. Some features might be hidden by the space or affected by a global space privilege. -

-
- - -
-
- - - - - - - - - - - - - - -
-
-`; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.test.tsx index c6268e19abfd1..155ccf98b9762 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.test.tsx @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiIconTip, EuiText, EuiToolTip } from '@elastic/eui'; +import { EuiText } from '@elastic/eui'; import React from 'react'; -import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { PRIVILEGE_SOURCE } from '../kibana_privilege_calculator'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { PrivilegeDisplay } from './privilege_display'; describe('PrivilegeDisplay', () => { @@ -23,41 +22,4 @@ describe('PrivilegeDisplay', () => { color: 'danger', }); }); - - it('renders a privilege with tooltip, if provided', () => { - const wrapper = mountWithIntl( - ahh} /> - ); - expect(wrapper.text().trim()).toEqual('All'); - expect(wrapper.find(EuiToolTip).props()).toMatchObject({ - content: ahh, - }); - }); - - it('renders a privilege with icon tooltip, if provided', () => { - const wrapper = mountWithIntl( - ahh} iconType={'asterisk'} /> - ); - expect(wrapper.text().trim()).toEqual('All'); - expect(wrapper.find(EuiIconTip).props()).toMatchObject({ - type: 'asterisk', - content: ahh, - }); - }); - - it('renders a superceded privilege', () => { - const wrapper = shallowWithIntl( - - ); - expect(wrapper).toMatchSnapshot(); - }); }); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.tsx index 55ac99da4c8c1..93f1d9bba460d 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_display.tsx @@ -3,95 +3,28 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiIcon, EuiIconTip, EuiText, IconType, PropsOf, EuiToolTip } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiIcon, EuiText, PropsOf } from '@elastic/eui'; import _ from 'lodash'; import React, { ReactNode, FC } from 'react'; -import { PRIVILEGE_SOURCE, PrivilegeExplanation } from '../kibana_privilege_calculator'; import { NO_PRIVILEGE_VALUE } from '../constants'; interface Props extends PropsOf { privilege: string | string[] | undefined; - explanation?: PrivilegeExplanation; - iconType?: IconType; - iconTooltipContent?: ReactNode; - tooltipContent?: ReactNode; + 'data-test-subj'?: string; } export const PrivilegeDisplay: FC = (props: Props) => { - const { explanation } = props; - - if (!explanation) { - return ; - } - - if (explanation.supersededPrivilege) { - return ; - } - - if (!explanation.isDirectlyAssigned) { - return ; - } - return ; }; const SimplePrivilegeDisplay: FC = (props: Props) => { - const { privilege, iconType, iconTooltipContent, explanation, tooltipContent, ...rest } = props; - - const text = ( - - {getDisplayValue(privilege)} {getIconTip(iconType, iconTooltipContent)} - - ); + const { privilege, ...rest } = props; - if (tooltipContent) { - return {text}; - } + const text = {getDisplayValue(privilege)}; return text; }; -export const SupersededPrivilegeDisplay: FC = (props: Props) => { - const { supersededPrivilege, actualPrivilegeSource } = - props.explanation || ({} as PrivilegeExplanation); - - return ( - - } - /> - ); -}; - -export const EffectivePrivilegeDisplay: FC = (props: Props) => { - const { explanation, ...rest } = props; - - const source = getReadablePrivilegeSource(explanation!.actualPrivilegeSource); - - const iconTooltipContent = ( - - ); - - return ( - - ); -}; - PrivilegeDisplay.defaultProps = { privilege: [], }; @@ -113,24 +46,6 @@ function getDisplayValue(privilege: string | string[] | undefined) { return displayValue; } -function getIconTip(iconType?: IconType, tooltipContent?: ReactNode) { - if (!iconType || !tooltipContent) { - return null; - } - - return ( - - ); -} - function coerceToArray(privilege: string | string[] | undefined): string[] { if (privilege === undefined) { return []; @@ -140,43 +55,3 @@ function coerceToArray(privilege: string | string[] | undefined): string[] { } return [privilege]; } - -function getReadablePrivilegeSource(privilegeSource: PRIVILEGE_SOURCE) { - switch (privilegeSource) { - case PRIVILEGE_SOURCE.GLOBAL_BASE: - return ( - - ); - case PRIVILEGE_SOURCE.GLOBAL_FEATURE: - return ( - - ); - case PRIVILEGE_SOURCE.SPACE_BASE: - return ( - - ); - case PRIVILEGE_SOURCE.SPACE_FEATURE: - return ( - - ); - default: - return ( - - ); - } -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.test.tsx deleted file mode 100644 index a01c026c1a5df..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.test.tsx +++ /dev/null @@ -1,128 +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 { EuiButtonEmpty, EuiInMemoryTable } from '@elastic/eui'; -import React from 'react'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { Space } from '../../../../../../../../spaces/public'; -import { Feature } from '../../../../../../../../features/public'; -import { KibanaPrivileges, Role } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; -import { PrivilegeMatrix } from './privilege_matrix'; - -describe('PrivilegeMatrix', () => { - it('can render a complex matrix', () => { - const spaces: Space[] = ['*', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'].map(a => ({ - id: a, - name: `${a} space`, - disabledFeatures: [], - })); - - const features: Feature[] = [ - { - id: 'feature1', - name: 'feature 1', - icon: 'apmApp', - app: [], - privileges: {}, - }, - { - id: 'feature2', - name: 'feature 2', - icon: 'apmApp', - app: [], - privileges: {}, - }, - { - id: 'feature3', - name: 'feature 3', - icon: 'apmApp', - app: [], - privileges: {}, - }, - ]; - - const role: Role = { - name: 'role', - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - spaces: ['*'], - base: ['read'], - feature: { - feature1: ['all'], - }, - }, - { - spaces: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'], - base: [], - feature: { - feature2: ['read'], - feature3: ['all'], - }, - }, - { - spaces: ['k'], - base: ['all'], - feature: { - feature2: ['read'], - feature3: ['read'], - }, - }, - ], - }; - - const calculator = new KibanaPrivilegeCalculatorFactory( - new KibanaPrivileges({ - global: { - all: [], - read: [], - }, - features: { - feature1: { - all: [], - read: [], - }, - feature2: { - all: [], - read: [], - }, - feature3: { - all: [], - read: [], - }, - }, - space: { - all: [], - read: [], - }, - reserved: {}, - }) - ).getInstance(role); - - const wrapper = mountWithIntl( - - ); - - wrapper.find(EuiButtonEmpty).simulate('click'); - wrapper.update(); - - const { columns, items } = wrapper.find(EuiInMemoryTable).props() as any; - - expect(columns).toHaveLength(4); // all spaces groups plus the "feature" column - expect(items).toHaveLength(features.length + 1); // all features plus the "base" row - }); -}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.tsx deleted file mode 100644 index f0f425273e25d..0000000000000 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_matrix.tsx +++ /dev/null @@ -1,342 +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 { - EuiButton, - EuiButtonEmpty, - EuiIcon, - EuiIconTip, - EuiInMemoryTable, - EuiModal, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, - EuiOverlayMask, - IconType, -} from '@elastic/eui'; -import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react'; -import React, { Component, Fragment } from 'react'; -import { Space, SpaceAvatar } from '../../../../../../../../spaces/public'; -import { Feature } from '../../../../../../../../features/public'; -import { FeaturesPrivileges, Role } from '../../../../../../../common/model'; -import { CalculatedPrivilege } from '../kibana_privilege_calculator'; -import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; -import { SpacesPopoverList } from '../../../spaces_popover_list'; -import { PrivilegeDisplay } from './privilege_display'; - -const SPACES_DISPLAY_COUNT = 4; - -interface Props { - role: Role; - spaces: Space[]; - features: Feature[]; - calculatedPrivileges: CalculatedPrivilege[]; - intl: InjectedIntl; -} - -interface State { - showModal: boolean; -} - -interface TableRow { - feature: Feature & { isBase: boolean }; - tooltip?: string; - role: Role; -} - -interface SpacesColumn { - isGlobal: boolean; - spacesIndex: number; - spaces: Space[]; - privileges: { - base: string[]; - feature: FeaturesPrivileges; - }; -} - -export class PrivilegeMatrix extends Component { - public state = { - showModal: false, - }; - public render() { - let modal = null; - if (this.state.showModal) { - modal = ( - - - - - - - - {this.renderTable()} - - - - - - - - ); - } - - return ( - - - - - {modal} - - ); - } - - private renderTable = () => { - const { role, features, intl } = this.props; - - const spacePrivileges = role.kibana; - - const globalPrivilege = this.locateGlobalPrivilege(); - - const spacesColumns: SpacesColumn[] = []; - - spacePrivileges.forEach((spacePrivs, spacesIndex) => { - spacesColumns.push({ - isGlobal: isGlobalPrivilegeDefinition(spacePrivs), - spacesIndex, - spaces: spacePrivs.spaces - .map(spaceId => this.props.spaces.find(space => space.id === spaceId)) - .filter(Boolean) as Space[], - privileges: { - base: spacePrivs.base, - feature: spacePrivs.feature, - }, - }); - }); - - const rows: TableRow[] = [ - { - feature: { - id: '*base*', - isBase: true, - name: intl.formatMessage({ - id: 'xpack.security.management.editRole.spacePrivilegeMatrix.basePrivilegeText', - defaultMessage: 'Base privilege', - }), - app: [], - privileges: {}, - }, - role, - }, - ...features.map(feature => ({ - feature: { - ...feature, - isBase: false, - }, - role, - })), - ]; - - const columns = [ - { - field: 'feature', - name: intl.formatMessage({ - id: 'xpack.security.management.editRole.spacePrivilegeMatrix.featureColumnTitle', - defaultMessage: 'Feature', - }), - width: '230px', - render: (feature: Feature & { isBase: boolean }) => { - return feature.isBase ? ( - - {feature.name} - - - ) : ( - - {feature.icon && ( - - )} - {feature.name} - - ); - }, - }, - ...spacesColumns.map(item => { - let columnWidth; - if (item.isGlobal) { - columnWidth = '100px'; - } else if (item.spaces.length - SPACES_DISPLAY_COUNT) { - columnWidth = '90px'; - } else { - columnWidth = '80px'; - } - - return { - // TODO: this is a hacky way to determine if we are looking at the global feature - // used for cellProps below... - field: item.isGlobal ? 'global' : 'feature', - width: columnWidth, - name: ( -
- {item.spaces.slice(0, SPACES_DISPLAY_COUNT).map((space: Space) => ( - - {' '} - {item.isGlobal && ( - - -
- s.id !== '*')} - intl={this.props.intl} - buttonText={this.props.intl.formatMessage({ - id: - 'xpack.security.management.editRole.spacePrivilegeMatrix.showAllSpacesLink', - defaultMessage: '(all spaces)', - })} - /> -
- )} -
- ))} - {item.spaces.length > SPACES_DISPLAY_COUNT && ( - -
- -
- )} -
- ), - render: (feature: Feature & { isBase: boolean }, record: TableRow) => { - return this.renderPrivilegeDisplay(item, record, globalPrivilege.base); - }, - }; - }), - ]; - - return ( - { - return { - className: item.feature.isBase ? 'secPrivilegeMatrix__row--isBasePrivilege' : '', - }; - }} - cellProps={(item: TableRow, column: Record) => { - return { - className: - column.field === 'global' ? 'secPrivilegeMatrix__cell--isGlobalPrivilege' : '', - }; - }} - /> - ); - }; - - private renderPrivilegeDisplay = ( - column: SpacesColumn, - { feature }: TableRow, - globalBasePrivilege: string[] - ) => { - if (column.isGlobal) { - if (feature.isBase) { - return ; - } - - const featureCalculatedPrivilege = this.props.calculatedPrivileges[column.spacesIndex] - .feature[feature.id]; - - return ( - - ); - } else { - // not global - - const calculatedPrivilege = this.props.calculatedPrivileges[column.spacesIndex]; - - if (feature.isBase) { - // Space base privilege - const actualBasePrivileges = calculatedPrivilege.base.actualPrivilege; - - return ( - - ); - } - - const featurePrivilegeExplanation = calculatedPrivilege.feature[feature.id]; - - return ( - - ); - } - }; - - private locateGlobalPrivilege = () => { - return ( - this.props.role.kibana.find(spacePriv => isGlobalPrivilegeDefinition(spacePriv)) || { - spaces: ['*'], - base: [], - feature: [], - } - ); - }; - - private hideModal = () => { - this.setState({ - showModal: false, - }); - }; - - private showModal = () => { - this.setState({ - showModal: true, - }); - }; -} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx index 675f02a81f9e1..968730181fe10 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx @@ -4,123 +4,379 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { merge } from 'lodash'; -// @ts-ignore -import { findTestSubject } from '@elastic/eui/lib/test'; -import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; -import { KibanaPrivileges } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; +import { Role } from '../../../../../../../common/model'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { PrivilegeSpaceForm } from './privilege_space_form'; -import { rawKibanaPrivileges } from './__fixtures__'; +import React from 'react'; +import { Space } from '../../../../../../../../spaces/public'; +import { EuiSuperSelect } from '@elastic/eui'; +import { FeatureTable } from '../feature_table'; +import { getDisplayedFeaturePrivileges } from '../feature_table/__fixtures__'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { SpaceSelector } from './space_selector'; -type RecursivePartial = { - [P in keyof T]?: RecursivePartial; +const createRole = (kibana: Role['kibana'] = []): Role => { + return { + name: 'my_role', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana, + }; }; -const buildProps = ( - overrides?: RecursivePartial -): PrivilegeSpaceForm['props'] => { - const kibanaPrivileges = new KibanaPrivileges(rawKibanaPrivileges); - const defaultProps: PrivilegeSpaceForm['props'] = { - spaces: [ +const displaySpaces: Space[] = [ + { + id: 'foo', + name: 'Foo Space', + disabledFeatures: [], + }, + { + id: 'default', + name: 'Default Space', + disabledFeatures: [], + }, + { + id: '*', + name: 'Global', + disabledFeatures: [], + }, +]; + +describe('PrivilegeSpaceForm', () => { + it('renders an empty form when the role contains no Kibana privileges', () => { + const role = createRole(); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiSuperSelect).props().valueOfSelected).toEqual(`basePrivilege_custom`); + expect(wrapper.find(FeatureTable).props().disabled).toEqual(true); + expect(getDisplayedFeaturePrivileges(wrapper)).toMatchInlineSnapshot(` + Object { + "excluded_from_base": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "no_sub_features": Object { + "primaryFeaturePrivilege": "none", + }, + "with_excluded_sub_features": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "with_sub_features": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + } + `); + + expect(findTestSubject(wrapper, 'spaceFormGlobalPermissionsSupersedeWarning')).toHaveLength(0); + }); + + it('renders when a base privilege is selected', () => { + const role = createRole([ { - id: 'default', - name: 'Default Space', - description: '', - disabledFeatures: [], - _reserved: true, + base: ['all'], + feature: {}, + spaces: ['foo'], }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiSuperSelect).props().valueOfSelected).toEqual(`basePrivilege_all`); + expect(wrapper.find(FeatureTable).props().disabled).toEqual(true); + expect(getDisplayedFeaturePrivileges(wrapper)).toMatchInlineSnapshot(` + Object { + "excluded_from_base": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "no_sub_features": Object { + "primaryFeaturePrivilege": "all", + }, + "with_excluded_sub_features": Object { + "primaryFeaturePrivilege": "all", + "subFeaturePrivileges": Array [], + }, + "with_sub_features": Object { + "primaryFeaturePrivilege": "all", + "subFeaturePrivileges": Array [ + "with_sub_features_cool_toggle_1", + "with_sub_features_cool_toggle_2", + "cool_all", + ], + }, + } + `); + + expect(findTestSubject(wrapper, 'spaceFormGlobalPermissionsSupersedeWarning')).toHaveLength(0); + }); + + it('renders when a feature privileges are selected', () => { + const role = createRole([ { - id: 'marketing', - name: 'Marketing', - description: '', - disabledFeatures: [], + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], }, - ], - kibanaPrivileges, - privilegeCalculatorFactory: new KibanaPrivilegeCalculatorFactory(kibanaPrivileges), - features: [], - role: { - name: 'test role', - elasticsearch: { - cluster: ['all'], - indices: [] as any[], - run_as: [] as string[], + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiSuperSelect).props().valueOfSelected).toEqual(`basePrivilege_custom`); + expect(wrapper.find(FeatureTable).props().disabled).toEqual(false); + expect(getDisplayedFeaturePrivileges(wrapper)).toMatchInlineSnapshot(` + Object { + "excluded_from_base": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "no_sub_features": Object { + "primaryFeaturePrivilege": "none", + }, + "with_excluded_sub_features": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "with_sub_features": Object { + "primaryFeaturePrivilege": "read", + "subFeaturePrivileges": Array [ + "with_sub_features_cool_toggle_2", + "cool_read", + ], + }, + } + `); + + expect(findTestSubject(wrapper, 'spaceFormGlobalPermissionsSupersedeWarning')).toHaveLength(0); + }); + + it('renders a warning when configuring a global privilege after space privileges are already defined', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], }, - kibana: [{ spaces: [], base: [], feature: {} }], - }, - onChange: jest.fn(), - onCancel: jest.fn(), - intl: {} as any, - editingIndex: 0, - }; - return merge(defaultProps, overrides || {}); -}; + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['*'], + }, + ]); + + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const wrapper = mountWithIntl( + + ); + + wrapper + .find(SpaceSelector) + .props() + .onChange(['*']); + + wrapper.update(); -describe('', () => { - it('renders without crashing', () => { - expect(shallowWithIntl()).toMatchSnapshot(); + expect(findTestSubject(wrapper, 'globalPrivilegeWarning')).toHaveLength(1); }); - it(`defaults to "Custom" for new global entries`, () => { - const props = buildProps({ - role: { - kibana: [ - { - spaces: ['*'], - base: [], - feature: {}, - }, - ], + it('renders a warning when space privileges are less permissive than configured global privileges', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['*'], }, - editingIndex: 0, - }); - const component = mountWithIntl(); - const basePrivilegeComboBox = findTestSubject(component, `basePrivilegeComboBox`); - expect(basePrivilegeComboBox.text()).toBe('Custom'); + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(EuiSuperSelect).props().valueOfSelected).toEqual(`basePrivilege_custom`); + expect(wrapper.find(FeatureTable).props().disabled).toEqual(false); + expect(getDisplayedFeaturePrivileges(wrapper)).toMatchInlineSnapshot(` + Object { + "excluded_from_base": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "no_sub_features": Object { + "primaryFeaturePrivilege": "none", + }, + "with_excluded_sub_features": Object { + "primaryFeaturePrivilege": "none", + "subFeaturePrivileges": Array [], + }, + "with_sub_features": Object { + "primaryFeaturePrivilege": "read", + "subFeaturePrivileges": Array [ + "with_sub_features_cool_toggle_2", + "cool_read", + ], + }, + } + `); + + expect(findTestSubject(wrapper, 'spaceFormGlobalPermissionsSupersedeWarning')).toHaveLength(1); + expect(findTestSubject(wrapper, 'globalPrivilegeWarning')).toHaveLength(0); }); - it(`defaults to "Custom" for new space entries`, () => { - const props = buildProps({ - role: { - kibana: [ - { - spaces: ['space:default'], - base: [], - feature: {}, - }, - ], + it('allows all feature privileges to be changed via "change all"', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all', 'with_sub_features_cool_toggle_2', 'cool_read'], + }, + spaces: ['foo'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], }, - editingIndex: 0, - }); - const component = mountWithIntl(); - const basePrivilegeComboBox = findTestSubject(component, `basePrivilegeComboBox`); - expect(basePrivilegeComboBox.text()).toBe('Custom'); + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const onChange = jest.fn(); + + const wrapper = mountWithIntl( + + ); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-read').simulate('click'); + findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click'); + + expect(onChange).toHaveBeenCalledWith( + createRole([ + { + base: [], + feature: { + excluded_from_base: ['read'], + with_excluded_sub_features: ['read'], + no_sub_features: ['read'], + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + // this set remains unchanged from the original + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]) + ); }); - describe('when an existing global all privilege', () => { - it(`defaults to "Custom" for new entries`, () => { - const props = buildProps({ - role: { - kibana: [ - { - spaces: ['*'], - base: ['all'], - feature: {}, - }, - { - spaces: ['default'], - base: [], - feature: {}, - }, - ], + it('passes the `canCustomizeSubFeaturePrivileges` prop to the FeatureTable', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all'], }, - editingIndex: 1, - }); - const component = mountWithIntl(); - const basePrivilegeComboBox = findTestSubject(component, `basePrivilegeComboBox`); - expect(basePrivilegeComboBox.text()).toBe('Custom'); - }); + spaces: ['foo'], + }, + ]); + const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures); + + const onChange = jest.fn(); + + const canCustomize = (Symbol('can customize') as unknown) as boolean; + + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find(FeatureTable).props().canCustomizeSubFeaturePrivileges).toBe(canCustomize); }); }); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx index 6f841b5d14cb3..4e9e02bb531f1 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx @@ -21,46 +21,42 @@ import { EuiSuperSelect, EuiText, EuiTitle, + EuiErrorBoundary, } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component, Fragment } from 'react'; import { Space } from '../../../../../../../../spaces/public'; -import { Feature } from '../../../../../../../../features/public'; -import { KibanaPrivileges, Role, copyRole } from '../../../../../../../common/model'; -import { - AllowedPrivilege, - KibanaPrivilegeCalculatorFactory, - PrivilegeExplanation, -} from '../kibana_privilege_calculator'; -import { hasAssignedFeaturePrivileges } from '../../../privilege_utils'; -import { CUSTOM_PRIVILEGE_VALUE } from '../constants'; -import { FeatureTable } from '../feature_table'; +import { Role, copyRole } from '../../../../../../../common/model'; import { SpaceSelector } from './space_selector'; +import { FeatureTable } from '../feature_table'; +import { CUSTOM_PRIVILEGE_VALUE } from '../constants'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { KibanaPrivileges } from '../../../../model'; interface Props { role: Role; - privilegeCalculatorFactory: KibanaPrivilegeCalculatorFactory; kibanaPrivileges: KibanaPrivileges; - features: Feature[]; spaces: Space[]; - editingIndex: number; + privilegeIndex: number; + canCustomizeSubFeaturePrivileges: boolean; onChange: (role: Role) => void; onCancel: () => void; - intl: InjectedIntl; } interface State { - editingIndex: number; + privilegeIndex: number; selectedSpaceIds: string[]; selectedBasePrivilege: string[]; role: Role; mode: 'create' | 'update'; isCustomizingFeaturePrivileges: boolean; + privilegeCalculator: PrivilegeFormCalculator; } export class PrivilegeSpaceForm extends Component { public static defaultProps = { - editingIndex: -1, + privilegeIndex: -1, }; constructor(props: Props) { @@ -68,10 +64,10 @@ export class PrivilegeSpaceForm extends Component { const role = copyRole(props.role); - let editingIndex = props.editingIndex; - if (editingIndex < 0) { + let privilegeIndex = props.privilegeIndex; + if (privilegeIndex < 0) { // create new form - editingIndex = + privilegeIndex = role.kibana.push({ spaces: [], base: [], @@ -81,11 +77,12 @@ export class PrivilegeSpaceForm extends Component { this.state = { role, - editingIndex, - selectedSpaceIds: [...role.kibana[editingIndex].spaces], - selectedBasePrivilege: [...(role.kibana[editingIndex].base || [])], - mode: props.editingIndex < 0 ? 'create' : 'update', + privilegeIndex, + selectedSpaceIds: [...role.kibana[privilegeIndex].spaces], + selectedBasePrivilege: [...(role.kibana[privilegeIndex].base || [])], + mode: props.privilegeIndex < 0 ? 'create' : 'update', isCustomizingFeaturePrivileges: false, + privilegeCalculator: new PrivilegeFormCalculator(props.kibanaPrivileges, role), }; } @@ -103,8 +100,33 @@ export class PrivilegeSpaceForm extends Component { - {this.getForm()} + + {this.getForm()} + + {this.state.privilegeCalculator.hasSupersededInheritedPrivileges( + this.state.privilegeIndex + ) && ( + + + } + > + + + + + )} { data-test-subj={'cancelSpacePrivilegeButton'} > @@ -128,18 +150,7 @@ export class PrivilegeSpaceForm extends Component { } private getForm = () => { - const { intl, spaces, privilegeCalculatorFactory } = this.props; - - const privilegeCalculator = privilegeCalculatorFactory.getInstance(this.state.role); - - const calculatedPrivileges = privilegeCalculator.calculateEffectivePrivileges()[ - this.state.editingIndex - ]; - const allowedPrivileges = privilegeCalculator.calculateAllowedPrivileges()[ - this.state.editingIndex - ]; - - const baseExplanation = calculatedPrivileges.base; + const { spaces } = this.props; const hasSelectedSpaces = this.state.selectedSpaceIds.length > 0; @@ -147,16 +158,17 @@ export class PrivilegeSpaceForm extends Component { @@ -164,10 +176,12 @@ export class PrivilegeSpaceForm extends Component { { options={[ { value: 'basePrivilege_custom', - disabled: !this.canCustomizeFeaturePrivileges(baseExplanation, allowedPrivileges), inputDisplay: ( { }, { value: 'basePrivilege_read', - disabled: !allowedPrivileges.base.privileges.includes('read'), inputDisplay: ( { }, ]} hasDividers - valueOfSelected={this.getDisplayedBasePrivilege(allowedPrivileges, baseExplanation)} + valueOfSelected={this.getDisplayedBasePrivilege()} disabled={!hasSelectedSpaces} /> @@ -280,14 +292,12 @@ export class PrivilegeSpaceForm extends Component { 0 || !hasSelectedSpaces} /> @@ -297,6 +307,7 @@ export class PrivilegeSpaceForm extends Component { { private getFeatureListLabel = (disabled: boolean) => { if (disabled) { - return this.props.intl.formatMessage({ - id: 'xpack.security.management.editRole.spacePrivilegeForm.summaryOfFeaturePrivileges', - defaultMessage: 'Summary of feature privileges', - }); + return i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeForm.summaryOfFeaturePrivileges', + { + defaultMessage: 'Summary of feature privileges', + } + ); } else { - return this.props.intl.formatMessage({ - id: 'xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivileges', - defaultMessage: 'Customize by feature', - }); + return i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivileges', + { + defaultMessage: 'Customize by feature', + } + ); } }; private getFeatureListDescription = (disabled: boolean) => { if (disabled) { - return this.props.intl.formatMessage({ - id: - 'xpack.security.management.editRole.spacePrivilegeForm.featurePrivilegeSummaryDescription', - defaultMessage: - 'Some features might be hidden by the space or affected by a global space privilege.', - }); + return i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeForm.featurePrivilegeSummaryDescription', + { + defaultMessage: + 'Some features might be hidden by the space or affected by a global space privilege.', + } + ); } else { - return this.props.intl.formatMessage({ - id: - 'xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivilegeDescription', - defaultMessage: - 'Increase privilege levels on a per feature basis. Some features might be hidden by the space or affected by a global space privilege.', - }); + return i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeForm.customizeFeaturePrivilegeDescription', + { + defaultMessage: + 'Increase privilege levels on a per feature basis. Some features might be hidden by the space or affected by a global space privilege.', + } + ); } }; @@ -410,10 +427,12 @@ export class PrivilegeSpaceForm extends Component { ); @@ -429,7 +448,7 @@ export class PrivilegeSpaceForm extends Component { private onSaveClick = () => { const role = copyRole(this.state.role); - const form = role.kibana[this.state.editingIndex]; + const form = role.kibana[this.state.privilegeIndex]; // remove any spaces that no longer exist if (!this.isDefiningGlobalPrivilege()) { @@ -444,18 +463,19 @@ export class PrivilegeSpaceForm extends Component { private onSelectedSpacesChange = (selectedSpaceIds: string[]) => { const role = copyRole(this.state.role); - const form = role.kibana[this.state.editingIndex]; + const form = role.kibana[this.state.privilegeIndex]; form.spaces = [...selectedSpaceIds]; this.setState({ selectedSpaceIds, role, + privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), }); }; private onSpaceBasePrivilegeChange = (basePrivilege: string) => { const role = copyRole(this.state.role); - const form = role.kibana[this.state.editingIndex]; + const form = role.kibana[this.state.privilegeIndex]; const privilegeName = basePrivilege.split('basePrivilege_')[1]; @@ -473,47 +493,25 @@ export class PrivilegeSpaceForm extends Component { selectedBasePrivilege: privilegeName === CUSTOM_PRIVILEGE_VALUE ? [] : [privilegeName], role, isCustomizingFeaturePrivileges, + privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), }); }; - private getDisplayedBasePrivilege = ( - allowedPrivileges: AllowedPrivilege, - explanation: PrivilegeExplanation - ) => { - let displayedBasePrivilege = explanation.actualPrivilege; - - if (this.canCustomizeFeaturePrivileges(explanation, allowedPrivileges)) { - const form = this.state.role.kibana[this.state.editingIndex]; - - if ( - hasAssignedFeaturePrivileges(form) || - form.base.length === 0 || - this.state.isCustomizingFeaturePrivileges - ) { - displayedBasePrivilege = CUSTOM_PRIVILEGE_VALUE; - } - } - - return displayedBasePrivilege ? `basePrivilege_${displayedBasePrivilege}` : undefined; - }; + private getDisplayedBasePrivilege = () => { + const basePrivilege = this.state.privilegeCalculator.getBasePrivilege( + this.state.privilegeIndex + ); - private canCustomizeFeaturePrivileges = ( - basePrivilegeExplanation: PrivilegeExplanation, - allowedPrivileges: AllowedPrivilege - ) => { - if (basePrivilegeExplanation.isDirectlyAssigned) { - return true; + if (basePrivilege) { + return `basePrivilege_${basePrivilege.id}`; } - const featureEntries = Object.values(allowedPrivileges.feature); - return featureEntries.some(entry => { - return entry != null && (entry.canUnassign || entry.privileges.length > 1); - }); + return `basePrivilege_${CUSTOM_PRIVILEGE_VALUE}`; }; private onFeaturePrivilegesChange = (featureId: string, privileges: string[]) => { const role = copyRole(this.state.role); - const form = role.kibana[this.state.editingIndex]; + const form = role.kibana[this.state.privilegeIndex]; if (privileges.length === 0) { delete form.feature[featureId]; @@ -523,32 +521,29 @@ export class PrivilegeSpaceForm extends Component { this.setState({ role, + privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), }); }; private onChangeAllFeaturePrivileges = (privileges: string[]) => { const role = copyRole(this.state.role); - const form = role.kibana[this.state.editingIndex]; - - const calculator = this.props.privilegeCalculatorFactory.getInstance(role); - const allowedPrivs = calculator.calculateAllowedPrivileges(); + const entry = role.kibana[this.state.privilegeIndex]; if (privileges.length === 0) { - form.feature = {}; + entry.feature = {}; } else { - this.props.features.forEach(feature => { - const allowedPrivilegesFeature = allowedPrivs[this.state.editingIndex].feature[feature.id]; - const canAssign = - allowedPrivilegesFeature && allowedPrivilegesFeature.privileges.includes(privileges[0]); - - if (canAssign) { - form.feature[feature.id] = [...privileges]; + this.props.kibanaPrivileges.getSecuredFeatures().forEach(feature => { + const nextFeaturePrivilege = feature + .getPrimaryFeaturePrivileges() + .find(pfp => privileges.includes(pfp.id)); + if (nextFeaturePrivilege) { + entry.feature[feature.id] = [nextFeaturePrivilege.id]; } }); } - this.setState({ role, + privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), }); }; @@ -557,7 +552,7 @@ export class PrivilegeSpaceForm extends Component { return false; } - const form = this.state.role.kibana[this.state.editingIndex]; + const form = this.state.role.kibana[this.state.privilegeIndex]; if (form.base.length === 0 && Object.keys(form.feature).length === 0) { return false; } diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx index f0a391c98c910..b1c7cb4b631e6 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.test.tsx @@ -5,14 +5,16 @@ */ import React from 'react'; -import { EuiBadge, EuiInMemoryTable, EuiIconTip } from '@elastic/eui'; +import { EuiBadge, EuiInMemoryTable } from '@elastic/eui'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; import { PrivilegeSpaceTable } from './privilege_space_table'; import { PrivilegeDisplay } from './privilege_display'; -import { KibanaPrivileges, Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; -import { rawKibanaPrivileges } from './__fixtures__'; +import { Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { Feature } from '../../../../../../../../features/public'; +import { findTestSubject } from 'test_utils/find_test_subject'; interface TableRow { spaces: string[]; @@ -21,20 +23,125 @@ interface TableRow { }; } -const buildProps = (roleKibanaPrivileges: RoleKibanaPrivilege[]): PrivilegeSpaceTable['props'] => { - const kibanaPrivileges = new KibanaPrivileges(rawKibanaPrivileges); - return { - role: { - name: 'test role', - elasticsearch: { - cluster: ['all'], - indices: [] as any[], - run_as: [] as string[], +const features = [ + new Feature({ + id: 'normal', + name: 'normal feature', + app: [], + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: ['normal-feature-all', 'normal-feature-read'], + }, + read: { + savedObject: { all: [], read: [] }, + ui: ['normal-feature-read'], }, - kibana: roleKibanaPrivileges, }, - privilegeCalculatorFactory: new KibanaPrivilegeCalculatorFactory(kibanaPrivileges), - onChange: (role: Role) => {}, + }), + new Feature({ + id: 'normal_with_sub', + name: 'normal feature with sub features', + app: [], + privileges: { + all: { + savedObject: { all: [], read: [] }, + ui: ['normal-feature-all', 'normal-feature-read'], + }, + read: { + savedObject: { all: [], read: [] }, + ui: ['normal-feature-read'], + }, + }, + subFeatures: [ + { + name: 'sub feature', + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + id: 'normal_sub_all', + name: 'normal sub feature privilege', + includeIn: 'all', + savedObject: { all: [], read: [] }, + ui: ['normal-sub-all', 'normal-sub-read'], + }, + { + id: 'normal_sub_read', + name: 'normal sub feature read privilege', + includeIn: 'read', + savedObject: { all: [], read: [] }, + ui: ['normal-sub-read'], + }, + ], + }, + { + groupType: 'independent', + privileges: [ + { + id: 'excluded_sub_priv', + name: 'excluded sub feature privilege', + includeIn: 'none', + savedObject: { all: [], read: [] }, + ui: ['excluded-sub-priv'], + }, + ], + }, + ], + }, + ], + }), + new Feature({ + id: 'bothPrivilegesExcludedFromBase', + name: 'bothPrivilegesExcludedFromBase', + app: [], + privileges: { + all: { + excludeFromBasePrivileges: true, + savedObject: { all: [], read: [] }, + ui: ['both-privileges-excluded-from-base-all', 'both-privileges-excluded-from-base-read'], + }, + read: { + excludeFromBasePrivileges: true, + savedObject: { all: [], read: [] }, + ui: ['both-privileges-excluded-from-base-read'], + }, + }, + }), + new Feature({ + id: 'allPrivilegeExcludedFromBase', + name: 'allPrivilegeExcludedFromBase', + app: [], + privileges: { + all: { + excludeFromBasePrivileges: true, + savedObject: { all: [], read: [] }, + ui: ['all-privilege-excluded-from-base-all', 'all-privilege-excluded-from-base-read'], + }, + read: { + savedObject: { all: [], read: [] }, + ui: ['all-privilege-excluded-from-base-read'], + }, + }, + }), +]; + +const buildProps = (roleKibanaPrivileges: RoleKibanaPrivilege[]): PrivilegeSpaceTable['props'] => { + const kibanaPrivileges = createKibanaPrivileges(features); + const role = { + name: 'test role', + elasticsearch: { + cluster: ['all'], + indices: [] as any[], + run_as: [] as string[], + }, + kibana: roleKibanaPrivileges, + }; + return { + role, + privilegeCalculator: new PrivilegeFormCalculator(kibanaPrivileges, role), + onChange: (r: Role) => {}, onEdit: (spacesIndex: number) => {}, displaySpaces: [ { @@ -51,7 +158,6 @@ const buildProps = (roleKibanaPrivileges: RoleKibanaPrivilege[]): PrivilegeSpace disabledFeatures: [], }, ], - intl: {} as any, }; }; @@ -73,7 +179,9 @@ const getTableFromComponent = ( spaces: spacesBadge.map(badge => badge.text().trim()), privileges: { summary: privilegesDisplay.text().trim(), - overridden: privilegesDisplay.find(EuiIconTip).exists('[type="lock"]'), + overridden: + findTestSubject(row as ReactWrapper, 'spaceTablePrivilegeSupersededWarning') + .length > 0, }, }, ]; @@ -117,6 +225,28 @@ describe('only global', () => { ]); }); + it('normal feature privilege minimal_all', () => { + const props = buildProps([ + { spaces: ['*'], base: [], feature: { normal_with_sub: ['minimal_all'] } }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, + ]); + }); + + it('normal feature privilege minimal_all and normal_sub_read', () => { + const props = buildProps([ + { spaces: ['*'], base: [], feature: { normal_with_sub: ['minimal_all', 'normal_sub_read'] } }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, + ]); + }); + it('bothPrivilegesExcludedFromBase feature privilege all', () => { const props = buildProps([ { spaces: ['*'], base: [], feature: { bothPrivilegesExcludedFromBase: ['read'] } }, @@ -203,6 +333,32 @@ describe('only default and marketing space', () => { ]); }); + it('normal feature privilege minimal_all', () => { + const props = buildProps([ + { spaces: ['default', 'marketing'], base: [], feature: { normal_with_sub: ['minimal_all'] } }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + ]); + }); + + it('normal feature privilege minimal_all and normal_sub_read', () => { + const props = buildProps([ + { + spaces: ['default', 'marketing'], + base: [], + feature: { normal_with_sub: ['minimal_all', 'normal_sub_read'] }, + }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + ]); + }); + it('bothPrivilegesExcludedFromBase feature privilege all', () => { const props = buildProps([ { @@ -275,7 +431,7 @@ describe('global base all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: false } }, ]); }); @@ -288,7 +444,7 @@ describe('global base all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Read', overridden: true } }, ]); }); @@ -301,7 +457,7 @@ describe('global base all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, ]); }); @@ -314,7 +470,41 @@ describe('global base all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, + ]); + }); + + it('normal feature privilege minimal_all', () => { + const props = buildProps([ + { spaces: ['*'], base: ['all'], feature: {} }, + { + spaces: ['default', 'marketing'], + base: [], + feature: { normal_with_sub: ['minimal_all'] }, + }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, + ]); + }); + + it('normal feature privilege minimal_all and normal_sub_read', () => { + const props = buildProps([ + { spaces: ['*'], base: ['all'], feature: {} }, + { + spaces: ['default', 'marketing'], + base: [], + feature: { normal_with_sub: ['minimal_all', 'normal_sub_read'] }, + }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, ]); }); @@ -382,7 +572,7 @@ describe('global base all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'All', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'All', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, ]); }); }); @@ -412,7 +602,7 @@ describe('global base read', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Read', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Read', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Read', overridden: false } }, ]); }); @@ -438,7 +628,41 @@ describe('global base read', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Read', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Read', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + ]); + }); + + it('normal feature privilege minimal_read', () => { + const props = buildProps([ + { spaces: ['*'], base: ['read'], feature: {} }, + { + spaces: ['default', 'marketing'], + base: [], + feature: { normal_with_sub: ['minimal_read'] }, + }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'Read', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, + ]); + }); + + it('normal feature privilege minimal_read and normal_sub_read', () => { + const props = buildProps([ + { spaces: ['*'], base: ['read'], feature: {} }, + { + spaces: ['default', 'marketing'], + base: [], + feature: { normal_with_sub: ['minimal_read', 'normal_sub_read'] }, + }, + ]); + const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); + expect(actualTable).toEqual([ + { spaces: ['*'], privileges: { summary: 'Read', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, ]); }); @@ -506,7 +730,7 @@ describe('global base read', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Read', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Read', overridden: true } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, ]); }); }); @@ -562,7 +786,7 @@ describe('global normal feature privilege all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, ]); }); @@ -844,7 +1068,7 @@ describe('global bothPrivilegesExcludedFromBase feature privilege all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, ]); }); @@ -1126,7 +1350,7 @@ describe('global allPrivilegeExcludedFromBase feature privilege all', () => { const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, - { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: false } }, + { spaces: ['Default', 'Marketing'], privileges: { summary: 'Custom', overridden: true } }, ]); }); }); @@ -1213,6 +1437,7 @@ describe('global allPrivilegeExcludedFromBase feature privilege read', () => { }, ]); const component = mountWithIntl(); + const actualTable = getTableFromComponent(component); expect(actualTable).toEqual([ { spaces: ['*'], privileges: { summary: 'Custom', overridden: false } }, diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx index 1a43fb9e2683a..ccb5398a11b23 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_table.tsx @@ -10,35 +10,32 @@ import { EuiButtonIcon, EuiInMemoryTable, EuiBasicTableColumn, + EuiIcon, + EuiIconTip, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import _ from 'lodash'; import React, { Component } from 'react'; import { Space, getSpaceColor } from '../../../../../../../../spaces/public'; -import { - FeaturesPrivileges, - Role, - RoleKibanaPrivilege, - copyRole, -} from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; -import { - isGlobalPrivilegeDefinition, - hasAssignedFeaturePrivileges, -} from '../../../privilege_utils'; -import { CUSTOM_PRIVILEGE_VALUE, NO_PRIVILEGE_VALUE } from '../constants'; +import { FeaturesPrivileges, Role, copyRole } from '../../../../../../../common/model'; import { SpacesPopoverList } from '../../../spaces_popover_list'; import { PrivilegeDisplay } from './privilege_display'; +import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { CUSTOM_PRIVILEGE_VALUE } from '../constants'; const SPACES_DISPLAY_COUNT = 4; interface Props { role: Role; - privilegeCalculatorFactory: KibanaPrivilegeCalculatorFactory; + privilegeCalculator: PrivilegeFormCalculator; onChange: (role: Role) => void; - onEdit: (spacesIndex: number) => void; + onEdit: (privilegeIndex: number) => void; displaySpaces: Space[]; disabled?: boolean; - intl: InjectedIntl; } interface State { @@ -52,12 +49,13 @@ type TableSpace = Space & interface TableRow { spaces: TableSpace[]; - spacesIndex: number; + privilegeIndex: number; isGlobal: boolean; privileges: { spaces: string[]; base: string[]; feature: FeaturesPrivileges; + reserved: string[]; }; } @@ -71,15 +69,11 @@ export class PrivilegeSpaceTable extends Component { } private renderKibanaPrivileges = () => { - const { privilegeCalculatorFactory, displaySpaces, intl } = this.props; + const { privilegeCalculator, displaySpaces } = this.props; const spacePrivileges = this.getSortedPrivileges(); - const privilegeCalculator = privilegeCalculatorFactory.getInstance(this.props.role); - - const effectivePrivileges = privilegeCalculator.calculateEffectivePrivileges(false); - - const rows: TableRow[] = spacePrivileges.map((spacePrivs, spacesIndex) => { + const rows: TableRow[] = spacePrivileges.map((spacePrivs, privilegeIndex) => { const spaces = spacePrivs.spaces.map( spaceId => displaySpaces.find(space => space.id === spaceId) || { @@ -92,12 +86,13 @@ export class PrivilegeSpaceTable extends Component { return { spaces, - spacesIndex, + privilegeIndex, isGlobal: isGlobalPrivilegeDefinition(spacePrivs), privileges: { spaces: spacePrivs.spaces, base: spacePrivs.base || [], feature: spacePrivs.feature || {}, + reserved: spacePrivs._reserved || [], }, }; }); @@ -117,26 +112,27 @@ export class PrivilegeSpaceTable extends Component { name: 'Spaces', width: '60%', render: (spaces: TableSpace[], record: TableRow) => { - const isExpanded = this.state.expandedSpacesGroups.includes(record.spacesIndex); + const isExpanded = this.state.expandedSpacesGroups.includes(record.privilegeIndex); const displayedSpaces = isExpanded ? spaces : spaces.slice(0, SPACES_DISPLAY_COUNT); let button = null; if (record.isGlobal) { button = ( s.id !== '*')} + buttonText={i18n.translate( + 'xpack.security.management.editRole.spacePrivilegeTable.showAllSpacesLink', + { + defaultMessage: 'show spaces', + } + )} /> ); } else if (spaces.length > displayedSpaces.length) { button = ( this.toggleExpandSpacesGroup(record.spacesIndex)} + onClick={() => this.toggleExpandSpacesGroup(record.privilegeIndex)} > { button = ( this.toggleExpandSpacesGroup(record.spacesIndex)} + onClick={() => this.toggleExpandSpacesGroup(record.privilegeIndex)} > { return (
- {displayedSpaces.map((space: TableSpace) => ( - - {space.name} - - ))} + + {displayedSpaces.map((space: TableSpace) => ( + + {space.name} + + ))} + + {button}
); @@ -178,45 +177,48 @@ export class PrivilegeSpaceTable extends Component { { field: 'privileges', name: 'Privileges', - render: (privileges: RoleKibanaPrivilege, record: TableRow) => { - const effectivePrivilege = effectivePrivileges[record.spacesIndex]; - const basePrivilege = effectivePrivilege.base; - - if (effectivePrivilege.reserved != null && effectivePrivilege.reserved.length > 0) { - return ; - } else if (record.isGlobal) { + render: (privileges: TableRow['privileges'], record: TableRow) => { + if (privileges.reserved.length > 0) { return ( ); - } else { - const hasNonSupersededCustomizations = Object.keys(privileges.feature).some( - featureId => { - const featureEffectivePrivilege = effectivePrivilege.feature[featureId]; - return ( - featureEffectivePrivilege && - featureEffectivePrivilege.directlyAssignedFeaturePrivilegeMorePermissiveThanBase - ); - } - ); - - const showCustom = - hasNonSupersededCustomizations || - (hasAssignedFeaturePrivileges(privileges) && - effectivePrivilege.base.actualPrivilege === NO_PRIVILEGE_VALUE); + } - return ( - + let icon = ; + if (privilegeCalculator.hasSupersededInheritedPrivileges(record.privilegeIndex)) { + icon = ( + + + } + /> + ); } + + return ( + + {icon} + + + + + ); }, }, ]; @@ -229,19 +231,16 @@ export class PrivilegeSpaceTable extends Component { render: (record: TableRow) => { return ( s.name).join(', '), + values: { spaceNames: record.spaces.map(s => s.name).join(', ') }, } )} color={'primary'} iconType={'pencil'} - onClick={() => this.props.onEdit(record.spacesIndex)} + onClick={() => this.props.onEdit(record.privilegeIndex)} /> ); }, @@ -250,14 +249,11 @@ export class PrivilegeSpaceTable extends Component { render: (record: TableRow) => { return ( s.name).join(', '), + values: { spaceNames: record.spaces.map(s => s.name).join(', ') }, } )} color={'danger'} @@ -294,26 +290,26 @@ export class PrivilegeSpaceTable extends Component { }); }; - private toggleExpandSpacesGroup = (spacesIndex: number) => { - if (this.state.expandedSpacesGroups.includes(spacesIndex)) { + private toggleExpandSpacesGroup = (privilegeIndex: number) => { + if (this.state.expandedSpacesGroups.includes(privilegeIndex)) { this.setState({ - expandedSpacesGroups: this.state.expandedSpacesGroups.filter(i => i !== spacesIndex), + expandedSpacesGroups: this.state.expandedSpacesGroups.filter(i => i !== privilegeIndex), }); } else { this.setState({ - expandedSpacesGroups: [...this.state.expandedSpacesGroups, spacesIndex], + expandedSpacesGroups: [...this.state.expandedSpacesGroups, privilegeIndex], }); } }; private onDeleteSpacePrivilege = (item: TableRow) => { const roleCopy = copyRole(this.props.role); - roleCopy.kibana.splice(item.spacesIndex, 1); + roleCopy.kibana.splice(item.privilegeIndex, 1); this.props.onChange(roleCopy); this.setState({ - expandedSpacesGroups: this.state.expandedSpacesGroups.filter(i => i !== item.spacesIndex), + expandedSpacesGroups: this.state.expandedSpacesGroups.filter(i => i !== item.privilegeIndex), }); }; } diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.test.tsx index e06d2a4f7dc33..a9bcb5433fcc7 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.test.tsx @@ -6,13 +6,13 @@ import React from 'react'; import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { KibanaPrivileges } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; import { RoleValidator } from '../../../validate_role'; -import { PrivilegeMatrix } from './privilege_matrix'; import { PrivilegeSpaceForm } from './privilege_space_form'; import { PrivilegeSpaceTable } from './privilege_space_table'; import { SpaceAwarePrivilegeSection } from './space_aware_privilege_section'; +import { PrivilegeSummary } from '../privilege_summary'; +import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; const buildProps = (customProps: any = {}) => { return { @@ -42,23 +42,12 @@ const buildProps = (customProps: any = {}) => { manage: true, }, }, - features: [], + features: kibanaFeatures, editable: true, onChange: jest.fn(), validator: new RoleValidator(), - privilegeCalculatorFactory: new KibanaPrivilegeCalculatorFactory( - new KibanaPrivileges({ - features: { - feature1: { - all: ['*'], - read: ['read'], - }, - }, - global: {}, - space: {}, - reserved: {}, - }) - ), + kibanaPrivileges: createKibanaPrivileges(kibanaFeatures), + canCustomizeSubFeaturePrivileges: true, ...customProps, }; }; @@ -80,7 +69,7 @@ describe('', () => { }, }); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); const table = wrapper.find(PrivilegeSpaceTable); expect(table).toHaveLength(1); @@ -89,13 +78,13 @@ describe('', () => { it('hides the space table if there are no existing space privileges', () => { const props = buildProps(); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); const table = wrapper.find(PrivilegeSpaceTable); expect(table).toHaveLength(0); }); - it('Renders flyout after clicking "Add a privilege" button', () => { + it('Renders flyout after clicking "Add space privilege" button', () => { const props = buildProps({ role: { elasticsearch: { @@ -111,7 +100,7 @@ describe('', () => { }, }); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); expect(wrapper.find(PrivilegeSpaceForm)).toHaveLength(0); wrapper.find('button[data-test-subj="addSpacePrivilegeButton"]').simulate('click'); @@ -119,7 +108,7 @@ describe('', () => { expect(wrapper.find(PrivilegeSpaceForm)).toHaveLength(1); }); - it('hides privilege matrix when the role is reserved', () => { + it('hides privilege summary when the role is reserved', () => { const props = buildProps({ role: { name: '', @@ -135,8 +124,8 @@ describe('', () => { }, }); - const wrapper = mountWithIntl(); - expect(wrapper.find(PrivilegeMatrix)).toHaveLength(0); + const wrapper = mountWithIntl(); + expect(wrapper.find(PrivilegeSummary)).toHaveLength(0); }); describe('with base privilege set to "read"', () => { @@ -156,7 +145,7 @@ describe('', () => { }, }); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); const table = wrapper.find(PrivilegeSpaceTable); expect(table).toHaveLength(1); @@ -183,7 +172,7 @@ describe('', () => { }, }); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); const table = wrapper.find(PrivilegeSpaceTable); expect(table).toHaveLength(1); @@ -202,7 +191,7 @@ describe('', () => { }, }); - const wrapper = shallowWithIntl(); + const wrapper = shallowWithIntl(); expect(wrapper).toMatchSnapshot(); }); }); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx index a847ccb677485..86b09e5332792 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx @@ -10,47 +10,49 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, + EuiErrorBoundary, } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import _ from 'lodash'; import React, { Component, Fragment } from 'react'; import { Capabilities } from 'src/core/public'; import { Space } from '../../../../../../../../spaces/public'; -import { Feature } from '../../../../../../../../features/public'; -import { KibanaPrivileges, Role, isRoleReserved } from '../../../../../../../common/model'; -import { KibanaPrivilegeCalculatorFactory } from '../kibana_privilege_calculator'; +import { Role, isRoleReserved } from '../../../../../../../common/model'; import { RoleValidator } from '../../../validate_role'; -import { PrivilegeMatrix } from './privilege_matrix'; -import { PrivilegeSpaceForm } from './privilege_space_form'; import { PrivilegeSpaceTable } from './privilege_space_table'; +import { PrivilegeSpaceForm } from './privilege_space_form'; +import { PrivilegeFormCalculator } from '../privilege_form_calculator'; +import { PrivilegeSummary } from '../privilege_summary'; +import { KibanaPrivileges } from '../../../../model'; interface Props { kibanaPrivileges: KibanaPrivileges; role: Role; - privilegeCalculatorFactory: KibanaPrivilegeCalculatorFactory; spaces: Space[]; onChange: (role: Role) => void; editable: boolean; + canCustomizeSubFeaturePrivileges: boolean; validator: RoleValidator; - intl: InjectedIntl; uiCapabilities: Capabilities; - features: Feature[]; } interface State { role: Role | null; - editingIndex: number; + privilegeIndex: number; showSpacePrivilegeEditor: boolean; showPrivilegeMatrix: boolean; } -class SpaceAwarePrivilegeSectionUI extends Component { +export class SpaceAwarePrivilegeSection extends Component { private globalSpaceEntry: Space = { id: '*', - name: this.props.intl.formatMessage({ - id: 'xpack.security.management.editRole.spaceAwarePrivilegeForm.globalSpacesName', - defaultMessage: '* Global (all spaces)', - }), + name: i18n.translate( + 'xpack.security.management.editRole.spaceAwarePrivilegeForm.globalSpacesName', + { + defaultMessage: '* Global (all spaces)', + } + ), color: '#D3DAE6', initials: '*', disabledFeatures: [], @@ -63,12 +65,12 @@ class SpaceAwarePrivilegeSectionUI extends Component { showSpacePrivilegeEditor: false, showPrivilegeMatrix: false, role: null, - editingIndex: -1, + privilegeIndex: -1, }; } public render() { - const { uiCapabilities, privilegeCalculatorFactory } = this.props; + const { uiCapabilities } = this.props; if (!uiCapabilities.spaces.manage) { return ( @@ -113,22 +115,22 @@ class SpaceAwarePrivilegeSectionUI extends Component { } return ( - - {this.renderKibanaPrivileges()} - {this.state.showSpacePrivilegeEditor && ( - - )} - + + + {this.renderKibanaPrivileges()} + {this.state.showSpacePrivilegeEditor && ( + + )} + + ); } @@ -143,10 +145,11 @@ class SpaceAwarePrivilegeSectionUI extends Component { ); @@ -205,14 +208,11 @@ class SpaceAwarePrivilegeSectionUI extends Component { } const viewMatrixButton = ( - ); @@ -250,18 +250,18 @@ class SpaceAwarePrivilegeSectionUI extends Component { private addSpacePrivilege = () => { this.setState({ showSpacePrivilegeEditor: true, - editingIndex: -1, + privilegeIndex: -1, }); }; private onSpacesPrivilegeChange = (role: Role) => { - this.setState({ showSpacePrivilegeEditor: false, editingIndex: -1 }); + this.setState({ showSpacePrivilegeEditor: false, privilegeIndex: -1 }); this.props.onChange(role); }; - private onEditSpacesPrivileges = (spacesIndex: number) => { + private onEditSpacesPrivileges = (privilegeIndex: number) => { this.setState({ - editingIndex: spacesIndex, + privilegeIndex, showSpacePrivilegeEditor: true, }); }; @@ -270,5 +270,3 @@ class SpaceAwarePrivilegeSectionUI extends Component { this.setState({ showSpacePrivilegeEditor: false }); }; } - -export const SpaceAwarePrivilegeSection = injectI18n(SpaceAwarePrivilegeSectionUI); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx index 1e42a926c51f7..70790f785ad58 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_selector.tsx @@ -5,9 +5,10 @@ */ import { EuiComboBox, EuiComboBoxOptionOption, EuiHealth, EuiHighlight } from '@elastic/eui'; -import { InjectedIntl } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import React, { Component } from 'react'; -import { Space, getSpaceColor } from '../../../../../../../../spaces/public'; +import { getSpaceColor } from '../../../../../../../../spaces/public'; +import { Space } from '../../../../../../../../spaces/common/model/space'; const spaceToOption = (space?: Space, currentSelection?: 'global' | 'spaces') => { if (!space) { @@ -32,7 +33,6 @@ interface Props { selectedSpaceIds: string[]; onChange: (spaceIds: string[]) => void; disabled?: boolean; - intl: InjectedIntl; } export class SpaceSelector extends Component { @@ -51,8 +51,7 @@ export class SpaceSelector extends Component { return ( { + it('renders a button with the provided text', () => { + const wrapper = mountWithIntl(); + expect(wrapper.find(EuiButtonEmpty).text()).toEqual('hello world'); + expect(wrapper.find(EuiContextMenuPanel)).toHaveLength(0); + }); + + it('clicking the button renders a context menu with the provided spaces', () => { + const wrapper = mountWithIntl(); + wrapper.find(EuiButtonEmpty).simulate('click'); + wrapper.update(); + + const menu = wrapper.find(EuiContextMenuPanel); + expect(menu).toHaveLength(1); + + const items = menu.find(EuiContextMenuItem); + expect(items).toHaveLength(spaces.length); + + spaces.forEach((space, index) => { + const spaceAvatar = items.at(index).find(SpaceAvatar); + expect(spaceAvatar.props().space).toEqual(space); + }); + + expect(wrapper.find(EuiFieldSearch)).toHaveLength(0); + }); + + it('renders a search box when there are 8 or more spaces', () => { + const lotsOfSpaces = [1, 2, 3, 4, 5, 6, 7, 8].map(num => ({ + id: `space-${num}`, + name: `Space ${num}`, + disabledFeatures: [], + })); + + const wrapper = mountWithIntl( + + ); + wrapper.find(EuiButtonEmpty).simulate('click'); + wrapper.update(); + + const menu = wrapper.find(EuiContextMenuPanel).first(); + const items = menu.find(EuiContextMenuItem); + expect(items).toHaveLength(lotsOfSpaces.length); + + const searchField = wrapper.find(EuiFieldSearch); + expect(searchField).toHaveLength(1); + + searchField.props().onSearch!('Space 6'); + wrapper.update(); + expect(wrapper.find(SpaceAvatar)).toHaveLength(1); + + searchField.props().onSearch!('this does not match'); + wrapper.update(); + expect(wrapper.find(SpaceAvatar)).toHaveLength(0); + + const updatedMenu = wrapper.find(EuiContextMenuPanel).first(); + expect(updatedMenu.text()).toMatchInlineSnapshot(`"Spaces no spaces found "`); + }); + + it('can close its popover', () => { + const wrapper = mountWithIntl(); + wrapper.find(EuiButtonEmpty).simulate('click'); + wrapper.update(); + + expect(wrapper.find(EuiPopover).props().isOpen).toEqual(true); + + wrapper + .find(EuiPopover) + .props() + .closePopover(); + + wrapper.update(); + + expect(wrapper.find(EuiPopover).props().isOpen).toEqual(false); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx b/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx index f8b2991a844f7..92e42ec811afc 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/spaces_popover_list/spaces_popover_list.tsx @@ -12,14 +12,14 @@ import { EuiPopover, EuiText, } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import React, { Component } from 'react'; import { Space, SpaceAvatar } from '../../../../../../spaces/public'; import { SPACE_SEARCH_COUNT_THRESHOLD } from '../../../../../../spaces/common'; interface Props { spaces: Space[]; - intl: InjectedIntl; buttonText: string; } @@ -59,15 +59,13 @@ export class SpacesPopoverList extends Component { } private getMenuPanel = () => { - const { intl } = this.props; const { searchTerm } = this.state; const items = this.getVisibleSpaces(searchTerm).map(this.renderSpaceMenuItem); const panelProps = { className: 'spcMenu', - title: intl.formatMessage({ - id: 'xpack.security.management.editRole.spacesPopoverList.popoverTitle', + title: i18n.translate('xpack.security.management.editRole.spacesPopoverList.popoverTitle', { defaultMessage: 'Spaces', }), watchedItemProps: ['data-search-term'], @@ -141,15 +139,16 @@ export class SpacesPopoverList extends Component { }; private renderSearchField = () => { - const { intl } = this.props; return (
{ !knownActions.includes(action)); + + const hasAllRequested = + knownActions.length > 0 && candidateActions.length > 0 && missing.length === 0; + + return { + missing, + hasAllRequested, + }; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/kibana_privileges.test.ts b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.test.ts new file mode 100644 index 0000000000000..a1f1e36e8df86 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.test.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createRawKibanaPrivileges } from '../__fixtures__/kibana_privileges'; +import { kibanaFeatures } from '../__fixtures__/kibana_features'; +import { KibanaPrivileges } from './kibana_privileges'; +import { RoleKibanaPrivilege } from '../../../../common/model'; +import { KibanaPrivilege } from './kibana_privilege'; + +describe('KibanaPrivileges', () => { + describe('#getBasePrivileges', () => { + it('returns the space base privileges for a non-global entry', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const entry: RoleKibanaPrivilege = { + base: [], + feature: {}, + spaces: ['foo'], + }; + + const basePrivileges = kibanaPrivileges.getBasePrivileges(entry); + + const expectedPrivileges = rawPrivileges.space; + + expect(basePrivileges).toHaveLength(2); + expect(basePrivileges[0]).toMatchObject({ + id: 'all', + actions: expectedPrivileges.all, + }); + expect(basePrivileges[1]).toMatchObject({ + id: 'read', + actions: expectedPrivileges.read, + }); + }); + + it('returns the global base privileges for a global entry', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const entry: RoleKibanaPrivilege = { + base: [], + feature: {}, + spaces: ['*'], + }; + + const basePrivileges = kibanaPrivileges.getBasePrivileges(entry); + + const expectedPrivileges = rawPrivileges.global; + + expect(basePrivileges).toHaveLength(2); + expect(basePrivileges[0]).toMatchObject({ + id: 'all', + actions: expectedPrivileges.all, + }); + expect(basePrivileges[1]).toMatchObject({ + id: 'read', + actions: expectedPrivileges.read, + }); + }); + }); + + describe('#createCollectionFromRoleKibanaPrivileges', () => { + it('creates a collection from a role with no privileges assigned', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const assignedPrivileges: RoleKibanaPrivilege[] = []; + kibanaPrivileges.createCollectionFromRoleKibanaPrivileges(assignedPrivileges); + }); + + it('creates a collection ignoring unknown privileges', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const assignedPrivileges: RoleKibanaPrivilege[] = [ + { + base: ['read', 'some-unknown-base-privilege'], + feature: {}, + spaces: ['*'], + }, + { + base: [], + feature: { + with_sub_features: ['read', 'cool_all', 'some-unknown-feature-privilege'], + some_unknown_feature: ['all'], + }, + spaces: ['foo'], + }, + ]; + kibanaPrivileges.createCollectionFromRoleKibanaPrivileges(assignedPrivileges); + }); + + it('creates a collection using all assigned privileges, and only the assigned privileges', () => { + const rawPrivileges = createRawKibanaPrivileges(kibanaFeatures); + const kibanaPrivileges = new KibanaPrivileges(rawPrivileges, kibanaFeatures); + + const assignedPrivileges: RoleKibanaPrivilege[] = [ + { + base: ['read'], + feature: {}, + spaces: ['*'], + }, + { + base: [], + feature: { + with_sub_features: ['read', 'cool_all'], + }, + spaces: ['foo'], + }, + ]; + const collection = kibanaPrivileges.createCollectionFromRoleKibanaPrivileges( + assignedPrivileges + ); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', [...rawPrivileges.features.with_excluded_sub_features.read]) + ) + ).toEqual(true); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', [...rawPrivileges.features.with_excluded_sub_features.all]) + ) + ).toEqual(false); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', [...rawPrivileges.features.with_sub_features.cool_all]) + ) + ).toEqual(true); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', [...rawPrivileges.features.with_sub_features.cool_toggle_1]) + ) + ).toEqual(false); + }); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts new file mode 100644 index 0000000000000..d8d75e90847e3 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RawKibanaPrivileges, RoleKibanaPrivilege } from '../../../../common/model'; +import { KibanaPrivilege } from './kibana_privilege'; +import { PrivilegeCollection } from './privilege_collection'; +import { SecuredFeature } from './secured_feature'; +import { Feature } from '../../../../../features/common'; +import { isGlobalPrivilegeDefinition } from '../edit_role/privilege_utils'; + +function toBasePrivilege(entry: [string, string[]]): [string, KibanaPrivilege] { + const [privilegeId, actions] = entry; + return [privilegeId, new KibanaPrivilege(privilegeId, actions)]; +} + +function recordsToBasePrivilegeMap( + record: Record +): ReadonlyMap { + return new Map(Object.entries(record).map(entry => toBasePrivilege(entry))); +} + +export class KibanaPrivileges { + private global: ReadonlyMap; + + private spaces: ReadonlyMap; + + private feature: ReadonlyMap; + + constructor(rawKibanaPrivileges: RawKibanaPrivileges, features: Feature[]) { + this.global = recordsToBasePrivilegeMap(rawKibanaPrivileges.global); + this.spaces = recordsToBasePrivilegeMap(rawKibanaPrivileges.space); + this.feature = new Map( + features.map(feature => { + const rawPrivs = rawKibanaPrivileges.features[feature.id]; + return [feature.id, new SecuredFeature(feature.toRaw(), rawPrivs)]; + }) + ); + } + + public getBasePrivileges(entry: RoleKibanaPrivilege) { + if (isGlobalPrivilegeDefinition(entry)) { + return Array.from(this.global.values()); + } + return Array.from(this.spaces.values()); + } + + public getSecuredFeature(featureId: string) { + return this.feature.get(featureId)!; + } + + public getSecuredFeatures() { + return Array.from(this.feature.values()); + } + + public createCollectionFromRoleKibanaPrivileges(roleKibanaPrivileges: RoleKibanaPrivilege[]) { + const filterAssigned = (assignedPrivileges: string[]) => (privilege: KibanaPrivilege) => + assignedPrivileges.includes(privilege.id); + + const privileges: KibanaPrivilege[] = roleKibanaPrivileges + .map(entry => { + const assignedBasePrivileges = this.getBasePrivileges(entry).filter( + filterAssigned(entry.base) + ); + + const assignedFeaturePrivileges: KibanaPrivilege[][] = Object.entries(entry.feature).map( + ([featureId, assignedFeaturePrivs]) => { + return this.getFeaturePrivileges(featureId).filter( + filterAssigned(assignedFeaturePrivs) + ); + } + ); + + return [assignedBasePrivileges, assignedFeaturePrivileges].flat(2); + }) + .flat(); + + return new PrivilegeCollection(privileges); + } + + private getFeaturePrivileges(featureId: string) { + return this.getSecuredFeature(featureId)?.getAllPrivileges() ?? []; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts b/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts new file mode 100644 index 0000000000000..9ed460fe734ef --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaPrivilege } from './kibana_privilege'; +import { FeatureKibanaPrivileges } from '../../../../../features/public'; + +export class PrimaryFeaturePrivilege extends KibanaPrivilege { + constructor( + id: string, + protected readonly config: FeatureKibanaPrivileges, + public readonly actions: string[] = [] + ) { + super(id, actions); + } + + public isMinimalFeaturePrivilege() { + return this.id.startsWith('minimal_'); + } + + public getMinimalPrivilegeId() { + if (this.isMinimalFeaturePrivilege()) { + return this.id; + } + return `minimal_${this.id}`; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/privilege_collection.test.ts b/x-pack/plugins/security/public/management/roles/model/privilege_collection.test.ts new file mode 100644 index 0000000000000..6b1c3785721b3 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/privilege_collection.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaPrivilege } from './kibana_privilege'; +import { PrivilegeCollection } from './privilege_collection'; + +describe('PrivilegeCollection', () => { + describe('#grantsPrivilege', () => { + it('returns true when the collection contains the same privilege being tested', () => { + const privilege = new KibanaPrivilege('some-privilege', ['action:foo', 'action:bar']); + const collection = new PrivilegeCollection([privilege]); + + expect(collection.grantsPrivilege(privilege)).toEqual(true); + }); + + it('returns false when a non-empty collection tests an empty privilege', () => { + const privilege = new KibanaPrivilege('some-privilege', ['action:foo', 'action:bar']); + const collection = new PrivilegeCollection([privilege]); + + expect(collection.grantsPrivilege(new KibanaPrivilege('test', []))).toEqual(false); + }); + + it('returns true for collections comprised of multiple privileges, with actions spanning them', () => { + const collection = new PrivilegeCollection([ + new KibanaPrivilege('privilege1', ['action:foo', 'action:bar']), + new KibanaPrivilege('privilege1', ['action:baz']), + ]); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', ['action:foo', 'action:bar', 'action:baz']) + ) + ).toEqual(true); + }); + + it('returns false for collections which do not contain all necessary actions', () => { + const collection = new PrivilegeCollection([ + new KibanaPrivilege('privilege1', ['action:foo', 'action:bar']), + new KibanaPrivilege('privilege1', ['action:baz']), + ]); + + expect( + collection.grantsPrivilege( + new KibanaPrivilege('test', ['action:foo', 'action:bar', 'action:baz', 'actions:secret']) + ) + ).toEqual(false); + }); + + it('returns false for collections which contain no privileges', () => { + const collection = new PrivilegeCollection([]); + + expect(collection.grantsPrivilege(new KibanaPrivilege('test', ['action:foo']))).toEqual( + false + ); + }); + + it('returns false for collections which contain no privileges, even if the requested privilege has no actions', () => { + const collection = new PrivilegeCollection([]); + + expect(collection.grantsPrivilege(new KibanaPrivilege('test', []))).toEqual(false); + }); + }); +}); diff --git a/x-pack/plugins/security/public/management/roles/model/privilege_collection.ts b/x-pack/plugins/security/public/management/roles/model/privilege_collection.ts new file mode 100644 index 0000000000000..cbbd22857666e --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/privilege_collection.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 { KibanaPrivilege } from './kibana_privilege'; + +export class PrivilegeCollection { + private actions: ReadonlySet; + + constructor(privileges: KibanaPrivilege[]) { + this.actions = new Set( + privileges.reduce((acc, priv) => [...acc, ...priv.actions], [] as string[]) + ); + } + + public grantsPrivilege(privilege: KibanaPrivilege) { + return this.checkActions(this.actions, privilege.actions).hasAllRequested; + } + + private checkActions(knownActions: ReadonlySet, candidateActions: string[]) { + const missing = candidateActions.filter(action => !knownActions.has(action)); + + const hasAllRequested = + knownActions.size > 0 && candidateActions.length > 0 && missing.length === 0; + + return { + missing, + hasAllRequested, + }; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/secured_feature.ts b/x-pack/plugins/security/public/management/roles/model/secured_feature.ts new file mode 100644 index 0000000000000..7fc466a70b984 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/secured_feature.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Feature, FeatureConfig } from '../../../../../features/common'; +import { PrimaryFeaturePrivilege } from './primary_feature_privilege'; +import { SecuredSubFeature } from './secured_sub_feature'; +import { SubFeaturePrivilege } from './sub_feature_privilege'; + +export class SecuredFeature extends Feature { + private readonly primaryFeaturePrivileges: PrimaryFeaturePrivilege[]; + + private readonly minimalPrimaryFeaturePrivileges: PrimaryFeaturePrivilege[]; + + private readonly subFeaturePrivileges: SubFeaturePrivilege[]; + + private readonly securedSubFeatures: SecuredSubFeature[]; + + constructor(config: FeatureConfig, actionMapping: { [privilegeId: string]: string[] } = {}) { + super(config); + this.primaryFeaturePrivileges = Object.entries(this.config.privileges || {}).map( + ([id, privilege]) => new PrimaryFeaturePrivilege(id, privilege, actionMapping[id]) + ); + + if (this.config.subFeatures?.length ?? 0 > 0) { + this.minimalPrimaryFeaturePrivileges = Object.entries(this.config.privileges || {}).map( + ([id, privilege]) => + new PrimaryFeaturePrivilege(`minimal_${id}`, privilege, actionMapping[`minimal_${id}`]) + ); + } else { + this.minimalPrimaryFeaturePrivileges = []; + } + + this.securedSubFeatures = + this.config.subFeatures?.map(sf => new SecuredSubFeature(sf, actionMapping)) ?? []; + + this.subFeaturePrivileges = this.securedSubFeatures.reduce((acc, subFeature) => { + return [...acc, ...subFeature.privilegeIterator()]; + }, [] as SubFeaturePrivilege[]); + } + + public getPrivilegesTooltip() { + return this.config.privilegesTooltip; + } + + public getAllPrivileges() { + return [ + ...this.primaryFeaturePrivileges, + ...this.minimalPrimaryFeaturePrivileges, + ...this.subFeaturePrivileges, + ]; + } + + public getPrimaryFeaturePrivileges( + { includeMinimalFeaturePrivileges }: { includeMinimalFeaturePrivileges: boolean } = { + includeMinimalFeaturePrivileges: false, + } + ) { + return includeMinimalFeaturePrivileges + ? [this.primaryFeaturePrivileges, this.minimalPrimaryFeaturePrivileges].flat() + : [...this.primaryFeaturePrivileges]; + } + + public getMinimalFeaturePrivileges() { + return [...this.minimalPrimaryFeaturePrivileges]; + } + + public getSubFeaturePrivileges() { + return [...this.subFeaturePrivileges]; + } + + public getSubFeatures() { + return [...this.securedSubFeatures]; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/secured_sub_feature.ts b/x-pack/plugins/security/public/management/roles/model/secured_sub_feature.ts new file mode 100644 index 0000000000000..3d69e5e709bb0 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/secured_sub_feature.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 { SubFeature, SubFeatureConfig } from '../../../../../features/common'; +import { SubFeaturePrivilege } from './sub_feature_privilege'; +import { SubFeaturePrivilegeGroup } from './sub_feature_privilege_group'; + +export class SecuredSubFeature extends SubFeature { + public readonly privileges: SubFeaturePrivilege[]; + + constructor( + config: SubFeatureConfig, + private readonly actionMapping: { [privilegeId: string]: string[] } = {} + ) { + super(config); + + this.privileges = []; + for (const privilege of this.privilegeIterator()) { + this.privileges.push(privilege); + } + } + + public getPrivilegeGroups() { + return this.privilegeGroups.map(pg => new SubFeaturePrivilegeGroup(pg, this.actionMapping)); + } + + public *privilegeIterator({ + predicate = () => true, + }: { + predicate?: (privilege: SubFeaturePrivilege, feature: SecuredSubFeature) => boolean; + } = {}): IterableIterator { + for (const group of this.privilegeGroups) { + yield* group.privileges + .map(gp => new SubFeaturePrivilege(gp, this.actionMapping[gp.id])) + .filter(privilege => predicate(privilege, this)); + } + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege.ts b/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege.ts new file mode 100644 index 0000000000000..e149a59e12edf --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege.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 { SubFeaturePrivilegeConfig } from '../../../../../features/public'; +import { KibanaPrivilege } from './kibana_privilege'; + +export class SubFeaturePrivilege extends KibanaPrivilege { + constructor( + protected readonly subPrivilegeConfig: SubFeaturePrivilegeConfig, + public readonly actions: string[] = [] + ) { + super(subPrivilegeConfig.id, actions); + } + + public get name() { + return this.subPrivilegeConfig.name; + } +} diff --git a/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege_group.ts b/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege_group.ts new file mode 100644 index 0000000000000..b437649236e27 --- /dev/null +++ b/x-pack/plugins/security/public/management/roles/model/sub_feature_privilege_group.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SubFeaturePrivilegeGroupConfig } from '../../../../../features/common'; +import { SubFeaturePrivilege } from './sub_feature_privilege'; + +export class SubFeaturePrivilegeGroup { + constructor( + private readonly config: SubFeaturePrivilegeGroupConfig, + private readonly actionMapping: { [privilegeId: string]: string[] } = {} + ) {} + + public get groupType() { + return this.config.groupType; + } + + public get privileges() { + return this.config.privileges.map( + p => new SubFeaturePrivilege(p, this.actionMapping[p.id] || []) + ); + } +} diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx index 5936409eb6e8b..96051dbd7fa56 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx @@ -17,17 +17,22 @@ jest.mock('./edit_role', () => ({ import { rolesManagementApp } from './roles_management_app'; import { coreMock } from '../../../../../../src/core/public/mocks'; +import { featuresPluginMock } from '../../../../features/public/mocks'; async function mountApp(basePath: string) { const { fatalErrors } = coreMock.createSetup(); const container = document.createElement('div'); const setBreadcrumbs = jest.fn(); + const featuresStart = featuresPluginMock.createStart(); + const unmount = await rolesManagementApp .create({ license: licenseMock.create(), fatalErrors, - getStartServices: jest.fn().mockResolvedValue([coreMock.createStart(), { data: {} }]), + getStartServices: jest + .fn() + .mockResolvedValue([coreMock.createStart(), { data: {}, features: featuresStart }]), }) .mount({ basePath, element: container, setBreadcrumbs }); diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.tsx index 4e8c95b61c2f1..e1a10fdc2b8c3 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { HashRouter as Router, Route, Switch, useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { CoreSetup, FatalErrorsSetup } from 'src/core/public'; +import { StartServicesAccessor, FatalErrorsSetup } from 'src/core/public'; import { RegisterManagementAppArgs } from '../../../../../../src/plugins/management/public'; import { SecurityLicense } from '../../../common/licensing'; import { PluginStartDependencies } from '../../plugin'; @@ -23,7 +23,7 @@ import { PrivilegesAPIClient } from './privileges_api_client'; interface CreateParams { fatalErrors: FatalErrorsSetup; license: SecurityLicense; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const rolesManagementApp = Object.freeze({ @@ -36,7 +36,7 @@ export const rolesManagementApp = Object.freeze({ async mount({ basePath, element, setBreadcrumbs }) { const [ { application, docLinks, http, i18n: i18nStart, injectedMetadata, notifications }, - { data }, + { data, features }, ] = await getStartServices(); const rolesBreadcrumbs = [ @@ -77,6 +77,7 @@ export const rolesManagementApp = Object.freeze({ userAPIClient={new UserAPIClient(http)} indicesAPIClient={new IndicesAPIClient(http)} privilegesAPIClient={new PrivilegesAPIClient(http)} + getFeatures={features.getFeatures} http={http} notifications={notifications} fatalErrors={fatalErrors} diff --git a/x-pack/plugins/security/public/management/users/users_management_app.tsx b/x-pack/plugins/security/public/management/users/users_management_app.tsx index 7874b810676b5..82a2b8d2a98ad 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { HashRouter as Router, Route, Switch, useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { CoreSetup } from 'src/core/public'; +import { StartServicesAccessor } from 'src/core/public'; import { RegisterManagementAppArgs } from '../../../../../../src/plugins/management/public'; import { AuthenticationServiceSetup } from '../../authentication'; import { PluginStartDependencies } from '../../plugin'; @@ -19,7 +19,7 @@ import { EditUserPage } from './edit_user'; interface CreateParams { authc: AuthenticationServiceSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const usersManagementApp = Object.freeze({ diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index 3d0ef3b2cabc7..122b26378d22b 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -15,6 +15,7 @@ import { coreMock } from '../../../../src/core/public/mocks'; import { managementPluginMock } from '../../../../src/plugins/management/public/mocks'; import { licensingMock } from '../../licensing/public/mocks'; import { ManagementService } from './management'; +import { FeaturesPluginStart } from '../../features/public'; describe('Security Plugin', () => { beforeAll(() => { @@ -86,6 +87,7 @@ describe('Security Plugin', () => { expect( plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { data: {} as DataPublicPluginStart, + features: {} as FeaturesPluginStart, }) ).toBeUndefined(); }); @@ -110,6 +112,7 @@ describe('Security Plugin', () => { plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { data: {} as DataPublicPluginStart, + features: {} as FeaturesPluginStart, management: managementStartMock, }); @@ -139,6 +142,7 @@ describe('Security Plugin', () => { plugin.start(coreMock.createStart({ basePath: '/some-base-path' }), { data: {} as DataPublicPluginStart, + features: {} as FeaturesPluginStart, }); expect(() => plugin.stop()).not.toThrow(); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index dcd90b1738f10..38ef552e75a9e 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -11,6 +11,7 @@ import { Plugin, PluginInitializerContext, } from '../../../../src/core/public'; +import { FeaturesPluginStart } from '../../features/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { FeatureCatalogueCategory, @@ -40,6 +41,7 @@ export interface PluginSetupDependencies { export interface PluginStartDependencies { data: DataPublicPluginStart; + features: FeaturesPluginStart; management?: ManagementStart; } diff --git a/x-pack/plugins/security/server/authorization/actions/actions.ts b/x-pack/plugins/security/server/authorization/actions/actions.ts index 4bf7a41550cc6..00293e88abe76 100644 --- a/x-pack/plugins/security/server/authorization/actions/actions.ts +++ b/x-pack/plugins/security/server/authorization/actions/actions.ts @@ -15,13 +15,6 @@ import { UIActions } from './ui'; * by the various `checkPrivilegesWithRequest` derivatives */ export class Actions { - /** - * The allHack action is used to differentiate the `all` privilege from the `read` privilege - * for those applications which register the same set of actions for both privileges. This is a - * temporary hack until we remove this assumption in the role management UI - */ - public readonly allHack = 'allHack:'; - public readonly api = new ApiActions(this.versionNumber); public readonly app = new AppActions(this.versionNumber); diff --git a/x-pack/plugins/security/server/authorization/actions/api.test.ts b/x-pack/plugins/security/server/authorization/actions/api.test.ts index 60a42ba6a78a2..d6e7a5d242d49 100644 --- a/x-pack/plugins/security/server/authorization/actions/api.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/api.test.ts @@ -8,13 +8,6 @@ import { ApiActions } from './api'; const version = '1.0.0-zeta1'; -describe('#all', () => { - test('returns `api:${version}:*`', () => { - const apiActions = new ApiActions(version); - expect(apiActions.all).toBe('api:1.0.0-zeta1:*'); - }); -}); - describe('#get', () => { [null, undefined, '', 1, true, {}].forEach((operation: any) => { test(`operation of ${JSON.stringify(operation)} throws error`, () => { diff --git a/x-pack/plugins/security/server/authorization/actions/api.ts b/x-pack/plugins/security/server/authorization/actions/api.ts index 35e614e7a03d4..60b135acc15ef 100644 --- a/x-pack/plugins/security/server/authorization/actions/api.ts +++ b/x-pack/plugins/security/server/authorization/actions/api.ts @@ -12,10 +12,6 @@ export class ApiActions { this.prefix = `api:${versionNumber}:`; } - public get all(): string { - return `${this.prefix}*`; - } - public get(operation: string) { if (!operation || !isString(operation)) { throw new Error('operation is required and must be a string'); diff --git a/x-pack/plugins/security/server/authorization/actions/app.test.ts b/x-pack/plugins/security/server/authorization/actions/app.test.ts index a696fd8693997..74c372a0699a2 100644 --- a/x-pack/plugins/security/server/authorization/actions/app.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/app.test.ts @@ -8,13 +8,6 @@ import { AppActions } from './app'; const version = '1.0.0-zeta1'; -describe('#all', () => { - test('returns `app:${version}:*`', () => { - const appActions = new AppActions(version); - expect(appActions.all).toBe('app:1.0.0-zeta1:*'); - }); -}); - describe('#get', () => { [null, undefined, '', 1, true, {}].forEach((appid: any) => { test(`appId of ${JSON.stringify(appid)} throws error`, () => { diff --git a/x-pack/plugins/security/server/authorization/actions/app.ts b/x-pack/plugins/security/server/authorization/actions/app.ts index ed0854e8a805b..227c658619175 100644 --- a/x-pack/plugins/security/server/authorization/actions/app.ts +++ b/x-pack/plugins/security/server/authorization/actions/app.ts @@ -12,10 +12,6 @@ export class AppActions { this.prefix = `app:${versionNumber}:`; } - public get all(): string { - return `${this.prefix}*`; - } - public get(appId: string) { if (!appId || !isString(appId)) { throw new Error('appId is required and must be a string'); diff --git a/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts b/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts index 5e5da7233d93e..9e8bfb6ad795f 100644 --- a/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts @@ -8,13 +8,6 @@ import { SavedObjectActions } from './saved_object'; const version = '1.0.0-zeta1'; -describe('#all', () => { - test(`returns saved_object:*`, () => { - const savedObjectActions = new SavedObjectActions(version); - expect(savedObjectActions.all).toBe('saved_object:1.0.0-zeta1:*'); - }); -}); - describe('#get', () => { [null, undefined, '', 1, true, {}].forEach((type: any) => { test(`type of ${JSON.stringify(type)} throws error`, () => { diff --git a/x-pack/plugins/security/server/authorization/actions/saved_object.ts b/x-pack/plugins/security/server/authorization/actions/saved_object.ts index 4a0bc7cda1b8f..e3a02d3807399 100644 --- a/x-pack/plugins/security/server/authorization/actions/saved_object.ts +++ b/x-pack/plugins/security/server/authorization/actions/saved_object.ts @@ -13,10 +13,6 @@ export class SavedObjectActions { this.prefix = `saved_object:${versionNumber}:`; } - public get all(): string { - return `${this.prefix}*`; - } - public get(type: string, operation: string): string { if (!type || !isString(type)) { throw new Error('type is required and must be a string'); diff --git a/x-pack/plugins/security/server/authorization/actions/ui.test.ts b/x-pack/plugins/security/server/authorization/actions/ui.test.ts index f91b7baf78baa..32827822117d0 100644 --- a/x-pack/plugins/security/server/authorization/actions/ui.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/ui.test.ts @@ -8,34 +8,6 @@ import { UIActions } from './ui'; const version = '1.0.0-zeta1'; -describe('#all', () => { - test('returns `ui:${version}:*`', () => { - const uiActions = new UIActions(version); - expect(uiActions.all).toBe('ui:1.0.0-zeta1:*'); - }); -}); - -describe('#allNavlinks', () => { - test('returns `ui:${version}:navLinks/*`', () => { - const uiActions = new UIActions(version); - expect(uiActions.allNavLinks).toBe('ui:1.0.0-zeta1:navLinks/*'); - }); -}); - -describe('#allCatalogueEntries', () => { - test('returns `ui:${version}:catalogue/*`', () => { - const uiActions = new UIActions(version); - expect(uiActions.allCatalogueEntries).toBe('ui:1.0.0-zeta1:catalogue/*'); - }); -}); - -describe('#allManagementLinks', () => { - test('returns `ui:${version}:management/*`', () => { - const uiActions = new UIActions(version); - expect(uiActions.allManagementLinks).toBe('ui:1.0.0-zeta1:management/*'); - }); -}); - describe('#get', () => { [null, undefined, '', 1, true, {}].forEach((featureId: any) => { test(`featureId of ${JSON.stringify(featureId)} throws error`, () => { diff --git a/x-pack/plugins/security/server/authorization/actions/ui.ts b/x-pack/plugins/security/server/authorization/actions/ui.ts index 9e77c319a9b3a..3dae9a47b3827 100644 --- a/x-pack/plugins/security/server/authorization/actions/ui.ts +++ b/x-pack/plugins/security/server/authorization/actions/ui.ts @@ -14,22 +14,6 @@ export class UIActions { this.prefix = `ui:${versionNumber}:`; } - public get all(): string { - return `${this.prefix}*`; - } - - public get allNavLinks(): string { - return `${this.prefix}navLinks/*`; - } - - public get allCatalogueEntries(): string { - return `${this.prefix}catalogue/*`; - } - - public get allManagementLinks(): string { - return `${this.prefix}management/*`; - } - public get(featureId: keyof UICapabilities, ...uiCapabilityParts: string[]) { if (!featureId || !isString(featureId)) { throw new Error('featureId is required and must be a string'); diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts index 49c9db2d0e6e3..912ae60e12065 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts @@ -9,6 +9,7 @@ import { disableUICapabilitiesFactory } from './disable_ui_capabilities'; import { httpServerMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; import { authorizationMock } from './index.mock'; +import { Feature } from '../../../features/server'; type MockAuthzOptions = { rejectCheckPrivileges: any } | { resolveCheckPrivileges: any }; @@ -42,7 +43,15 @@ describe('usingPrivileges', () => { const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, - [{ id: 'fooFeature', name: 'Foo Feature', app: [], navLinkId: 'foo', privileges: {} }], + [ + new Feature({ + id: 'fooFeature', + name: 'Foo Feature', + app: [], + navLinkId: 'foo', + privileges: null, + }), + ], mockLoggers.get(), mockAuthz ); @@ -108,7 +117,15 @@ describe('usingPrivileges', () => { const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, - [{ id: 'fooFeature', name: 'Foo Feature', app: [], navLinkId: 'foo', privileges: {} }], + [ + new Feature({ + id: 'fooFeature', + name: 'Foo Feature', + app: [], + navLinkId: 'foo', + privileges: null, + }), + ], mockLoggers.get(), mockAuthz ); @@ -226,20 +243,20 @@ describe('usingPrivileges', () => { const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, [ - { + new Feature({ id: 'fooFeature', name: 'Foo Feature', navLinkId: 'foo', app: [], - privileges: {}, - }, - { + privileges: null, + }), + new Feature({ id: 'barFeature', name: 'Bar Feature', navLinkId: 'bar', app: [], - privileges: {}, - }, + privileges: null, + }), ], loggingServiceMock.create().get(), mockAuthz @@ -312,20 +329,20 @@ describe('usingPrivileges', () => { const { usingPrivileges } = disableUICapabilitiesFactory( mockRequest, [ - { + new Feature({ id: 'fooFeature', name: 'Foo Feature', navLinkId: 'foo', app: [], - privileges: {}, - }, - { + privileges: null, + }), + new Feature({ id: 'barFeature', name: 'Bar Feature', navLinkId: 'bar', app: [], - privileges: {}, - }, + privileges: null, + }), ], loggingServiceMock.create().get(), mockAuthz @@ -383,7 +400,15 @@ describe('all', () => { const { all } = disableUICapabilitiesFactory( mockRequest, - [{ id: 'fooFeature', name: 'Foo Feature', app: [], navLinkId: 'foo', privileges: {} }], + [ + new Feature({ + id: 'fooFeature', + name: 'Foo Feature', + app: [], + navLinkId: 'foo', + privileges: null, + }), + ], loggingServiceMock.create().get(), mockAuthz ); diff --git a/x-pack/plugins/security/server/authorization/index.test.ts b/x-pack/plugins/security/server/authorization/index.test.ts index 9e99cae620633..3252053454764 100644 --- a/x-pack/plugins/security/server/authorization/index.test.ts +++ b/x-pack/plugins/security/server/authorization/index.test.ts @@ -93,7 +93,7 @@ test(`returns exposed services`, () => { ); expect(authz.privileges).toBe(mockPrivilegesService); - expect(privilegesFactory).toHaveBeenCalledWith(authz.actions, mockFeaturesService); + expect(privilegesFactory).toHaveBeenCalledWith(authz.actions, mockFeaturesService, mockLicense); expect(authz.mode).toBe(mockAuthorizationMode); expect(authorizationModeFactory).toHaveBeenCalledWith(mockLicense); diff --git a/x-pack/plugins/security/server/authorization/index.ts b/x-pack/plugins/security/server/authorization/index.ts index 4cbc76ecb6be4..f065c9cfd90ba 100644 --- a/x-pack/plugins/security/server/authorization/index.ts +++ b/x-pack/plugins/security/server/authorization/index.ts @@ -35,6 +35,7 @@ import { SecurityLicense } from '../../common/licensing'; export { Actions } from './actions'; export { CheckSavedObjectsPrivileges } from './check_saved_objects_privileges'; +export { featurePrivilegeIterator } from './privileges'; interface SetupAuthorizationParams { packageVersion: string; @@ -80,7 +81,7 @@ export function setupAuthorization({ clusterClient, applicationName ); - const privileges = privilegesFactory(actions, featuresService); + const privileges = privilegesFactory(actions, featuresService, license); const logger = loggers.get('authorization'); const authz = { @@ -120,7 +121,7 @@ export function setupAuthorization({ }, registerPrivilegesWithCluster: async () => { - validateFeaturePrivileges(actions, featuresService.getFeatures()); + validateFeaturePrivileges(featuresService.getFeatures()); await registerPrivilegesWithCluster(logger, privileges, applicationName, clusterClient); }, diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts index c874886d908eb..514d6734b47ba 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts @@ -9,7 +9,7 @@ import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeAppBuilder extends BaseFeaturePrivilegeBuilder { public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { - const appIds = privilegeDefinition.app || feature.app; + const appIds = privilegeDefinition.app; if (!appIds) { return []; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts index 3dbe71db93f4a..fc15aff32b975 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts @@ -9,7 +9,7 @@ import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeCatalogueBuilder extends BaseFeaturePrivilegeBuilder { public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { - const catalogueEntries = privilegeDefinition.catalogue || feature.catalogue; + const catalogueEntries = privilegeDefinition.catalogue; if (!catalogueEntries) { return []; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts index 0180554a47ccc..7a2bb87d72b45 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts @@ -9,7 +9,7 @@ import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeManagementBuilder extends BaseFeaturePrivilegeBuilder { public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { - const managementSections = privilegeDefinition.management || feature.management; + const managementSections = privilegeDefinition.management; if (!managementSections) { return []; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts new file mode 100644 index 0000000000000..7d92eacfe6b35 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts @@ -0,0 +1,891 @@ +/* + * 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 { Feature } from '../../../../../features/server'; +import { featurePrivilegeIterator } from './feature_privilege_iterator'; + +describe('featurePrivilegeIterator', () => { + it('handles features with no privileges', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + privileges: null, + app: [], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toHaveLength(0); + }); + + it('handles features with no sub-features', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + app: ['foo'], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it('filters privileges using the provided predicate', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + app: ['foo'], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + predicate: privilegeId => privilegeId === 'all', + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it('ignores sub features when `augmentWithSubFeaturePrivileges` is false', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'read', + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: false, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it('ignores sub features when `includeIn` is none, even if `augmentWithSubFeaturePrivileges` is true', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'none', + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it('includes sub feature privileges into both all and read when`augmentWithSubFeaturePrivileges` is true and `includeIn: read`', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'read', + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + section: ['other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api', 'sub-feature-api'], + app: ['foo', 'sub-app'], + catalogue: ['foo-catalogue', 'sub-catalogue'], + management: { + section: ['foo-management', 'other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-type', 'all-sub-type'], + read: ['read-type', 'read-sub-type'], + }, + ui: ['ui-action', 'ui-sub-type'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api', 'sub-feature-api'], + app: ['foo', 'sub-app'], + catalogue: ['foo-catalogue', 'sub-catalogue'], + management: { + section: ['foo-management', 'other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-type', 'read-sub-type'], + }, + ui: ['ui-action', 'ui-sub-type'], + }, + }, + ]); + }); + + it('does not duplicate privileges when merging', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'read', + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it('includes sub feature privileges into both all and read when`augmentWithSubFeaturePrivileges` is true and `includeIn: all`', () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'all', + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + section: ['other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api', 'sub-feature-api'], + app: ['foo', 'sub-app'], + catalogue: ['foo-catalogue', 'sub-catalogue'], + management: { + section: ['foo-management', 'other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-type', 'all-sub-type'], + read: ['read-type', 'read-sub-type'], + }, + ui: ['ui-action', 'ui-sub-type'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); + + it(`can augment primary feature privileges even if they don't specify their own`, () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'read', + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + section: ['other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + section: ['other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['sub-feature-api'], + app: ['sub-app'], + catalogue: ['sub-catalogue'], + management: { + section: ['other-sub-management'], + kibana: ['sub-management'], + }, + savedObject: { + all: ['all-sub-type'], + read: ['read-sub-type'], + }, + ui: ['ui-sub-type'], + }, + }, + ]); + }); + + it(`can augment primary feature privileges even if the sub-feature privileges don't specify their own`, () => { + const feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + read: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-priv-1', + name: 'first sub feature privilege', + includeIn: 'read', + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }); + + const actualPrivileges = Array.from( + featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + }) + ); + + expect(actualPrivileges).toEqual([ + { + privilegeId: 'all', + privilege: { + api: ['all-api', 'read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: ['all-type'], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + { + privilegeId: 'read', + privilege: { + api: ['read-api'], + app: ['foo'], + catalogue: ['foo-catalogue'], + management: { + section: ['foo-management'], + }, + savedObject: { + all: [], + read: ['read-type'], + }, + ui: ['ui-action'], + }, + }, + ]); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts new file mode 100644 index 0000000000000..e239a6e280aec --- /dev/null +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts @@ -0,0 +1,83 @@ +/* + * 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 _ from 'lodash'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; +import { subFeaturePrivilegeIterator } from './sub_feature_privilege_iterator'; + +interface IteratorOptions { + augmentWithSubFeaturePrivileges: boolean; + predicate?: (privilegeId: string, privilege: FeatureKibanaPrivileges) => boolean; +} + +export function* featurePrivilegeIterator( + feature: Feature, + options: IteratorOptions +): IterableIterator<{ privilegeId: string; privilege: FeatureKibanaPrivileges }> { + for (const entry of Object.entries(feature.privileges ?? {})) { + const [privilegeId, privilege] = entry; + + if (options.predicate && !options.predicate(privilegeId, privilege)) { + continue; + } + + if (options.augmentWithSubFeaturePrivileges) { + yield { privilegeId, privilege: mergeWithSubFeatures(privilegeId, privilege, feature) }; + } else { + yield { privilegeId, privilege }; + } + } +} + +function mergeWithSubFeatures( + privilegeId: string, + privilege: FeatureKibanaPrivileges, + feature: Feature +) { + const mergedConfig = _.cloneDeep(privilege); + for (const subFeaturePrivilege of subFeaturePrivilegeIterator(feature)) { + if (subFeaturePrivilege.includeIn !== 'read' && subFeaturePrivilege.includeIn !== privilegeId) { + continue; + } + + mergedConfig.api = mergeArrays(mergedConfig.api, subFeaturePrivilege.api); + + mergedConfig.app = mergeArrays(mergedConfig.app, subFeaturePrivilege.app); + + mergedConfig.catalogue = mergeArrays(mergedConfig.catalogue, subFeaturePrivilege.catalogue); + + const managementEntries = Object.entries(mergedConfig.management ?? {}); + const subFeatureManagementEntries = Object.entries(subFeaturePrivilege.management ?? {}); + + mergedConfig.management = [managementEntries, subFeatureManagementEntries] + .flat() + .reduce((acc, [sectionId, managementApps]) => { + return { + ...acc, + [sectionId]: mergeArrays(acc[sectionId], managementApps), + }; + }, {} as Record); + + mergedConfig.ui = mergeArrays(mergedConfig.ui, subFeaturePrivilege.ui); + + mergedConfig.savedObject.all = mergeArrays( + mergedConfig.savedObject.all, + subFeaturePrivilege.savedObject.all + ); + + mergedConfig.savedObject.read = mergeArrays( + mergedConfig.savedObject.read, + subFeaturePrivilege.savedObject.read + ); + } + return mergedConfig; +} + +function mergeArrays(input1: string[] | undefined, input2: string[] | undefined) { + const first = input1 ?? []; + const second = input2 ?? []; + return Array.from(new Set([...first, ...second])); +} diff --git a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/index.ts similarity index 61% rename from x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts rename to x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/index.ts index db5bb3aa62a16..24af524c350b0 100644 --- a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './action_factory_definition'; -export * from './action_factory'; +export { featurePrivilegeIterator } from './feature_privilege_iterator'; +export { subFeaturePrivilegeIterator } from './sub_feature_privilege_iterator'; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts new file mode 100644 index 0000000000000..b288262be25c6 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/sub_feature_privilege_iterator.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SubFeaturePrivilegeConfig } from '../../../../../features/common'; +import { Feature } from '../../../../../features/server'; + +export function* subFeaturePrivilegeIterator( + feature: Feature +): IterableIterator { + for (const subFeature of feature.subFeatures) { + for (const group of subFeature.privilegeGroups) { + yield* group.privileges; + } + } +} diff --git a/x-pack/plugins/security/server/authorization/privileges/index.ts b/x-pack/plugins/security/server/authorization/privileges/index.ts index 22b9cd45d4c0f..e12a33ce509bd 100644 --- a/x-pack/plugins/security/server/authorization/privileges/index.ts +++ b/x-pack/plugins/security/server/authorization/privileges/index.ts @@ -5,3 +5,4 @@ */ export { privilegesFactory, PrivilegesService } from './privileges'; +export { featurePrivilegeIterator } from './feature_privilege_iterator'; diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 38d4d413c591e..3d25fc03f568b 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -11,9 +11,9 @@ import { privilegesFactory } from './privileges'; const actions = new Actions('1.0.0-zeta1'); describe('features', () => { - test('actions defined at the feature cascade to the privileges', () => { + test('actions defined at the feature do not cascade to the privileges', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo-feature', name: 'Foo Feature', icon: 'arrowDown', @@ -39,115 +39,25 @@ describe('features', () => { ui: [], }, }, - }, + }), ]; const mockFeaturesService = { getFeatures: jest.fn().mockReturnValue(features) }; - const privileges = privilegesFactory(actions, mockFeaturesService); - - const actual = privileges.get(); - expect(actual).toHaveProperty('features.foo-feature', { - all: [ - actions.login, - actions.version, - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'foo', 'management-1'), - actions.ui.get('management', 'foo', 'management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - actions.allHack, - ], - read: [ - actions.login, - actions.version, - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'foo', 'management-1'), - actions.ui.get('management', 'foo', 'management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - ], - }); - }); - - test('actions defined at the privilege take precedence', () => { - const features: Feature[] = [ - { - id: 'foo', - name: 'Foo Feature', - icon: 'arrowDown', - app: ['ignore-me-1', 'ignore-me-2'], - catalogue: ['ignore-me-1', 'ignore-me-2'], - management: { - foo: ['ignore-me-1', 'ignore-me-2'], - }, - privileges: { - all: { - app: ['all-app-1', 'all-app-2'], - catalogue: ['catalogue-all-1', 'catalogue-all-2'], - management: { - all: ['all-management-1', 'all-management-2'], - }, - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - read: { - app: ['read-app-1', 'read-app-2'], - catalogue: ['catalogue-read-1', 'catalogue-read-2'], - management: { - read: ['read-management-1', 'read-management-2'], - }, - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - }, - }, - ]; - - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const privileges = privilegesFactory(actions, mockFeaturesService, mockLicenseService); const actual = privileges.get(); - expect(actual).toHaveProperty('features.foo', { - all: [ - actions.login, - actions.version, - actions.app.get('all-app-1'), - actions.app.get('all-app-2'), - actions.ui.get('catalogue', 'catalogue-all-1'), - actions.ui.get('catalogue', 'catalogue-all-2'), - actions.ui.get('management', 'all', 'all-management-1'), - actions.ui.get('management', 'all', 'all-management-2'), - actions.allHack, - ], - read: [ - actions.login, - actions.version, - actions.app.get('read-app-1'), - actions.app.get('read-app-2'), - actions.ui.get('catalogue', 'catalogue-read-1'), - actions.ui.get('catalogue', 'catalogue-read-2'), - actions.ui.get('management', 'read', 'read-management-1'), - actions.ui.get('management', 'read', 'read-management-2'), - ], + expect(actual).toHaveProperty('features.foo-feature', { + all: [actions.login, actions.version, actions.ui.get('navLinks', 'kibana:foo')], + read: [actions.login, actions.version, actions.ui.get('navLinks', 'kibana:foo')], }); }); test(`actions only specified at the privilege are alright too`, () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -168,93 +78,100 @@ describe('features', () => { ui: ['read-ui-1', 'read-ui-2'], }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const expectedAllPrivileges = [ + actions.login, + actions.version, + actions.savedObject.get('all-savedObject-all-1', 'bulk_get'), + actions.savedObject.get('all-savedObject-all-1', 'get'), + actions.savedObject.get('all-savedObject-all-1', 'find'), + actions.savedObject.get('all-savedObject-all-1', 'create'), + actions.savedObject.get('all-savedObject-all-1', 'bulk_create'), + actions.savedObject.get('all-savedObject-all-1', 'update'), + actions.savedObject.get('all-savedObject-all-1', 'bulk_update'), + actions.savedObject.get('all-savedObject-all-1', 'delete'), + actions.savedObject.get('all-savedObject-all-2', 'bulk_get'), + actions.savedObject.get('all-savedObject-all-2', 'get'), + actions.savedObject.get('all-savedObject-all-2', 'find'), + actions.savedObject.get('all-savedObject-all-2', 'create'), + actions.savedObject.get('all-savedObject-all-2', 'bulk_create'), + actions.savedObject.get('all-savedObject-all-2', 'update'), + actions.savedObject.get('all-savedObject-all-2', 'bulk_update'), + actions.savedObject.get('all-savedObject-all-2', 'delete'), + actions.savedObject.get('all-savedObject-read-1', 'bulk_get'), + actions.savedObject.get('all-savedObject-read-1', 'get'), + actions.savedObject.get('all-savedObject-read-1', 'find'), + actions.savedObject.get('all-savedObject-read-2', 'bulk_get'), + actions.savedObject.get('all-savedObject-read-2', 'get'), + actions.savedObject.get('all-savedObject-read-2', 'find'), + actions.ui.get('foo', 'all-ui-1'), + actions.ui.get('foo', 'all-ui-2'), + ]; + + const expectedReadPrivileges = [ + actions.login, + actions.version, + actions.savedObject.get('read-savedObject-all-1', 'bulk_get'), + actions.savedObject.get('read-savedObject-all-1', 'get'), + actions.savedObject.get('read-savedObject-all-1', 'find'), + actions.savedObject.get('read-savedObject-all-1', 'create'), + actions.savedObject.get('read-savedObject-all-1', 'bulk_create'), + actions.savedObject.get('read-savedObject-all-1', 'update'), + actions.savedObject.get('read-savedObject-all-1', 'bulk_update'), + actions.savedObject.get('read-savedObject-all-1', 'delete'), + actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), + actions.savedObject.get('read-savedObject-all-2', 'get'), + actions.savedObject.get('read-savedObject-all-2', 'find'), + actions.savedObject.get('read-savedObject-all-2', 'create'), + actions.savedObject.get('read-savedObject-all-2', 'bulk_create'), + actions.savedObject.get('read-savedObject-all-2', 'update'), + actions.savedObject.get('read-savedObject-all-2', 'bulk_update'), + actions.savedObject.get('read-savedObject-all-2', 'delete'), + actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), + actions.savedObject.get('read-savedObject-read-1', 'get'), + actions.savedObject.get('read-savedObject-read-1', 'find'), + actions.savedObject.get('read-savedObject-read-2', 'bulk_get'), + actions.savedObject.get('read-savedObject-read-2', 'get'), + actions.savedObject.get('read-savedObject-read-2', 'find'), + actions.ui.get('foo', 'read-ui-1'), + actions.ui.get('foo', 'read-ui-2'), + ]; const actual = privileges.get(); expect(actual).toHaveProperty('features.foo', { - all: [ - actions.login, - actions.version, - actions.savedObject.get('all-savedObject-all-1', 'bulk_get'), - actions.savedObject.get('all-savedObject-all-1', 'get'), - actions.savedObject.get('all-savedObject-all-1', 'find'), - actions.savedObject.get('all-savedObject-all-1', 'create'), - actions.savedObject.get('all-savedObject-all-1', 'bulk_create'), - actions.savedObject.get('all-savedObject-all-1', 'update'), - actions.savedObject.get('all-savedObject-all-1', 'bulk_update'), - actions.savedObject.get('all-savedObject-all-1', 'delete'), - actions.savedObject.get('all-savedObject-all-2', 'bulk_get'), - actions.savedObject.get('all-savedObject-all-2', 'get'), - actions.savedObject.get('all-savedObject-all-2', 'find'), - actions.savedObject.get('all-savedObject-all-2', 'create'), - actions.savedObject.get('all-savedObject-all-2', 'bulk_create'), - actions.savedObject.get('all-savedObject-all-2', 'update'), - actions.savedObject.get('all-savedObject-all-2', 'bulk_update'), - actions.savedObject.get('all-savedObject-all-2', 'delete'), - actions.savedObject.get('all-savedObject-read-1', 'bulk_get'), - actions.savedObject.get('all-savedObject-read-1', 'get'), - actions.savedObject.get('all-savedObject-read-1', 'find'), - actions.savedObject.get('all-savedObject-read-2', 'bulk_get'), - actions.savedObject.get('all-savedObject-read-2', 'get'), - actions.savedObject.get('all-savedObject-read-2', 'find'), - actions.ui.get('foo', 'all-ui-1'), - actions.ui.get('foo', 'all-ui-2'), - actions.allHack, - ], - read: [ - actions.login, - actions.version, - actions.savedObject.get('read-savedObject-all-1', 'bulk_get'), - actions.savedObject.get('read-savedObject-all-1', 'get'), - actions.savedObject.get('read-savedObject-all-1', 'find'), - actions.savedObject.get('read-savedObject-all-1', 'create'), - actions.savedObject.get('read-savedObject-all-1', 'bulk_create'), - actions.savedObject.get('read-savedObject-all-1', 'update'), - actions.savedObject.get('read-savedObject-all-1', 'bulk_update'), - actions.savedObject.get('read-savedObject-all-1', 'delete'), - actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), - actions.savedObject.get('read-savedObject-all-2', 'get'), - actions.savedObject.get('read-savedObject-all-2', 'find'), - actions.savedObject.get('read-savedObject-all-2', 'create'), - actions.savedObject.get('read-savedObject-all-2', 'bulk_create'), - actions.savedObject.get('read-savedObject-all-2', 'update'), - actions.savedObject.get('read-savedObject-all-2', 'bulk_update'), - actions.savedObject.get('read-savedObject-all-2', 'delete'), - actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), - actions.savedObject.get('read-savedObject-read-1', 'get'), - actions.savedObject.get('read-savedObject-read-1', 'find'), - actions.savedObject.get('read-savedObject-read-2', 'bulk_get'), - actions.savedObject.get('read-savedObject-read-2', 'get'), - actions.savedObject.get('read-savedObject-read-2', 'find'), - actions.ui.get('foo', 'read-ui-1'), - actions.ui.get('foo', 'read-ui-2'), - ], + all: [...expectedAllPrivileges], + read: [...expectedReadPrivileges], }); }); test(`features with no privileges aren't listed`, () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', app: [], - privileges: {}, - }, + privileges: null, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).not.toHaveProperty('features.foo'); @@ -276,82 +193,9 @@ describe('features', () => { }, ].forEach(({ group, expectManageSpaces, expectGetFeatures }) => { describe(`${group}`, () => { - test('actions defined only at the feature are included in `all` and `read`', () => { - const features: Feature[] = [ - { - id: 'foo', - name: 'Foo Feature', - icon: 'arrowDown', - navLinkId: 'kibana:foo', - app: ['app-1', 'app-2'], - catalogue: ['catalogue-1', 'catalogue-2'], - management: { - foo: ['management-1', 'management-2'], - }, - privileges: { - all: { - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - read: { - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - }, - }, - ]; - - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), - }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); - - const actual = privileges.get(); - expect(actual).toHaveProperty(group, { - all: [ - actions.login, - actions.version, - ...(expectGetFeatures ? [actions.api.get('features')] : []), - ...(expectManageSpaces - ? [ - actions.space.manage, - actions.ui.get('spaces', 'manage'), - actions.ui.get('management', 'kibana', 'spaces'), - ] - : []), - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'foo', 'management-1'), - actions.ui.get('management', 'foo', 'management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - actions.allHack, - ], - read: [ - actions.login, - actions.version, - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'foo', 'management-1'), - actions.ui.get('management', 'foo', 'management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - ], - }); - }); - test('actions defined in any feature privilege are included in `all`', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -362,17 +206,6 @@ describe('features', () => { foo: ['ignore-me-1', 'ignore-me-2'], }, privileges: { - bar: { - management: { - 'bar-management': ['bar-management-1', 'bar-management-2'], - }, - catalogue: ['bar-catalogue-1', 'bar-catalogue-2'], - savedObject: { - all: ['bar-savedObject-all-1', 'bar-savedObject-all-2'], - read: ['bar-savedObject-read-1', 'bar-savedObject-read-2'], - }, - ui: ['bar-ui-1', 'bar-ui-2'], - }, all: { management: { 'all-management': ['all-management-1', 'all-management-2'], @@ -396,14 +229,16 @@ describe('features', () => { ui: ['read-ui-1', 'read-ui-2'], }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ @@ -417,39 +252,11 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), - actions.ui.get('catalogue', 'bar-catalogue-1'), - actions.ui.get('catalogue', 'bar-catalogue-2'), - actions.ui.get('management', 'bar-management', 'bar-management-1'), - actions.ui.get('management', 'bar-management', 'bar-management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - actions.savedObject.get('bar-savedObject-all-1', 'bulk_get'), - actions.savedObject.get('bar-savedObject-all-1', 'get'), - actions.savedObject.get('bar-savedObject-all-1', 'find'), - actions.savedObject.get('bar-savedObject-all-1', 'create'), - actions.savedObject.get('bar-savedObject-all-1', 'bulk_create'), - actions.savedObject.get('bar-savedObject-all-1', 'update'), - actions.savedObject.get('bar-savedObject-all-1', 'bulk_update'), - actions.savedObject.get('bar-savedObject-all-1', 'delete'), - actions.savedObject.get('bar-savedObject-all-2', 'bulk_get'), - actions.savedObject.get('bar-savedObject-all-2', 'get'), - actions.savedObject.get('bar-savedObject-all-2', 'find'), - actions.savedObject.get('bar-savedObject-all-2', 'create'), - actions.savedObject.get('bar-savedObject-all-2', 'bulk_create'), - actions.savedObject.get('bar-savedObject-all-2', 'update'), - actions.savedObject.get('bar-savedObject-all-2', 'bulk_update'), - actions.savedObject.get('bar-savedObject-all-2', 'delete'), - actions.savedObject.get('bar-savedObject-read-1', 'bulk_get'), - actions.savedObject.get('bar-savedObject-read-1', 'get'), - actions.savedObject.get('bar-savedObject-read-1', 'find'), - actions.savedObject.get('bar-savedObject-read-2', 'bulk_get'), - actions.savedObject.get('bar-savedObject-read-2', 'get'), - actions.savedObject.get('bar-savedObject-read-2', 'find'), - actions.ui.get('foo', 'bar-ui-1'), - actions.ui.get('foo', 'bar-ui-2'), actions.ui.get('catalogue', 'all-catalogue-1'), actions.ui.get('catalogue', 'all-catalogue-2'), actions.ui.get('management', 'all-management', 'all-management-1'), actions.ui.get('management', 'all-management', 'all-management-2'), + actions.ui.get('navLinks', 'kibana:foo'), actions.savedObject.get('all-savedObject-all-1', 'bulk_get'), actions.savedObject.get('all-savedObject-all-1', 'get'), actions.savedObject.get('all-savedObject-all-1', 'find'), @@ -502,13 +309,12 @@ describe('features', () => { actions.savedObject.get('read-savedObject-read-2', 'find'), actions.ui.get('foo', 'read-ui-1'), actions.ui.get('foo', 'read-ui-2'), - actions.allHack, ]); }); test('actions defined in a feature privilege with name `read` are included in `read`', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -519,17 +325,6 @@ describe('features', () => { foo: ['ignore-me-1', 'ignore-me-2'], }, privileges: { - bar: { - management: { - 'ignore-me': ['ignore-me-1', 'ignore-me-2'], - }, - catalogue: ['ignore-me-1', 'ignore-me-2'], - savedObject: { - all: ['ignore-me-1', 'ignore-me-2'], - read: ['ignore-me-1', 'ignore-me-2'], - }, - ui: ['ignore-me-1', 'ignore-me-2'], - }, all: { management: { 'ignore-me': ['ignore-me-1', 'ignore-me-2'], @@ -553,14 +348,16 @@ describe('features', () => { ui: ['read-ui-1', 'read-ui-2'], }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.read`, [ @@ -600,7 +397,7 @@ describe('features', () => { test('actions defined in a reserved privilege are not included in `all` or `read`', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -610,7 +407,7 @@ describe('features', () => { management: { foo: ['ignore-me-1', 'ignore-me-2'], }, - privileges: {}, + privileges: null, reserved: { privilege: { savedObject: { @@ -621,14 +418,16 @@ describe('features', () => { }, description: '', }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ @@ -642,14 +441,13 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), - actions.allHack, ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); test('actions defined in a feature with excludeFromBasePrivileges are not included in `all` or `read', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', excludeFromBasePrivileges: true, @@ -661,17 +459,6 @@ describe('features', () => { foo: ['ignore-me-1', 'ignore-me-2'], }, privileges: { - bar: { - management: { - 'bar-management': ['bar-management-1'], - }, - catalogue: ['bar-catalogue-1'], - savedObject: { - all: ['bar-savedObject-all-1'], - read: ['bar-savedObject-read-1'], - }, - ui: ['bar-ui-1'], - }, all: { management: { 'all-management': ['all-management-1'], @@ -695,14 +482,16 @@ describe('features', () => { ui: ['read-ui-1'], }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ @@ -716,14 +505,13 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), - actions.allHack, ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); test('actions defined in an individual feature privilege with excludeFromBasePrivileges are not included in `all` or `read`', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -734,18 +522,6 @@ describe('features', () => { foo: ['ignore-me-1', 'ignore-me-2'], }, privileges: { - bar: { - excludeFromBasePrivileges: true, - management: { - 'bar-management': ['bar-management-1'], - }, - catalogue: ['bar-catalogue-1'], - savedObject: { - all: ['bar-savedObject-all-1'], - read: ['bar-savedObject-read-1'], - }, - ui: ['bar-ui-1'], - }, all: { excludeFromBasePrivileges: true, management: { @@ -771,14 +547,16 @@ describe('features', () => { ui: ['read-ui-1'], }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty(`${group}.all`, [ @@ -792,7 +570,6 @@ describe('features', () => { actions.ui.get('management', 'kibana', 'spaces'), ] : []), - actions.allHack, ]); expect(actual).toHaveProperty(`${group}.read`, [actions.login, actions.version]); }); @@ -800,9 +577,9 @@ describe('features', () => { }); describe('reserved', () => { - test('actions defined at the feature cascade to the privileges', () => { + test('actions defined at the feature do not cascade to the privileges', () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -812,7 +589,7 @@ describe('reserved', () => { management: { foo: ['management-1', 'management-2'], }, - privileges: {}, + privileges: null, reserved: { privilege: { savedObject: { @@ -823,84 +600,32 @@ describe('reserved', () => { }, description: '', }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); - - const actual = privileges.get(); - expect(actual).toHaveProperty('reserved.foo', [ - actions.version, - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'foo', 'management-1'), - actions.ui.get('management', 'foo', 'management-2'), - actions.ui.get('navLinks', 'kibana:foo'), - ]); - }); - - test('actions defined at the reservedPrivilege take precedence', () => { - const features: Feature[] = [ - { - id: 'foo', - name: 'Foo Feature', - icon: 'arrowDown', - app: ['ignore-me-1', 'ignore-me-2'], - catalogue: ['ignore-me-1', 'ignore-me-2'], - management: { - foo: ['ignore-me-1', 'ignore-me-2'], - }, - privileges: {}, - reserved: { - privilege: { - app: ['app-1', 'app-2'], - catalogue: ['catalogue-1', 'catalogue-2'], - management: { - bar: ['management-1', 'management-2'], - }, - savedObject: { - all: [], - read: [], - }, - ui: [], - }, - description: '', - }, - }, - ]; - - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty('reserved.foo', [ actions.version, - actions.app.get('app-1'), - actions.app.get('app-2'), - actions.ui.get('catalogue', 'catalogue-1'), - actions.ui.get('catalogue', 'catalogue-2'), - actions.ui.get('management', 'bar', 'management-1'), - actions.ui.get('management', 'bar', 'management-2'), + actions.ui.get('navLinks', 'kibana:foo'), ]); }); test(`actions only specified at the privilege are alright too`, () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', app: [], - privileges: {}, + privileges: null, reserved: { privilege: { savedObject: { @@ -911,14 +636,16 @@ describe('reserved', () => { }, description: '', }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).toHaveProperty('reserved.foo', [ @@ -952,7 +679,7 @@ describe('reserved', () => { test(`features with no reservedPrivileges aren't listed`, () => { const features: Feature[] = [ - { + new Feature({ id: 'foo', name: 'Foo Feature', icon: 'arrowDown', @@ -965,17 +692,953 @@ describe('reserved', () => { }, ui: ['foo'], }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, }, - }, + }), ]; const mockXPackMainPlugin = { getFeatures: jest.fn().mockReturnValue(features), }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); const actual = privileges.get(); expect(actual).not.toHaveProperty('reserved.foo'); }); }); + +describe('subFeatures', () => { + describe(`with includeIn: 'none'`, () => { + test(`should not augment the primary feature privileges, base privileges, or minimal feature privileges`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'none', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty('foo.all', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + expect(actual.features).toHaveProperty('foo.minimal_all', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty('foo.read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + expect(actual.features).toHaveProperty('foo.minimal_read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + actions.ui.get('foo', 'foo'), + ]); + expect(actual).toHaveProperty('global.read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('space.all', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + expect(actual).toHaveProperty('space.read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + }); + }); + + describe(`with includeIn: 'read'`, () => { + test(`should augment the primary feature privileges and base privileges, but never the minimal versions`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'read', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_all`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('global.read', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual).toHaveProperty('space.all', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('space.read', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + }); + + test(`should augment the primary feature privileges, but not base privileges if feature is excluded from them.`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + excludeFromBasePrivileges: true, + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'read', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_all`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + ]); + expect(actual).toHaveProperty('global.read', [actions.login, actions.version]); + + expect(actual).toHaveProperty('space.all', [actions.login, actions.version]); + expect(actual).toHaveProperty('space.read', [actions.login, actions.version]); + }); + }); + + describe(`with includeIn: 'all'`, () => { + test(`should augment the primary 'all' feature privileges and base 'all' privileges, but never the minimal versions`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'all', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_all`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('global.read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('space.all', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('space.read', [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + }); + + test(`should augment the primary 'all' feature privileges, but not the base privileges if the feature is excluded from them`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + excludeFromBasePrivileges: true, + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'all', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: true }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_all`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual.features).toHaveProperty(`foo.minimal_read`, [ + actions.login, + actions.version, + actions.ui.get('foo', 'foo'), + ]); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + ]); + expect(actual).toHaveProperty('global.read', [actions.login, actions.version]); + + expect(actual).toHaveProperty('space.all', [actions.login, actions.version]); + expect(actual).toHaveProperty('space.read', [actions.login, actions.version]); + }); + }); + + describe(`when license does not allow sub features`, () => { + test(`should augment the primary feature privileges, and should not create minimal or sub-feature privileges`, () => { + const features: Feature[] = [ + new Feature({ + id: 'foo', + name: 'Foo Feature', + icon: 'arrowDown', + app: [], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: ['foo'], + }, + }, + subFeatures: [ + { + name: 'subFeature1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'subFeaturePriv1', + name: 'sub feature priv 1', + includeIn: 'read', + savedObject: { + all: ['all-sub-feature-type'], + read: ['read-sub-feature-type'], + }, + ui: ['sub-feature-ui'], + }, + ], + }, + ], + }, + ], + }), + ]; + + const mockXPackMainPlugin = { + getFeatures: jest.fn().mockReturnValue(features), + }; + const mockLicenseService = { + getFeatures: jest.fn().mockReturnValue({ allowSubFeaturePrivileges: false }), + }; + const privileges = privilegesFactory(actions, mockXPackMainPlugin as any, mockLicenseService); + + const actual = privileges.get(); + expect(actual.features).not.toHaveProperty(`foo.subFeaturePriv1`); + + expect(actual.features).toHaveProperty(`foo.all`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).not.toHaveProperty(`foo.minimal_all`); + + expect(actual.features).toHaveProperty(`foo.read`, [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual.features).not.toHaveProperty(`foo.minimal_read`); + + expect(actual).toHaveProperty('global.all', [ + actions.login, + actions.version, + actions.api.get('features'), + actions.space.manage, + actions.ui.get('spaces', 'manage'), + actions.ui.get('management', 'kibana', 'spaces'), + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('global.read', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + + expect(actual).toHaveProperty('space.all', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + expect(actual).toHaveProperty('space.read', [ + actions.login, + actions.version, + actions.savedObject.get('all-sub-feature-type', 'bulk_get'), + actions.savedObject.get('all-sub-feature-type', 'get'), + actions.savedObject.get('all-sub-feature-type', 'find'), + actions.savedObject.get('all-sub-feature-type', 'create'), + actions.savedObject.get('all-sub-feature-type', 'bulk_create'), + actions.savedObject.get('all-sub-feature-type', 'update'), + actions.savedObject.get('all-sub-feature-type', 'bulk_update'), + actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('read-sub-feature-type', 'bulk_get'), + actions.savedObject.get('read-sub-feature-type', 'get'), + actions.savedObject.get('read-sub-feature-type', 'find'), + actions.ui.get('foo', 'foo'), + actions.ui.get('foo', 'sub-feature-ui'), + ]); + }); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts index c73c4be8f36ac..b25aad30a3423 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -4,65 +4,94 @@ * you may not use this file except in compliance with the Elastic License. */ -import { flatten, mapValues, uniq } from 'lodash'; +import { uniq } from 'lodash'; +import { SecurityLicense } from '../../../common/licensing'; import { Feature } from '../../../../features/server'; -import { RawKibanaFeaturePrivileges, RawKibanaPrivileges } from '../../../common/model'; +import { RawKibanaPrivileges } from '../../../common/model'; import { Actions } from '../actions'; import { featurePrivilegeBuilderFactory } from './feature_privilege_builder'; import { FeaturesService } from '../../plugin'; +import { + featurePrivilegeIterator, + subFeaturePrivilegeIterator, +} from './feature_privilege_iterator'; export interface PrivilegesService { get(): RawKibanaPrivileges; } -export function privilegesFactory(actions: Actions, featuresService: FeaturesService) { +export function privilegesFactory( + actions: Actions, + featuresService: FeaturesService, + licenseService: Pick +) { const featurePrivilegeBuilder = featurePrivilegeBuilderFactory(actions); return { get() { const features = featuresService.getFeatures(); + const { allowSubFeaturePrivileges } = licenseService.getFeatures(); const basePrivilegeFeatures = features.filter(feature => !feature.excludeFromBasePrivileges); - const allActions = uniq( - flatten( - basePrivilegeFeatures.map(feature => - Object.values(feature.privileges).reduce((acc, privilege) => { - if (privilege.excludeFromBasePrivileges) { - return acc; - } + let allActions: string[] = []; + let readActions: string[] = []; - return [...acc, ...featurePrivilegeBuilder.getActions(privilege, feature)]; - }, []) - ) - ) - ); + basePrivilegeFeatures.forEach(feature => { + for (const { privilegeId, privilege } of featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + predicate: (pId, featurePrivilege) => !featurePrivilege.excludeFromBasePrivileges, + })) { + const privilegeActions = featurePrivilegeBuilder.getActions(privilege, feature); + allActions = [...allActions, ...privilegeActions]; + if (privilegeId === 'read') { + readActions = [...readActions, ...privilegeActions]; + } + } + }); - const readActions = uniq( - flatten( - basePrivilegeFeatures.map(feature => - Object.entries(feature.privileges).reduce((acc, [privilegeId, privilege]) => { - if (privilegeId !== 'read' || privilege.excludeFromBasePrivileges) { - return acc; - } + allActions = uniq(allActions); + readActions = uniq(readActions); - return [...acc, ...featurePrivilegeBuilder.getActions(privilege, feature)]; - }, []) - ) - ) - ); + const featurePrivileges: Record> = {}; + for (const feature of features) { + featurePrivileges[feature.id] = {}; + for (const featurePrivilege of featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: true, + })) { + featurePrivileges[feature.id][featurePrivilege.privilegeId] = [ + actions.login, + actions.version, + ...uniq(featurePrivilegeBuilder.getActions(featurePrivilege.privilege, feature)), + ]; + } - return { - features: features.reduce((acc: RawKibanaFeaturePrivileges, feature: Feature) => { - if (Object.keys(feature.privileges).length > 0) { - acc[feature.id] = mapValues(feature.privileges, (privilege, privilegeId) => [ + if (allowSubFeaturePrivileges && feature.subFeatures?.length > 0) { + for (const featurePrivilege of featurePrivilegeIterator(feature, { + augmentWithSubFeaturePrivileges: false, + })) { + featurePrivileges[feature.id][`minimal_${featurePrivilege.privilegeId}`] = [ actions.login, actions.version, - ...featurePrivilegeBuilder.getActions(privilege, feature), - ...(privilegeId === 'all' ? [actions.allHack] : []), - ]); + ...uniq(featurePrivilegeBuilder.getActions(featurePrivilege.privilege, feature)), + ]; } - return acc; - }, {}), + + for (const subFeaturePrivilege of subFeaturePrivilegeIterator(feature)) { + featurePrivileges[feature.id][subFeaturePrivilege.id] = [ + actions.login, + actions.version, + ...uniq(featurePrivilegeBuilder.getActions(subFeaturePrivilege, feature)), + ]; + } + } + + if (Object.keys(featurePrivileges[feature.id]).length === 0) { + delete featurePrivileges[feature.id]; + } + } + + return { + features: featurePrivileges, global: { all: [ actions.login, @@ -72,12 +101,11 @@ export function privilegesFactory(actions: Actions, featuresService: FeaturesSer actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), ...allActions, - actions.allHack, ], read: [actions.login, actions.version, ...readActions], }, space: { - all: [actions.login, actions.version, ...allActions, actions.allHack], + all: [actions.login, actions.version, ...allActions], read: [actions.login, actions.version, ...readActions], }, reserved: features.reduce((acc: Record, feature: Feature) => { diff --git a/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts b/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts index 3dc3ae03b18cb..ac386d287cff1 100644 --- a/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts @@ -5,13 +5,42 @@ */ import { Feature } from '../../../features/server'; -import { Actions } from './actions'; import { validateFeaturePrivileges } from './validate_feature_privileges'; -const actions = new Actions('1.0.0-zeta1'); +it('allows features to be defined without privileges', () => { + const feature: Feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: null, + }); -it(`doesn't allow read to grant privileges which aren't also included in all`, () => { - const feature: Feature = { + validateFeaturePrivileges([feature]); +}); + +it('allows features with reserved privileges to be defined', () => { + const feature: Feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: null, + reserved: { + description: 'foo', + privilege: { + savedObject: { + all: ['foo'], + read: ['bar'], + }, + ui: [], + }, + }, + }); + + validateFeaturePrivileges([feature]); +}); + +it('allows features with sub-features to be defined', () => { + const feature: Feature = new Feature({ id: 'foo', name: 'foo', app: [], @@ -31,15 +60,50 @@ it(`doesn't allow read to grant privileges which aren't also included in all`, ( ui: [], }, }, - }; + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-1-priv-1', + name: 'some sub feature', + includeIn: 'all', + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + ], + }, + { + groupType: 'independent', + privileges: [ + { + id: 'sub-feature-1-priv-2', + name: 'some second sub feature', + includeIn: 'none', + savedObject: { + all: ['foo', 'bar'], + read: ['baz'], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }); - expect(() => validateFeaturePrivileges(actions, [feature])).toThrowErrorMatchingInlineSnapshot( - `"foo's \\"all\\" privilege should be a superset of the \\"read\\" privilege."` - ); + validateFeaturePrivileges([feature]); }); -it(`allows all and read to grant the same privileges`, () => { - const feature: Feature = { +it('does not allow features with sub-features which have id conflicts with the minimal privileges', () => { + const feature: Feature = new Feature({ id: 'foo', name: 'foo', app: [], @@ -54,18 +118,42 @@ it(`allows all and read to grant the same privileges`, () => { read: { savedObject: { all: ['foo'], - read: ['bar'], + read: ['bar', 'baz'], }, ui: [], }, }, - }; + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'minimal_all', + name: 'some sub feature', + includeIn: 'all', + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }); - validateFeaturePrivileges(actions, [feature]); + expect(() => validateFeaturePrivileges([feature])).toThrowErrorMatchingInlineSnapshot( + `"Feature 'foo' already has a privilege with ID 'minimal_all'. Sub feature 'sub-feature-1' cannot also specify this."` + ); }); -it(`allows all to grant privileges in addition to read`, () => { - const feature: Feature = { +it('does not allow features with sub-features which have id conflicts with the primary feature privileges', () => { + const feature: Feature = new Feature({ id: 'foo', name: 'foo', app: [], @@ -73,19 +161,113 @@ it(`allows all to grant privileges in addition to read`, () => { all: { savedObject: { all: ['foo'], - read: ['bar', 'baz'], + read: ['bar'], }, ui: [], }, read: { + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + }, + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'read', + name: 'some sub feature', + includeIn: 'all', + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }); + + expect(() => validateFeaturePrivileges([feature])).toThrowErrorMatchingInlineSnapshot( + `"Feature 'foo' already has a privilege with ID 'read'. Sub feature 'sub-feature-1' cannot also specify this."` + ); +}); + +it('does not allow features with sub-features which have id conflicts each other', () => { + const feature: Feature = new Feature({ + id: 'foo', + name: 'foo', + app: [], + privileges: { + all: { savedObject: { all: ['foo'], read: ['bar'], }, ui: [], }, + read: { + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, }, - }; + subFeatures: [ + { + name: 'sub-feature-1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'some-sub-feature', + name: 'some sub feature', + includeIn: 'all', + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + ], + }, + ], + }, + { + name: 'sub-feature-2', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'some-sub-feature', + name: 'some sub feature', + includeIn: 'all', + savedObject: { + all: ['foo'], + read: ['bar', 'baz'], + }, + ui: [], + }, + ], + }, + ], + }, + ], + }); - validateFeaturePrivileges(actions, [feature]); + expect(() => validateFeaturePrivileges([feature])).toThrowErrorMatchingInlineSnapshot( + `"Feature 'foo' already has a privilege with ID 'some-sub-feature'. Sub feature 'sub-feature-2' cannot also specify this."` + ); }); diff --git a/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts b/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts index 7998c816ae1c7..510feb1151a9b 100644 --- a/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts +++ b/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts @@ -5,21 +5,27 @@ */ import { Feature } from '../../../features/server'; -import { areActionsFullyCovered } from '../../common/privilege_calculator_utils'; -import { Actions } from './actions'; -import { featurePrivilegeBuilderFactory } from './privileges/feature_privilege_builder'; -export function validateFeaturePrivileges(actions: Actions, features: Feature[]) { - const featurePrivilegeBuilder = featurePrivilegeBuilderFactory(actions); +export function validateFeaturePrivileges(features: Feature[]) { for (const feature of features) { - if (feature.privileges.all != null && feature.privileges.read != null) { - const allActions = featurePrivilegeBuilder.getActions(feature.privileges.all, feature); - const readActions = featurePrivilegeBuilder.getActions(feature.privileges.read, feature); - if (!areActionsFullyCovered(allActions, readActions)) { - throw new Error( - `${feature.id}'s "all" privilege should be a superset of the "read" privilege.` - ); - } - } + const seenPrivilegeIds = new Set(); + Object.keys(feature.privileges ?? {}).forEach(privilegeId => { + seenPrivilegeIds.add(privilegeId); + seenPrivilegeIds.add(`minimal_${privilegeId}`); + }); + + const subFeatureEntries = feature.subFeatures ?? []; + subFeatureEntries.forEach(subFeature => { + subFeature.privilegeGroups.forEach(subFeaturePrivilegeGroup => { + subFeaturePrivilegeGroup.privileges.forEach(subFeaturePrivilege => { + if (seenPrivilegeIds.has(subFeaturePrivilege.id)) { + throw new Error( + `Feature '${feature.id}' already has a privilege with ID '${subFeaturePrivilege.id}'. Sub feature '${subFeature.name}' cannot also specify this.` + ); + } + seenPrivilegeIds.add(subFeaturePrivilege.id); + }); + }); + }); } } diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index a23c826b32fbd..4767f57de764c 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -82,7 +82,6 @@ describe('Security Plugin', () => { }, "authz": Object { "actions": Actions { - "allHack": "allHack:", "api": ApiActions { "prefix": "api:version:", }, 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 9217d5a437f9c..7751f9a952c09 100644 --- a/x-pack/plugins/security/server/routes/views/login.test.ts +++ b/x-pack/plugins/security/server/routes/views/login.test.ts @@ -163,6 +163,7 @@ describe('Login view routes', () => { layout: 'error-es-unavailable', showLinks: false, showRoleMappingsManagement: true, + allowSubFeaturePrivileges: true, showLogin: true, }); diff --git a/x-pack/plugins/snapshot_restore/public/application/constants/index.ts b/x-pack/plugins/snapshot_restore/public/application/constants/index.ts index 481516479df4e..9c8fb3d288d24 100644 --- a/x-pack/plugins/snapshot_restore/public/application/constants/index.ts +++ b/x-pack/plugins/snapshot_restore/public/application/constants/index.ts @@ -17,7 +17,7 @@ export enum REPOSITORY_DOC_PATHS { default = 'modules-snapshots.html', fs = 'modules-snapshots.html#_shared_file_system_repository', url = 'modules-snapshots.html#_read_only_url_repository', - source = 'modules-snapshots.html#_source_only_repository', + source = 'snapshots-register-repository.html#snapshots-source-only-repository', s3 = 'repository-s3.html', hdfs = 'repository-hdfs.html', azure = 'repository-azure.html', diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap index 7db3d5456fbd3..6d40ce15fc57f 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/__snapshots__/enabled_features.test.tsx.snap @@ -91,14 +91,14 @@ exports[`EnabledFeatures renders as expected 1`] = ` "icon": "spacesApp", "id": "feature-1", "name": "Feature 1", - "privileges": Object {}, + "privileges": null, }, Object { "app": Array [], "icon": "spacesApp", "id": "feature-2", "name": "Feature 2", - "privileges": Object {}, + "privileges": null, }, ] } diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx index d9282ad0457dd..ca53a9eb17253 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.test.tsx @@ -10,22 +10,22 @@ import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; import { Space } from '../../../../common/model/space'; import { SectionPanel } from '../section_panel'; import { EnabledFeatures } from './enabled_features'; -import { Feature } from '../../../../../features/public'; +import { FeatureConfig } from '../../../../../features/public'; -const features: Feature[] = [ +const features: FeatureConfig[] = [ { id: 'feature-1', name: 'Feature 1', icon: 'spacesApp', app: [], - privileges: {}, + privileges: null, }, { id: 'feature-2', name: 'Feature 2', icon: 'spacesApp', app: [], - privileges: {}, + privileges: null, }, ]; diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx index 52a0fe8d4d26c..6f0462a6ddcc2 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/enabled_features.tsx @@ -8,7 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText, EuiTitle } from import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component, Fragment, ReactNode } from 'react'; -import { Feature } from '../../../../../../plugins/features/public'; +import { FeatureConfig } from '../../../../../../plugins/features/public'; import { Space } from '../../../../common/model/space'; import { getEnabledFeatures } from '../../lib/feature_utils'; import { SectionPanel } from '../section_panel'; @@ -16,7 +16,7 @@ import { FeatureTable } from './feature_table'; interface Props { space: Partial; - features: Feature[]; + features: FeatureConfig[]; securityEnabled: boolean; onChange: (space: Partial) => void; } diff --git a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx index 380f151b54a18..880842ed0ae30 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/enabled_features/feature_table.tsx @@ -9,13 +9,13 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import _ from 'lodash'; import React, { ChangeEvent, Component } from 'react'; -import { Feature } from '../../../../../../plugins/features/public'; +import { FeatureConfig } from '../../../../../../plugins/features/public'; import { Space } from '../../../../common/model/space'; import { ToggleAllFeatures } from './toggle_all_features'; interface Props { space: Partial; - features: Feature[]; + features: FeatureConfig[]; onChange: (space: Partial) => void; } @@ -69,7 +69,10 @@ export class FeatureTable extends Component { name: i18n.translate('xpack.spaces.management.enabledSpaceFeaturesFeatureColumnTitle', { defaultMessage: 'Feature', }), - render: (feature: Feature, _item: { feature: Feature; space: Props['space'] }) => { + render: ( + feature: FeatureConfig, + _item: { feature: FeatureConfig; space: Props['space'] } + ) => { return ( diff --git a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx index 2aba1522a7e3f..b79bbd0d6ab3f 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx @@ -13,7 +13,9 @@ import { ManageSpacePage } from './manage_space_page'; import { SectionPanel } from './section_panel'; import { spacesManagerMock } from '../../spaces_manager/mocks'; import { SpacesManager } from '../../spaces_manager'; -import { httpServiceMock, notificationServiceMock } from 'src/core/public/mocks'; +import { notificationServiceMock } from 'src/core/public/mocks'; +import { featuresPluginMock } from '../../../../features/public/mocks'; +import { Feature } from '../../../../features/public'; const space = { id: 'my-space', @@ -21,19 +23,27 @@ const space = { disabledFeatures: [], }; +const featuresStart = featuresPluginMock.createStart(); +featuresStart.getFeatures.mockResolvedValue([ + new Feature({ + id: 'feature-1', + name: 'feature 1', + icon: 'spacesApp', + app: [], + privileges: null, + }), +]); + describe('ManageSpacePage', () => { it('allows a space to be created', async () => { const spacesManager = spacesManagerMock.create(); spacesManager.createSpace = jest.fn(spacesManager.createSpace); spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); - const httpStart = httpServiceMock.createStartContract(); - httpStart.get.mockResolvedValue([{ id: 'feature-1', name: 'feature 1', icon: 'spacesApp' }]); - const wrapper = mountWithIntl( { }); spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); - const httpStart = httpServiceMock.createStartContract(); - httpStart.get.mockResolvedValue([{ id: 'feature-1', name: 'feature 1', icon: 'spacesApp' }]); - const onLoadSpace = jest.fn(); const wrapper = mountWithIntl( @@ -93,7 +100,7 @@ describe('ManageSpacePage', () => { spaceId={'existing-space'} spacesManager={(spacesManager as unknown) as SpacesManager} onLoadSpace={onLoadSpace} - http={httpStart} + getFeatures={featuresStart.getFeatures} notifications={notificationServiceMock.createStartContract()} securityEnabled={true} capabilities={{ @@ -130,6 +137,37 @@ describe('ManageSpacePage', () => { }); }); + it('notifies when there is an error retrieving features', async () => { + const spacesManager = spacesManagerMock.create(); + spacesManager.createSpace = jest.fn(spacesManager.createSpace); + spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); + + const error = new Error('something awful happened'); + + const notifications = notificationServiceMock.createStartContract(); + + const wrapper = mountWithIntl( + Promise.reject(error)} + notifications={notifications} + securityEnabled={true} + capabilities={{ + navLinks: {}, + management: {}, + catalogue: {}, + spaces: { manage: true }, + }} + /> + ); + + await waitForDataLoad(wrapper); + + expect(notifications.toasts.addError).toHaveBeenCalledWith(error, { + title: 'Error loading available features', + }); + }); + it('warns when updating features in the active space', async () => { const spacesManager = spacesManagerMock.create(); spacesManager.getSpace = jest.fn().mockResolvedValue({ @@ -142,14 +180,11 @@ describe('ManageSpacePage', () => { }); spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); - const httpStart = httpServiceMock.createStartContract(); - httpStart.get.mockResolvedValue([{ id: 'feature-1', name: 'feature 1', icon: 'spacesApp' }]); - const wrapper = mountWithIntl( { }); spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); - const httpStart = httpServiceMock.createStartContract(); - httpStart.get.mockResolvedValue([{ id: 'feature-1', name: 'feature 1', icon: 'spacesApp' }]); - const wrapper = mountWithIntl( { return; } - const { spaceId, http } = this.props; + const { spaceId, getFeatures, notifications } = this.props; - const getFeatures = http.get('/api/features'); - - if (spaceId) { - await this.loadSpace(spaceId, getFeatures); - } else { - const features = await getFeatures; - this.setState({ isLoading: false, features }); + try { + if (spaceId) { + await this.loadSpace(spaceId, getFeatures()); + } else { + const features = await getFeatures(); + this.setState({ isLoading: false, features }); + } + } catch (e) { + notifications.toasts.addError(e, { + title: i18n.translate('xpack.spaces.management.manageSpacePage.loadErrorTitle', { + defaultMessage: 'Error loading available features', + }), + }); } } @@ -318,7 +324,7 @@ export class ManageSpacePage extends Component { this.setState({ space, - features: await features, + features, originalSpace: space, isLoading: false, }); diff --git a/x-pack/plugins/spaces/public/management/lib/feature_utils.ts b/x-pack/plugins/spaces/public/management/lib/feature_utils.ts index a1b64eb954403..09dbe886ab191 100644 --- a/x-pack/plugins/spaces/public/management/lib/feature_utils.ts +++ b/x-pack/plugins/spaces/public/management/lib/feature_utils.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../../features/common'; +import { FeatureConfig } from '../../../../features/common'; import { Space } from '../..'; -export function getEnabledFeatures(features: Feature[], space: Partial) { +export function getEnabledFeatures(features: FeatureConfig[], space: Partial) { return features.filter(feature => !(space.disabledFeatures || []).includes(feature.id)); } diff --git a/x-pack/plugins/spaces/public/management/management_service.test.ts b/x-pack/plugins/spaces/public/management/management_service.test.ts index d4c6bdaea2776..782c261be9664 100644 --- a/x-pack/plugins/spaces/public/management/management_service.test.ts +++ b/x-pack/plugins/spaces/public/management/management_service.test.ts @@ -10,6 +10,8 @@ import { spacesManagerMock } from '../spaces_manager/mocks'; import { managementPluginMock } from '../../../../../src/plugins/management/public/mocks'; import { ManagementSection } from 'src/plugins/management/public'; import { Capabilities } from 'kibana/public'; +import { PluginsStart } from '../plugin'; +import { CoreSetup } from 'src/core/public'; describe('ManagementService', () => { describe('#setup', () => { @@ -19,7 +21,9 @@ describe('ManagementService', () => { } as unknown) as ManagementSection; const deps = { management: managementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + getStartServices: coreMock.createSetup().getStartServices as CoreSetup< + PluginsStart + >['getStartServices'], spacesManager: spacesManagerMock.create(), }; @@ -43,7 +47,9 @@ describe('ManagementService', () => { it('will not crash if the kibana section is missing', () => { const deps = { management: managementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + getStartServices: coreMock.createSetup().getStartServices as CoreSetup< + PluginsStart + >['getStartServices'], spacesManager: spacesManagerMock.create(), }; @@ -61,7 +67,9 @@ describe('ManagementService', () => { const deps = { management: managementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + getStartServices: coreMock.createSetup().getStartServices as CoreSetup< + PluginsStart + >['getStartServices'], spacesManager: spacesManagerMock.create(), }; @@ -88,7 +96,9 @@ describe('ManagementService', () => { const deps = { management: managementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + getStartServices: coreMock.createSetup().getStartServices as CoreSetup< + PluginsStart + >['getStartServices'], spacesManager: spacesManagerMock.create(), }; @@ -117,7 +127,9 @@ describe('ManagementService', () => { const deps = { management: managementPluginMock.createSetupContract(), - getStartServices: coreMock.createSetup().getStartServices, + getStartServices: coreMock.createSetup().getStartServices as CoreSetup< + PluginsStart + >['getStartServices'], spacesManager: spacesManagerMock.create(), }; diff --git a/x-pack/plugins/spaces/public/management/management_service.tsx b/x-pack/plugins/spaces/public/management/management_service.tsx index c81a3497762a5..cec4bee1373ca 100644 --- a/x-pack/plugins/spaces/public/management/management_service.tsx +++ b/x-pack/plugins/spaces/public/management/management_service.tsx @@ -5,7 +5,7 @@ */ import { ManagementSetup, ManagementApp } from 'src/plugins/management/public'; -import { CoreSetup, Capabilities } from 'src/core/public'; +import { StartServicesAccessor, Capabilities } from 'src/core/public'; import { SecurityLicense } from '../../../security/public'; import { SpacesManager } from '../spaces_manager'; import { PluginsStart } from '../plugin'; @@ -13,7 +13,7 @@ import { spacesManagementApp } from './spaces_management_app'; interface SetupDeps { management: ManagementSetup; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; spacesManager: SpacesManager; securityLicense?: SecurityLicense; } diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx index 4cc4190e9591b..ff4be84207832 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx @@ -20,8 +20,8 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Capabilities, HttpStart, NotificationsStart } from 'src/core/public'; -import { Feature } from '../../../../features/public'; +import { Capabilities, NotificationsStart } from 'src/core/public'; +import { Feature, FeaturesPluginStart } from '../../../../features/public'; import { isReservedSpace } from '../../../common'; import { DEFAULT_SPACE_ID } from '../../../common/constants'; import { Space } from '../../../common/model/space'; @@ -36,7 +36,7 @@ import { getEnabledFeatures } from '../lib/feature_utils'; interface Props { spacesManager: SpacesManager; notifications: NotificationsStart; - http: HttpStart; + getFeatures: FeaturesPluginStart['getFeatures']; capabilities: Capabilities; securityEnabled: boolean; } @@ -47,7 +47,6 @@ interface State { loading: boolean; showConfirmDeleteModal: boolean; selectedSpace: Space | null; - error: Error | null; } export class SpacesGridPage extends Component { @@ -59,7 +58,6 @@ export class SpacesGridPage extends Component { loading: true, showConfirmDeleteModal: false, selectedSpace: null, - error: null, }; } @@ -211,7 +209,7 @@ export class SpacesGridPage extends Component { }; public loadGrid = async () => { - const { spacesManager, http } = this.props; + const { spacesManager, getFeatures, notifications } = this.props; this.setState({ loading: true, @@ -220,10 +218,9 @@ export class SpacesGridPage extends Component { }); const getSpaces = spacesManager.getSpaces(); - const getFeatures = http.get('/api/features'); try { - const [spaces, features] = await Promise.all([getSpaces, getFeatures]); + const [spaces, features] = await Promise.all([getSpaces, getFeatures()]); this.setState({ loading: false, spaces, @@ -232,7 +229,11 @@ export class SpacesGridPage extends Component { } catch (error) { this.setState({ loading: false, - error, + }); + notifications.toasts.addError(error, { + title: i18n.translate('xpack.spaces.management.spacesGridPage.errorTitle', { + defaultMessage: 'Error loading spaces', + }), }); } }; diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx index 90c7aba65e3d6..9b7dc921b9a25 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx @@ -12,6 +12,8 @@ import { SpacesManager } from '../../spaces_manager'; import { SpacesGridPage } from './spaces_grid_page'; import { httpServiceMock } from 'src/core/public/mocks'; import { notificationServiceMock } from 'src/core/public/mocks'; +import { featuresPluginMock } from '../../../../features/public/mocks'; +import { Feature } from '../../../../features/public'; const spaces = [ { @@ -38,6 +40,17 @@ const spaces = [ const spacesManager = spacesManagerMock.create(); spacesManager.getSpaces = jest.fn().mockResolvedValue(spaces); +const featuresStart = featuresPluginMock.createStart(); +featuresStart.getFeatures.mockResolvedValue([ + new Feature({ + id: 'feature-1', + name: 'feature 1', + icon: 'spacesApp', + app: [], + privileges: null, + }), +]); + describe('SpacesGridPage', () => { it('renders as expected', () => { const httpStart = httpServiceMock.createStartContract(); @@ -47,7 +60,7 @@ describe('SpacesGridPage', () => { shallowWithIntl( { const wrapper = mountWithIntl( { expect(wrapper.find(SpaceAvatar)).toHaveLength(spaces.length); expect(wrapper.find(SpaceAvatar)).toMatchSnapshot(); }); + + it('notifies when spaces fail to load', async () => { + const httpStart = httpServiceMock.createStartContract(); + httpStart.get.mockResolvedValue([]); + + const error = new Error('something awful happened'); + spacesManager.getSpaces.mockRejectedValue(error); + + const notifications = notificationServiceMock.createStartContract(); + + const wrapper = mountWithIntl( + + ); + + // allow spacesManager to load spaces + await nextTick(); + wrapper.update(); + + expect(wrapper.find(SpaceAvatar)).toHaveLength(0); + expect(notifications.toasts.addError).toHaveBeenCalledWith(error, { + title: 'Error loading spaces', + }); + }); + + it('notifies when features fail to load', async () => { + const httpStart = httpServiceMock.createStartContract(); + httpStart.get.mockResolvedValue([]); + + const error = new Error('something awful happened'); + + const notifications = notificationServiceMock.createStartContract(); + + const wrapper = mountWithIntl( + Promise.reject(error)} + notifications={notifications} + securityEnabled={true} + capabilities={{ + navLinks: {}, + management: {}, + catalogue: {}, + spaces: { manage: true }, + }} + /> + ); + + // allow spacesManager to load spaces + await nextTick(); + wrapper.update(); + + expect(wrapper.find(SpaceAvatar)).toHaveLength(0); + // For end-users, the effect is that spaces won't load, even though this was a request to retrieve features. + expect(notifications.toasts.addError).toHaveBeenCalledWith(error, { + title: 'Error loading spaces', + }); + }); }); diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx index 2e274e08ee13b..7738a440cb5e1 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx @@ -23,6 +23,8 @@ import { coreMock } from '../../../../../src/core/public/mocks'; import { securityMock } from '../../../security/public/mocks'; import { spacesManagerMock } from '../spaces_manager/mocks'; import { SecurityLicenseFeatures } from '../../../security/public'; +import { featuresPluginMock } from '../../../features/public/mocks'; +import { PluginsStart } from '../plugin'; async function mountApp(basePath: string, spaceId?: string) { const container = document.createElement('div'); @@ -42,11 +44,14 @@ async function mountApp(basePath: string, spaceId?: string) { showLinks: true, } as SecurityLicenseFeatures); + const [coreStart, pluginsStart] = await coreMock.createSetup().getStartServices(); + (pluginsStart as PluginsStart).features = featuresPluginMock.createStart(); + const unmount = await spacesManagementApp .create({ spacesManager, securityLicense, - getStartServices: coreMock.createSetup().getStartServices as any, + getStartServices: async () => [coreStart, pluginsStart as PluginsStart], }) .mount({ basePath, element: container, setBreadcrumbs }); @@ -81,7 +86,7 @@ describe('spacesManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledWith([{ href: `#${basePath}`, text: 'Spaces' }]); expect(container).toMatchInlineSnapshot(`
- Spaces Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"securityEnabled":true} + Spaces Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"securityEnabled":true}
`); @@ -103,7 +108,7 @@ describe('spacesManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"securityEnabled":true} + Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"securityEnabled":true}
`); @@ -126,7 +131,7 @@ describe('spacesManagementApp', () => { ]); expect(container).toMatchInlineSnapshot(`
- Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"http":{"basePath":{"basePath":"","serverBasePath":""},"anonymousPaths":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"spaceId":"some-space","securityEnabled":true} + Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{"_isScalar":false}},"spaceId":"some-space","securityEnabled":true}
`); diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx index 663237cfc2e8a..92b369807b0da 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { HashRouter as Router, Route, Switch, useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { CoreSetup } from 'src/core/public'; +import { StartServicesAccessor } from 'src/core/public'; import { SecurityLicense } from '../../../security/public'; import { RegisterManagementAppArgs } from '../../../../../src/plugins/management/public'; import { PluginsStart } from '../plugin'; @@ -18,7 +18,7 @@ import { ManageSpacePage } from './edit_space'; import { Space } from '..'; interface CreateParams { - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; spacesManager: SpacesManager; securityLicense?: SecurityLicense; } @@ -33,7 +33,10 @@ export const spacesManagementApp = Object.freeze({ defaultMessage: 'Spaces', }), async mount({ basePath, element, setBreadcrumbs }) { - const [{ http, notifications, i18n: i18nStart, application }] = await getStartServices(); + const [ + { notifications, i18n: i18nStart, application }, + { features }, + ] = await getStartServices(); const spacesBreadcrumbs = [ { text: i18n.translate('xpack.spaces.management.breadcrumb', { @@ -48,7 +51,7 @@ export const spacesManagementApp = Object.freeze({ return ( { describe('#setup', () => { @@ -101,7 +102,7 @@ describe('Spaces plugin', () => { const plugin = new SpacesPlugin(); plugin.setup(coreSetup, {}); - plugin.start(coreStart, {}); + plugin.start(coreStart, { features: featuresPluginMock.createStart() }); expect(coreStart.chrome.navControls.registerLeft).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 44215ec538002..876ab39df3a1f 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -9,6 +9,7 @@ import { HomePublicPluginSetup } from 'src/plugins/home/public'; import { SavedObjectsManagementAction } from 'src/legacy/core_plugins/management/public'; import { ManagementStart, ManagementSetup } from 'src/plugins/management/public'; import { AdvancedSettingsSetup } from 'src/plugins/advanced_settings/public'; +import { FeaturesPluginStart } from '../../features/public'; import { SecurityPluginStart, SecurityPluginSetup } from '../../security/public'; import { SpacesManager } from './spaces_manager'; import { initSpacesNavControl } from './nav_control'; @@ -26,6 +27,7 @@ export interface PluginsSetup { } export interface PluginsStart { + features: FeaturesPluginStart; management?: ManagementStart; security?: SecurityPluginStart; } @@ -53,7 +55,7 @@ export class SpacesPlugin implements Plugin['getStartServices'], spacesManager: this.spacesManager, securityLicense: plugins.security?.license, }); diff --git a/x-pack/plugins/spaces/public/space_selector/space_selector_app.tsx b/x-pack/plugins/spaces/public/space_selector/space_selector_app.tsx index 6fab1767e4b6d..048f0e30cd469 100644 --- a/x-pack/plugins/spaces/public/space_selector/space_selector_app.tsx +++ b/x-pack/plugins/spaces/public/space_selector/space_selector_app.tsx @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, AppMountParameters } from 'src/core/public'; +import { StartServicesAccessor, ApplicationSetup, AppMountParameters } from 'src/core/public'; import { i18n } from '@kbn/i18n'; import { SpacesManager } from '../spaces_manager'; interface CreateDeps { - application: CoreSetup['application']; + application: ApplicationSetup; spacesManager: SpacesManager; - getStartServices: CoreSetup['getStartServices']; + getStartServices: StartServicesAccessor; } export const spaceSelectorApp = Object.freeze({ diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts index 3f7b93c754aef..2c1ab26dd3d82 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.test.ts @@ -13,12 +13,11 @@ import { featuresPluginMock } from '../../../features/server/mocks'; import { spacesServiceMock } from '../spaces_service/spaces_service.mock'; import { PluginsStart } from '../plugin'; -const features: Feature[] = [ +const features = ([ { id: 'feature_1', name: 'Feature 1', app: [], - privileges: {}, }, { id: 'feature_2', @@ -60,7 +59,7 @@ const features: Feature[] = [ }, }, }, -]; +] as unknown) as Feature[]; const buildCapabilities = () => Object.freeze({ @@ -154,7 +153,7 @@ describe('capabilitiesSwitcher', () => { expect(result).toEqual(buildCapabilities()); }); - it('logs a warning, and does not toggle capabilities if an error is encountered', async () => { + it('logs a debug message, and does not toggle capabilities if an error is encountered', async () => { const space: Space = { id: 'space', name: '', @@ -171,7 +170,7 @@ describe('capabilitiesSwitcher', () => { const result = await switcher(request, capabilities); expect(result).toEqual(buildCapabilities()); - expect(logger.warn).toHaveBeenCalledWith( + expect(logger.debug).toHaveBeenCalledWith( `Error toggling capabilities for request to /path: Error: Something terrible happened` ); }); diff --git a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts index 317cc7fe0e3c3..ddbea91f7268c 100644 --- a/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts +++ b/x-pack/plugins/spaces/server/capabilities/capabilities_switcher.ts @@ -30,9 +30,10 @@ export function setupCapabilitiesSwitcher( const registeredFeatures = features.getFeatures(); + // try to retrieve capabilities for authenticated or "maybe authenticated" users return toggleCapabilities(registeredFeatures, capabilities, activeSpace); } catch (e) { - logger.warn(`Error toggling capabilities for request to ${request.url.pathname}: ${e}`); + logger.debug(`Error toggling capabilities for request to ${request.url.pathname}: ${e}`); return capabilities; } }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f02b52e5922d1..15d9bec1189c6 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -636,6 +636,7 @@ "embeddableApi.addPanel.noMatchingObjectsMessage": "一致するオブジェクトが見つかりませんでした。", "embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} が追加されました", "embeddableApi.addPanel.Title": "パネルの追加", + "embeddableApi.customizePanel.action.displayName": "パネルをカスタマイズ", "embeddableApi.customizePanel.modal.cancel": "キャンセル", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleFormRowLabel": "パネルタイトル", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleInputAriaLabel": "パネルのカスタムタイトルを入力してください", @@ -7526,16 +7527,9 @@ "xpack.ml.dataframe.analytics.create.trainingPercentLabel": "トレーニングパーセンテージ", "xpack.ml.dataframe.analytics.exploration.experimentalBadgeLabel": "実験的", "xpack.ml.dataframe.analytics.exploration.experimentalBadgeTooltipContent": "データフレーム分析は実験段階の機能です。フィードバックをお待ちしています。", - "xpack.ml.dataframe.analytics.exploration.fieldSelection": "{docFieldsCount, number} 件中 showing {selectedFieldsLength, number} 件の{docFieldsCount, plural, one {フィールド} other {フィールド}}", - "xpack.ml.dataframe.analytics.exploration.indexArrayBadgeContent": "配列", - "xpack.ml.dataframe.analytics.exploration.indexArrayToolTipContent": "この配列ベースの列の完全なコンテンツは表示できません。", "xpack.ml.dataframe.analytics.exploration.indexError": "インデックスデータの読み込み中にエラーが発生しました。", - "xpack.ml.dataframe.analytics.exploration.indexObjectBadgeContent": "オブジェクト", - "xpack.ml.dataframe.analytics.exploration.indexObjectToolTipContent": "このオブジェクトベースの列の完全なコンテンツは表示できません。", "xpack.ml.dataframe.analytics.exploration.jobIdTitle": "ジョブID {jobId}", "xpack.ml.dataframe.analytics.exploration.noDataCalloutBody": "インデックスのクエリが結果を返しませんでした。インデックスにドキュメントが含まれていて、クエリ要件が妥当であることを確認してください。", - "xpack.ml.dataframe.analytics.exploration.selectColumnsAriaLabel": "列を選択", - "xpack.ml.dataframe.analytics.exploration.selectFieldsPopoverTitle": "フィールドを選択", "xpack.ml.dataframe.analytics.exploration.title": "分析の探索", "xpack.ml.dataframe.analytics.regressionExploration.evaluateError": "データの読み込み中にエラーが発生しました。", "xpack.ml.dataframe.analytics.regressionExploration.generalError": "データの読み込み中にエラーが発生しました。", @@ -10429,7 +10423,6 @@ "xpack.security.management.editRole.elasticSearchPrivileges.runAsPrivilegesTitle": "権限として実行", "xpack.security.management.editRole.featureTable.enabledRoleFeaturesEnabledColumnTitle": "権限", "xpack.security.management.editRole.featureTable.enabledRoleFeaturesFeatureColumnTitle": "機能", - "xpack.security.management.editRole.featureTable.excludedFromBasePrivilegsTooltip": "アクセスを許可するには、「カスタム」特権を使用します。{featureName} は基本権限の一部ではありません。", "xpack.security.management.editRole.indexPrivilegeForm.deleteSpacePrivilegeAriaLabel": "インデックスの権限を削除", "xpack.security.management.editRole.indexPrivilegeForm.grantedDocumentsQueryFormRowLabel": "提供されたドキュメントのクエリ", "xpack.security.management.editRole.indexPrivilegeForm.grantReadPrivilegesLabel": "特定のドキュメントの読み込み権限を提供", @@ -10459,13 +10452,6 @@ "xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeInput": "読み込み", "xpack.security.management.editRole.simplePrivilegeForm.specifyPrivilegeForRoleDescription": "このロールの Kibana の権限を指定します。", "xpack.security.management.editRole.simplePrivilegeForm.unsupportedSpacePrivilegesWarning": "このロールはスペースへの権限が定義されていますが、Kibana でスペースが有効ではありません。このロールを保存するとこれらの権限が削除されます。", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.effectivePrivilegeMessage": "{source} で許可されています。", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.globalBasePrivilegeSource": "グローバルベース権限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.globalFeaturePrivilegeSource": "グローバル機能権限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.privilegeSupercededMessage": "{supersededPrivilege} のオリジナルの権限は {actualPrivilegeSource} により上書きされています", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.spaceBasePrivilegeSource": "スペースベース権限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.spaceFeaturePrivilegeSource": "スペース機能権限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.unknownPrivilegeSource": "**不明**", "xpack.security.management.editRole.spaceAwarePrivilegeForm.ensureAccountHasAllPrivilegesGrantedDescription": "{kibanaAdmin} ロールによりアカウントにすべての権限が提供されていることを確認し、再試行してください。", "xpack.security.management.editRole.spaceAwarePrivilegeForm.globalSpacesName": "* グローバル (すべてのスペース)", "xpack.security.management.editRole.spaceAwarePrivilegeForm.howToViewAllAvailableSpacesDescription": "利用可能なすべてのスペースを表示する権限がありません。", @@ -10489,15 +10475,9 @@ "xpack.security.management.editRole.spacePrivilegeForm.readPrivilegeDropdownDisplay": "読み込み", "xpack.security.management.editRole.spacePrivilegeForm.spaceSelectorFormLabel": "スペース", "xpack.security.management.editRole.spacePrivilegeForm.summaryOfFeaturePrivileges": "機能権限のサマリー", - "xpack.security.management.editRole.spacePrivilegeMatrix.basePrivilegeText": "ベース権限", - "xpack.security.management.editRole.spacePrivilegeMatrix.basePrivilegeTooltip": "基本権限は自動的にすべての機能に与えられます。", - "xpack.security.management.editRole.spacePrivilegeMatrix.closeButton": "閉じる", - "xpack.security.management.editRole.spacePrivilegeMatrix.featureColumnTitle": "機能", "xpack.security.management.editRole.spacePrivilegeMatrix.globalSpaceName": "グローバル", - "xpack.security.management.editRole.spacePrivilegeMatrix.modalTitle": "権限のサマリー", "xpack.security.management.editRole.spacePrivilegeMatrix.showAllSpacesLink": "(すべてのスペース)", "xpack.security.management.editRole.spacePrivilegeMatrix.showNMoreSpacesLink": "他 {count} 件", - "xpack.security.management.editRole.spacePrivilegeMatrix.showSummaryText": "権限サマリーを表示", "xpack.security.management.editRole.spacePrivilegeSection.addSpacePrivilegeButton": "スペース権限を追加", "xpack.security.management.editRole.spacePrivilegeSection.noAccessToKibanaTitle": "このロールは Kibana へのアクセスを許可しません", "xpack.security.management.editRole.spacePrivilegeTable.deletePrivilegesLabel": "次のスペースの権限を削除: {spaceNames}", @@ -10524,7 +10504,6 @@ "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowHelpText": "フィールドが提供されていない場合、このロールのユーザーはこのインデックスのデータを表示できません。", "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowLabel": "許可されたフィールド", "xpack.security.management.editRoles.indexPrivilegeForm.grantFieldPrivilegesLabel": "特定のフィールドへのアクセスを許可", - "xpack.security.management.editRolespacePrivilegeForm.cancelButton": "キャンセル", "xpack.security.management.editRolespacePrivilegeForm.createGlobalPrivilegeButton": "グローバル権限を作成", "xpack.security.management.editRolespacePrivilegeForm.createPrivilegeButton": "スペース権限を作成", "xpack.security.management.editRolespacePrivilegeForm.updateGlobalPrivilegeButton": "グローバル特権を更新", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index be6a3df6b6c18..5037c883037b9 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -636,6 +636,7 @@ "embeddableApi.addPanel.noMatchingObjectsMessage": "未找到任何匹配对象。", "embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} 已添加", "embeddableApi.addPanel.Title": "添加面板", + "embeddableApi.customizePanel.action.displayName": "定制面板", "embeddableApi.customizePanel.modal.cancel": "取消", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleFormRowLabel": "面板标题", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleInputAriaLabel": "为面板输入定制标题", @@ -7526,16 +7527,9 @@ "xpack.ml.dataframe.analytics.create.trainingPercentLabel": "训练百分比", "xpack.ml.dataframe.analytics.exploration.experimentalBadgeLabel": "实验性", "xpack.ml.dataframe.analytics.exploration.experimentalBadgeTooltipContent": "数据帧分析为实验功能。我们很乐意听取您的反馈意见。", - "xpack.ml.dataframe.analytics.exploration.fieldSelection": "已选择 {selectedFieldsLength, number} 个{docFieldsCount, plural, one {字段} other {字段}},共 {docFieldsCount, number} 个", - "xpack.ml.dataframe.analytics.exploration.indexArrayBadgeContent": "数组", - "xpack.ml.dataframe.analytics.exploration.indexArrayToolTipContent": "无法显示此基于数组的列的完整内容。", "xpack.ml.dataframe.analytics.exploration.indexError": "加载索引数据时出错。", - "xpack.ml.dataframe.analytics.exploration.indexObjectBadgeContent": "对象", - "xpack.ml.dataframe.analytics.exploration.indexObjectToolTipContent": "无法显示此基于对象的列的完整内容。", "xpack.ml.dataframe.analytics.exploration.jobIdTitle": "作业 ID {jobId}", "xpack.ml.dataframe.analytics.exploration.noDataCalloutBody": "该索引的查询未返回结果。请确保索引包含文档且您的查询限制不过于严格。", - "xpack.ml.dataframe.analytics.exploration.selectColumnsAriaLabel": "选择列", - "xpack.ml.dataframe.analytics.exploration.selectFieldsPopoverTitle": "选择字段", "xpack.ml.dataframe.analytics.exploration.title": "分析浏览", "xpack.ml.dataframe.analytics.regressionExploration.evaluateError": "加载数据时出错。", "xpack.ml.dataframe.analytics.regressionExploration.generalError": "加载数据时出错。", @@ -10429,7 +10423,6 @@ "xpack.security.management.editRole.elasticSearchPrivileges.runAsPrivilegesTitle": "运行身份权限", "xpack.security.management.editRole.featureTable.enabledRoleFeaturesEnabledColumnTitle": "权限", "xpack.security.management.editRole.featureTable.enabledRoleFeaturesFeatureColumnTitle": "功能", - "xpack.security.management.editRole.featureTable.excludedFromBasePrivilegsTooltip": "使用“定制”权限来授予权限。{featureName} 不属于基础权限。", "xpack.security.management.editRole.indexPrivilegeForm.deleteSpacePrivilegeAriaLabel": "删除索引权限", "xpack.security.management.editRole.indexPrivilegeForm.grantedDocumentsQueryFormRowLabel": "已授权文档查询", "xpack.security.management.editRole.indexPrivilegeForm.grantReadPrivilegesLabel": "授予特定文档的读取权限", @@ -10459,13 +10452,6 @@ "xpack.security.management.editRole.simplePrivilegeForm.readPrivilegeInput": "读取", "xpack.security.management.editRole.simplePrivilegeForm.specifyPrivilegeForRoleDescription": "为此角色指定 Kibana 权限。", "xpack.security.management.editRole.simplePrivilegeForm.unsupportedSpacePrivilegesWarning": "此角色包含工作区的权限定义,但在 Kibana 中未启用工作区。保存此角色将会移除这些权限。", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.effectivePrivilegeMessage": "已通过 {source} 授予。", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.globalBasePrivilegeSource": "全局基本权限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.globalFeaturePrivilegeSource": "全局功能权限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.privilegeSupercededMessage": "{supersededPrivilege} 的原始权限已为 {actualPrivilegeSource} 所覆盖", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.spaceBasePrivilegeSource": "工作区基本权限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.spaceFeaturePrivilegeSource": "全局功能权限", - "xpack.security.management.editRole.spaceAwarePrivilegeDisplay.unknownPrivilegeSource": "**未知**", "xpack.security.management.editRole.spaceAwarePrivilegeForm.ensureAccountHasAllPrivilegesGrantedDescription": "请确保您的帐户具有 {kibanaAdmin} 角色授予的所有权限,然后重试。", "xpack.security.management.editRole.spaceAwarePrivilegeForm.globalSpacesName": "* 全局(所有工作区)", "xpack.security.management.editRole.spaceAwarePrivilegeForm.howToViewAllAvailableSpacesDescription": "您无权查看所有可用工作区。", @@ -10489,15 +10475,9 @@ "xpack.security.management.editRole.spacePrivilegeForm.readPrivilegeDropdownDisplay": "读取", "xpack.security.management.editRole.spacePrivilegeForm.spaceSelectorFormLabel": "工作区", "xpack.security.management.editRole.spacePrivilegeForm.summaryOfFeaturePrivileges": "功能权限的摘要", - "xpack.security.management.editRole.spacePrivilegeMatrix.basePrivilegeText": "基本权限", - "xpack.security.management.editRole.spacePrivilegeMatrix.basePrivilegeTooltip": "所有功能的基本权限将自动授予。", - "xpack.security.management.editRole.spacePrivilegeMatrix.closeButton": "关闭", - "xpack.security.management.editRole.spacePrivilegeMatrix.featureColumnTitle": "功能", "xpack.security.management.editRole.spacePrivilegeMatrix.globalSpaceName": "全局", - "xpack.security.management.editRole.spacePrivilegeMatrix.modalTitle": "权限摘要", "xpack.security.management.editRole.spacePrivilegeMatrix.showAllSpacesLink": "(所有工作区)", "xpack.security.management.editRole.spacePrivilegeMatrix.showNMoreSpacesLink": "另外 {count} 个", - "xpack.security.management.editRole.spacePrivilegeMatrix.showSummaryText": "查看权限摘要", "xpack.security.management.editRole.spacePrivilegeSection.addSpacePrivilegeButton": "添加工作区权限", "xpack.security.management.editRole.spacePrivilegeSection.noAccessToKibanaTitle": "此角色未授予对 Kibana 的访问权限", "xpack.security.management.editRole.spacePrivilegeTable.deletePrivilegesLabel": "删除以下工作区的权限:{spaceNames}。", @@ -10524,7 +10504,6 @@ "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowHelpText": "如果未授权任何字段,则分配到此角色的用户将无法查看此索引的任何数据。", "xpack.security.management.editRoles.indexPrivilegeForm.grantedFieldsFormRowLabel": "已授权字段", "xpack.security.management.editRoles.indexPrivilegeForm.grantFieldPrivilegesLabel": "授予对特定字段的访问权限", - "xpack.security.management.editRolespacePrivilegeForm.cancelButton": "取消", "xpack.security.management.editRolespacePrivilegeForm.createGlobalPrivilegeButton": "创建全局权限", "xpack.security.management.editRolespacePrivilegeForm.createPrivilegeButton": "创建工作区权限", "xpack.security.management.editRolespacePrivilegeForm.updateGlobalPrivilegeButton": "更新全局权限", diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index e6af63ecd4359..3b6ca4f9da7cc 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -660,6 +660,7 @@ const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false); alertTypeRegistry: triggers_actions_ui.alertTypeRegistry, toastNotifications: toasts, uiSettings, + docLinks, charts, dataFieldsFormats, metadata: { test: 'some value', fields: ['test'] }, @@ -697,6 +698,7 @@ export interface AlertsContextValue> { alertTypeRegistry: TypeRegistry; actionTypeRegistry: TypeRegistry; uiSettings?: IUiSettingsClient; + docLinks: DocLinksStart; toastNotifications: Pick< ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' @@ -714,6 +716,7 @@ export interface AlertsContextValue> { |alertTypeRegistry|Registry for alert types.| |actionTypeRegistry|Registry for action types.| |uiSettings|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| +|docLinks|Documentation Links, needed to link to the documentation from informational callouts.| |toastNotifications|Toast messages.| |charts|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| |dataFieldsFormats|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| @@ -1322,6 +1325,7 @@ export interface AlertsContextValue { alertTypeRegistry: TypeRegistry; actionTypeRegistry: TypeRegistry; uiSettings?: IUiSettingsClient; + docLinks: DocLinksStart; toastNotifications: Pick< ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' @@ -1338,6 +1342,7 @@ export interface AlertsContextValue { |alertTypeRegistry|Registry for alert types.| |actionTypeRegistry|Registry for action types.| |uiSettings|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| +|docLinks|Documentation Links, needed to link to the documentation from informational callouts.| |toastNotifications|Toast messages.| |charts|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| |dataFieldsFormats|Optional property, which is needed to display visualization of alert type expression. Will be changed after visualization refactoring.| diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/alert_action_security_call_out.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/alert_action_security_call_out.test.tsx new file mode 100644 index 0000000000000..85699cfbd750f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/alert_action_security_call_out.test.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { AlertActionSecurityCallOut } from './alert_action_security_call_out'; + +import { EuiCallOut, EuiButton } from '@elastic/eui'; +import { act } from 'react-dom/test-utils'; +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; + +const docLinks = { ELASTIC_WEBSITE_URL: 'elastic.co/', DOC_LINK_VERSION: 'current' }; + +const http = httpServiceMock.createStartContract(); + +describe('alert action security call out', () => { + let useEffect: any; + + const mockUseEffect = () => { + // make react execute useEffects despite shallow rendering + useEffect.mockImplementationOnce((f: Function) => f()); + }; + + beforeEach(() => { + jest.resetAllMocks(); + useEffect = jest.spyOn(React, 'useEffect'); + mockUseEffect(); + }); + + test('renders nothing while health is loading', async () => { + http.get.mockImplementationOnce(() => new Promise(() => {})); + + let component: ShallowWrapper | undefined; + await act(async () => { + component = shallow( + + ); + }); + + expect(component?.is(Fragment)).toBeTruthy(); + expect(component?.html()).toBe(''); + }); + + test('renders nothing if keys are enabled', async () => { + http.get.mockResolvedValue({ isSufficientlySecure: true }); + + let component: ShallowWrapper | undefined; + await act(async () => { + component = shallow( + + ); + }); + + expect(component?.is(Fragment)).toBeTruthy(); + expect(component?.html()).toBe(''); + }); + + test('renders the callout if keys are disabled', async () => { + http.get.mockResolvedValue({ isSufficientlySecure: false }); + + let component: ShallowWrapper | undefined; + await act(async () => { + component = shallow( + + ); + }); + + expect(component?.find(EuiCallOut).prop('title')).toMatchInlineSnapshot( + `"Alert creation requires TLS between Elasticsearch and Kibana."` + ); + + expect(component?.find(EuiButton).prop('href')).toMatchInlineSnapshot( + `"elastic.co/guide/en/kibana/current/configuring-tls.html"` + ); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/alert_action_security_call_out.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/alert_action_security_call_out.tsx new file mode 100644 index 0000000000000..f7a80202dff89 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/alert_action_security_call_out.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { Option, none, some, fold, filter } from 'fp-ts/lib/Option'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { DocLinksStart, HttpSetup } from 'kibana/public'; +import { AlertingFrameworkHealth } from '../../types'; +import { health } from '../lib/alert_api'; + +interface Props { + docLinks: Pick; + action: string; + http: HttpSetup; +} + +export const AlertActionSecurityCallOut: React.FunctionComponent = ({ + http, + action, + docLinks, +}) => { + const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; + + const [alertingHealth, setAlertingHealth] = React.useState>(none); + + React.useEffect(() => { + async function fetchSecurityConfigured() { + setAlertingHealth(some(await health({ http }))); + } + + fetchSecurityConfigured(); + }, [http]); + + return pipe( + alertingHealth, + filter(healthCheck => !healthCheck.isSufficientlySecure), + fold( + () => , + () => ( + + + + + + + + + ) + ) + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx index f17180ee74e56..b4bbb8af36a19 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx @@ -137,7 +137,7 @@ export function getActionType(): ActionTypeModel { const errorText = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredEntryText', { - defaultMessage: 'No [to], [cc], or [bcc] entries. At least one entry is required.', + defaultMessage: 'No To, Cc, or Bcc entry. At least one entry is required.', } ); errors.to.push(errorText); @@ -396,7 +396,7 @@ const EmailParamsFields: React.FunctionComponent setAddCC(true)}> @@ -415,7 +415,7 @@ const EmailParamsFields: React.FunctionComponent ) : null} @@ -459,7 +459,7 @@ const EmailParamsFields: React.FunctionComponent @@ -500,7 +500,7 @@ const EmailParamsFields: React.FunctionComponent @@ -540,7 +540,7 @@ const EmailParamsFields: React.FunctionComponent @@ -550,7 +550,6 @@ const EmailParamsFields: React.FunctionComponent { editAction('subject', e.target.value, index); }} @@ -568,7 +567,7 @@ const EmailParamsFields: React.FunctionComponent { }); }); +describe('index connector validation with minimal config', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.index', + name: 'es_index', + config: { + index: 'test_es_index', + }, + } as EsIndexActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + index: [], + }, + }); + }); +}); + describe('action params validation', () => { test('action params validation succeeds when action params is valid', () => { const actionParams = { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx index b3e62e022c412..706d746b92995 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index.tsx @@ -42,6 +42,12 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Index data into Elasticsearch.', } ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.actionTypeTitle', + { + defaultMessage: 'Index data', + } + ), validateConnector: (action: EsIndexActionConnector): ValidationResult => { const validationResult = { errors: {} }; const errors = { @@ -73,7 +79,7 @@ const IndexActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, errors, http }) => { const { index, refresh, executionTimeField } = action.config; const [hasTimeFieldCheckbox, setTimeFieldCheckboxState] = useState( - executionTimeField !== undefined + executionTimeField != null ); const [indexPatterns, setIndexPatterns] = useState([]); @@ -179,7 +185,7 @@ const IndexActionConnectorFields: React.FunctionComponent {' '} { setTimeFieldCheckboxState(!hasTimeFieldCheckbox); + // if changing from checked to not checked (hasTimeField === true), + // set time field to null + if (hasTimeFieldCheckbox) { + editActionConfig('executionTimeField', null); + } }} label={ <> @@ -239,13 +250,13 @@ const IndexActionConnectorFields: React.FunctionComponent { - editActionConfig('executionTimeField', e.target.value); + editActionConfig('executionTimeField', nullableString(e.target.value)); }} onBlur={() => { if (executionTimeField === undefined) { - editActionConfig('executionTimeField', ''); + editActionConfig('executionTimeField', null); } }} /> @@ -306,3 +317,9 @@ const IndexParamsFields: React.FunctionComponent ); }; + +// if the string == null or is empty, return null, else return string +function nullableString(str: string | null | undefined) { + if (str == null || str.trim() === '') return null; + return str; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx index 7666129e4abdc..947d098c46483 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty.tsx @@ -125,7 +125,7 @@ const PagerDutyActionConnectorFields: React.FunctionComponent } @@ -270,12 +270,20 @@ const PagerDutyParamsFields: React.FunctionComponent )); + const addVariableButtonTitle = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.addVariableTitle', + { + defaultMessage: 'Add alert variable', + } + ); + const getAddVariableComponent = (paramsProperty: string, buttonName: string) => { return ( setIsVariablesPopoverOpen({ ...isVariablesPopoverOpen, [paramsProperty]: true }) } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx index f0ac43c04ee0e..8f84e9da5ada0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log.tsx @@ -134,6 +134,12 @@ export const ServerLogParamsFields: React.FunctionComponent setIsVariablesPopoverOpen(true)} iconType="indexOpen" + title={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.addVariableTitle', + { + defaultMessage: 'Add variable', + } + )} aria-label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.addVariablePopoverButton', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx index a8ba11faa08dd..2ca07e0d57a8e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack.tsx @@ -98,7 +98,7 @@ const SlackActionFields: React.FunctionComponent } @@ -115,7 +115,7 @@ const SlackActionFields: React.FunctionComponent 0 && webhookUrl !== undefined} name="webhookUrl" - placeholder="URL like https://hooks.slack.com/services" + placeholder="Example: https://hooks.slack.com/services" value={webhookUrl || ''} data-test-subj="slackWebhookUrlInput" onChange={e => { @@ -182,10 +182,16 @@ const SlackParamsFields: React.FunctionComponent setIsVariablesPopoverOpen(true)} iconType="indexOpen" + title={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.addVariableTitle', + { + defaultMessage: 'Add alert variable', + } + )} aria-label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.addVariablePopoverButton', { - defaultMessage: 'Add variable', + defaultMessage: 'Add alert variable', } )} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts index 2e0576d933f90..fd35db4304275 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts @@ -83,7 +83,7 @@ export interface EmailActionConnector extends ActionConnector { interface EsIndexConfig { index: string; - executionTimeField?: string; + executionTimeField?: string | null; refresh?: boolean; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx index 5d07483c8a989..f611c3715e56a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook.tsx @@ -47,6 +47,12 @@ export function getActionType(): ActionTypeModel { defaultMessage: 'Send a request to a web service.', } ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.actionTypeTitle', + { + defaultMessage: 'Webhook data', + } + ), validateConnector: (action: WebhookActionConnector): ValidationResult => { const validationResult = { errors: {} }; const errors = { @@ -142,7 +148,7 @@ const WebhookActionConnectorFields: React.FunctionComponent
@@ -496,6 +502,12 @@ const WebhookParamsFields: React.FunctionComponent setIsVariablesPopoverOpen(true)} iconType="indexOpen" + title={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.addVariableTitle', + { + defaultMessage: 'Add variable', + } + )} aria-label={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.addVariablePopoverButton', { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx index fa26e8b11bfec..96513f0563ad0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/expression.tsx @@ -10,7 +10,6 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFlexItem, EuiFlexGroup, - EuiFormLabel, EuiExpression, EuiPopover, EuiPopoverTitle, @@ -23,6 +22,8 @@ import { EuiEmptyPrompt, EuiText, } from '@elastic/eui'; +import { EuiSteps } from '@elastic/eui'; +import { EuiButtonIcon } from '@elastic/eui'; import { firstFieldOption, getIndexPatterns, @@ -213,6 +214,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent ); + const firstSetOfSteps = [ + { + title: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.selectIndex', { + defaultMessage: 'Select an index.', + }), + children: ( + <> + + + { + setIndexPopoverOpen(true); + }} + color={index ? 'secondary' : 'danger'} + /> + } + isOpen={indexPopoverOpen} + closePopover={() => { + setIndexPopoverOpen(false); + }} + ownFocus + withTitle + anchorPosition="downLeft" + zIndex={8000} + > +
+ + + + {i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.indexButtonLabel', + { + defaultMessage: 'index', + } + )} + + + { + setIndexPopoverOpen(false); + }} + /> + + + + + {indexPopover} +
+
+
+
+ + + + setAlertParams('aggType', selectedAggType) + } + /> + + {aggType && builtInAggregationTypes[aggType].fieldRequired ? ( + + + setAlertParams('aggField', selectedAggField) + } + /> + + ) : null} + + + + + setAlertParams('groupBy', selectedGroupBy) + } + onChangeSelectedTermField={selectedTermField => + setAlertParams('termField', selectedTermField) + } + onChangeSelectedTermSize={selectedTermSize => + setAlertParams('termSize', selectedTermSize) + } + /> + + + + ), + }, + { + title: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.conditionPrompt', { + defaultMessage: 'Define the condition.', + }), + children: ( + <> + + + + setAlertParams('threshold', selectedThresholds) + } + onChangeSelectedThresholdComparator={selectedThresholdComparator => + setAlertParams('thresholdComparator', selectedThresholdComparator) + } + /> + + + + setAlertParams('timeWindowSize', selectedWindowSize) + } + onChangeWindowUnit={(selectedWindowUnit: any) => + setAlertParams('timeWindowUnit', selectedWindowUnit) + } + /> + + + + ), + }, + ]; + return ( {hasExpressionErrors ? ( @@ -281,136 +441,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent ) : null} - - - - - - - { - setIndexPopoverOpen(true); - }} - color={index ? 'secondary' : 'danger'} - /> - } - isOpen={indexPopoverOpen} - closePopover={() => { - setIndexPopoverOpen(false); - }} - ownFocus - withTitle - anchorPosition="downLeft" - zIndex={8000} - > -
- - {i18n.translate( - 'xpack.triggersActionsUI.sections.alertAdd.threshold.indexButtonLabel', - { - defaultMessage: 'index', - } - )} - - {indexPopover} -
-
-
-
- - - - setAlertParams('aggType', selectedAggType) - } - /> - - {aggType && builtInAggregationTypes[aggType].fieldRequired ? ( - - - setAlertParams('aggField', selectedAggField) - } - /> - - ) : null} - - - - setAlertParams('groupBy', selectedGroupBy)} - onChangeSelectedTermField={selectedTermField => - setAlertParams('termField', selectedTermField) - } - onChangeSelectedTermSize={selectedTermSize => - setAlertParams('termSize', selectedTermSize) - } - /> - - - - - - - - - - - setAlertParams('threshold', selectedThresholds) - } - onChangeSelectedThresholdComparator={selectedThresholdComparator => - setAlertParams('thresholdComparator', selectedThresholdComparator) - } - /> - - - - setAlertParams('timeWindowSize', selectedWindowSize) - } - onChangeWindowUnit={(selectedWindowUnit: any) => - setAlertParams('timeWindowUnit', selectedWindowUnit) - } - /> - - +
{canShowVizualization ? ( @@ -422,7 +453,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts index ecf60e995d1a1..983f759214b6b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts @@ -10,7 +10,7 @@ import { validateExpression } from './validation'; export function getAlertType(): AlertTypeModel { return { id: '.index-threshold', - name: 'Index Threshold', + name: 'Index threshold', iconClass: 'alert', alertParamsExpression: IndexThresholdAlertTypeExpression, validate: validateExpression, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/visualization.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/visualization.tsx index ef08ac9a9d0de..06d311ef064c3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/visualization.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/visualization.tsx @@ -305,14 +305,14 @@ export const ThresholdVisualization: React.FunctionComponent = ({ title={ } color="warning" > )} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx index 80b59e15644ec..5862a567f71ba 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/delete_modal_confirmation.tsx @@ -14,6 +14,7 @@ export const DeleteModalConfirmation = ({ apiDeleteCall, onDeleted, onCancel, + onErrors, singleTitle, multipleTitle, }: { @@ -27,6 +28,7 @@ export const DeleteModalConfirmation = ({ }) => Promise<{ successes: string[]; errors: string[] }>; onDeleted: (deleted: string[]) => void; onCancel: () => void; + onErrors: () => void; singleTitle: string; multipleTitle: string; }) => { @@ -93,6 +95,7 @@ export const DeleteModalConfirmation = ({ } ) ); + onErrors(); } }} cancelButtonText={cancelButtonText} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/security_call_out.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/security_call_out.test.tsx new file mode 100644 index 0000000000000..28bc02ec3392f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/security_call_out.test.tsx @@ -0,0 +1,72 @@ +/* + * 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, { Fragment } from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { SecurityEnabledCallOut } from './security_call_out'; + +import { EuiCallOut, EuiButton } from '@elastic/eui'; +import { act } from 'react-dom/test-utils'; +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; + +const docLinks = { ELASTIC_WEBSITE_URL: 'elastic.co/', DOC_LINK_VERSION: 'current' }; + +const http = httpServiceMock.createStartContract(); + +describe('security call out', () => { + let useEffect: any; + + const mockUseEffect = () => { + // make react execute useEffects despite shallow rendering + useEffect.mockImplementationOnce((f: Function) => f()); + }; + + beforeEach(() => { + jest.resetAllMocks(); + useEffect = jest.spyOn(React, 'useEffect'); + mockUseEffect(); + }); + + test('renders nothing while health is loading', async () => { + http.get.mockImplementationOnce(() => new Promise(() => {})); + + let component: ShallowWrapper | undefined; + await act(async () => { + component = shallow(); + }); + + expect(component?.is(Fragment)).toBeTruthy(); + expect(component?.html()).toBe(''); + }); + + test('renders nothing if keys are enabled', async () => { + http.get.mockResolvedValue({ isSufficientlySecure: true }); + + let component: ShallowWrapper | undefined; + await act(async () => { + component = shallow(); + }); + + expect(component?.is(Fragment)).toBeTruthy(); + expect(component?.html()).toBe(''); + }); + + test('renders the callout if keys are disabled', async () => { + http.get.mockImplementationOnce(async () => ({ isSufficientlySecure: false })); + + let component: ShallowWrapper | undefined; + await act(async () => { + component = shallow(); + }); + + expect(component?.find(EuiCallOut).prop('title')).toMatchInlineSnapshot( + `"Enable Transport Layer Security"` + ); + + expect(component?.find(EuiButton).prop('href')).toMatchInlineSnapshot( + `"elastic.co/guide/en/kibana/current/configuring-tls.html"` + ); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/security_call_out.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/security_call_out.tsx new file mode 100644 index 0000000000000..9874a3a0697d2 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/security_call_out.tsx @@ -0,0 +1,75 @@ +/* + * 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, { Fragment } from 'react'; +import { Option, none, some, fold, filter } from 'fp-ts/lib/Option'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { DocLinksStart, HttpSetup } from 'kibana/public'; + +import { AlertingFrameworkHealth } from '../../types'; +import { health } from '../lib/alert_api'; + +interface Props { + docLinks: Pick; + http: HttpSetup; +} + +export const SecurityEnabledCallOut: React.FunctionComponent = ({ docLinks, http }) => { + const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; + + const [alertingHealth, setAlertingHealth] = React.useState>(none); + + React.useEffect(() => { + async function fetchSecurityConfigured() { + setAlertingHealth(some(await health({ http }))); + } + + fetchSecurityConfigured(); + }, [http]); + + return pipe( + alertingHealth, + filter(healthCheck => !healthCheck?.isSufficientlySecure), + fold( + () => , + () => ( + + +

+ +

+ + + +
+ +
+ ) + ) + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx b/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx index 1944cdeab7552..340370cc0314b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/context/alerts_context.tsx @@ -5,7 +5,7 @@ */ import React, { useContext, createContext } from 'react'; -import { HttpSetup, IUiSettingsClient, ToastsApi } from 'kibana/public'; +import { HttpSetup, IUiSettingsClient, ToastsApi, DocLinksStart } from 'kibana/public'; import { ChartsPluginSetup } from 'src/plugins/charts/public'; import { DataPublicPluginSetup } from 'src/plugins/data/public'; import { TypeRegistry } from '../type_registry'; @@ -22,6 +22,7 @@ export interface AlertsContextValue> { >; uiSettings?: IUiSettingsClient; charts?: ChartsPluginSetup; + docLinks: DocLinksStart; dataFieldsFormats?: DataPublicPluginSetup['fieldFormats']; metadata?: MetaData; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx index b478a9f0ced8b..7c8d798984bf2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx @@ -17,6 +17,7 @@ import { EuiTabs, EuiTitle, EuiBetaBadge, + EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -28,6 +29,8 @@ import { hasShowActionsCapability, hasShowAlertsCapability } from './lib/capabil import { ActionsConnectorsList } from './sections/actions_connectors_list/components/actions_connectors_list'; import { AlertsList } from './sections/alerts_list/components/alerts_list'; +import { SecurityEnabledCallOut } from './components/security_call_out'; +import { PLUGIN } from './constants/plugin'; interface MatchParams { section: Section; @@ -39,7 +42,7 @@ export const TriggersActionsUIHome: React.FunctionComponent { - const { chrome, capabilities, setBreadcrumbs } = useAppDependencies(); + const { chrome, capabilities, setBreadcrumbs, docLinks, http } = useAppDependencies(); const canShowActions = hasShowActionsCapability(capabilities); const canShowAlerts = hasShowAlertsCapability(capabilities); @@ -85,6 +88,7 @@ export const TriggersActionsUIHome: React.FunctionComponent + @@ -100,12 +104,24 @@ export const TriggersActionsUIHome: React.FunctionComponent + + +

+ +

+
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts index 453fbc4a9eb4f..b830ac471c4d0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts @@ -24,6 +24,7 @@ import { updateAlert, muteAlertInstance, unmuteAlertInstance, + health, } from './alert_api'; import uuid from 'uuid'; @@ -618,3 +619,17 @@ describe('unmuteAlerts', () => { `); }); }); + +describe('health', () => { + test('should call health API', async () => { + const result = await health({ http }); + expect(result).toEqual(undefined); + expect(http.get.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/_health", + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts index 359c48850549a..0fec2d49df986 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts @@ -9,7 +9,7 @@ import * as t from 'io-ts'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { pick } from 'lodash'; -import { alertStateSchema } from '../../../../alerting/common'; +import { alertStateSchema, AlertingFrameworkHealth } from '../../../../alerting/common'; import { BASE_ALERT_API_PATH } from '../constants'; import { Alert, AlertType, AlertWithoutId, AlertTaskState } from '../../types'; @@ -214,3 +214,7 @@ export async function unmuteAlerts({ }): Promise { await Promise.all(ids.map(id => unmuteAlert({ id, http }))); } + +export async function health({ http }: { http: HttpSetup }): Promise { + return await http.get(`${BASE_ALERT_API_PATH}/_health`); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss index 32ab1bd7b1821..24dbb865742d8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss @@ -7,3 +7,10 @@ box-shadow: none; } } + +.actConnectorsListGrid { + .euiToolTipAnchor, + .euiCard { + height: 100%; + } +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.test.tsx index eb51bb8ac5098..566ed7935e013 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.test.tsx @@ -43,11 +43,11 @@ test('returns isEnabled:false when action type is disabled by license', async () expect(checkActionTypeEnabled(actionType)).toMatchInlineSnapshot(` Object { "isEnabled": false, - "message": "This connector is disabled because it requires a basic license.", + "message": "This connector requires a Basic license.", "messageCard": { try { setIsLoadingActionTypes(true); - const registeredActionTypes = actionTypes ?? (await loadActionTypes({ http })); + const registeredActionTypes = ( + actionTypes ?? (await loadActionTypes({ http })) + ).sort((a, b) => a.name.localeCompare(b.name)); const index: ActionTypeIndex = {}; for (const actionTypeItem of registeredActionTypes) { index[actionTypeItem.id] = actionTypeItem; @@ -188,7 +190,7 @@ export const ActionForm = ({ label={
, ]} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx index 84d5269337b9e..0fb759226c21f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx @@ -184,11 +184,6 @@ describe('connector_add_flyout', () => { ); - const element = wrapper.find('[data-test-subj="my-action-type-card"]'); - expect(element.exists()).toBeTruthy(); - expect(element.first().prop('betaBadgeLabel')).toEqual('Upgrade'); - expect(element.first().prop('betaBadgeTooltipContent')).toEqual( - 'This connector is disabled because it requires a gold license.' - ); + expect(wrapper.find('EuiToolTip [data-test-subj="my-action-type-card"]').exists()).toBeTruthy(); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx index 2dd5e413faf9c..91ecfb2fa8ded 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useState } from 'react'; import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { EuiToolTip } from '@elastic/eui'; import { ActionType, ActionTypeIndex } from '../../../types'; import { loadActionTypes } from '../../lib/action_connector_api'; import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; @@ -81,21 +82,19 @@ export const ActionTypeMenu = ({ description={item.selectMessage} isDisabled={!checkEnabledResult.isEnabled} onClick={() => onActionTypeChange(item.actionType)} - betaBadgeLabel={ - checkEnabledResult.isEnabled - ? undefined - : i18n.translate( - 'xpack.triggersActionsUI.sections.actionsConnectorsList.upgradeBadge', - { defaultMessage: 'Upgrade' } - ) - } - betaBadgeTooltipContent={ - checkEnabledResult.isEnabled ? undefined : checkEnabledResult.message - } /> ); - return {card}; + return ( + + {checkEnabledResult.isEnabled && card} + {checkEnabledResult.isEnabled === false && ( + + {card} + + )} + + ); }); return ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index 665eeca43acb4..6b4a461bad24d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -31,6 +31,7 @@ import { hasSaveActionsCapability } from '../../lib/capabilities'; import { createActionConnector } from '../../lib/action_connector_api'; import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants'; +import { PLUGIN } from '../../constants/plugin'; export interface ConnectorAddFlyoutProps { addFlyoutVisible: boolean; @@ -138,15 +139,11 @@ export const ConnectorAddFlyout = ({ }) .catch(errorRes => { toastNotifications.addDanger( - i18n.translate( - 'xpack.triggersActionsUI.sections.addConnectorForm.updateErrorNotificationText', - { - defaultMessage: 'Failed to create connector: {message}', - values: { - message: errorRes.body?.message ?? '', - }, - } - ) + errorRes.body?.message ?? + i18n.translate( + 'xpack.triggersActionsUI.sections.addConnectorForm.updateErrorNotificationText', + { defaultMessage: 'Cannot create a connector.' } + ) ); return undefined; }); @@ -179,7 +176,10 @@ export const ConnectorAddFlyout = ({ 'xpack.triggersActionsUI.sections.addConnectorForm.betaBadgeTooltipContent', { defaultMessage: - 'This module is not GA. Please help us by reporting any bugs.', + '{pluginName} is in beta and is subject to change. The design and code is less mature than official GA features and is being provided as-is with no warranties. Beta features are not subject to the support SLA of official GA features.', + values: { + pluginName: PLUGIN.getI18nName(i18n), + }, } )} /> @@ -203,7 +203,10 @@ export const ConnectorAddFlyout = ({ 'xpack.triggersActionsUI.sections.addFlyout.betaBadgeTooltipContent', { defaultMessage: - 'This module is not GA. Please help us by reporting any bugs.', + '{pluginName} is in beta and is subject to change. The design and code is less mature than official GA features and is being provided as-is with no warranties. Beta features are not subject to the support SLA of official GA features.', + values: { + pluginName: PLUGIN.getI18nName(i18n), + }, } )} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index 977a908fd86f0..e04484b897e1c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -24,6 +24,7 @@ import { connectorReducer } from './connector_reducer'; import { createActionConnector } from '../../lib/action_connector_api'; import { TypeRegistry } from '../../type_registry'; import './connector_add_modal.scss'; +import { PLUGIN } from '../../constants/plugin'; interface ConnectorAddModalProps { actionType: ActionType; @@ -133,7 +134,10 @@ export const ConnectorAddModal = ({ 'xpack.triggersActionsUI.sections.addModalConnectorForm.betaBadgeTooltipContent', { defaultMessage: - 'This module is not GA. Please help us by reporting any bugs.', + '{pluginName} is in beta and is subject to change. The design and code is less mature than official GA features and is being provided as-is with no warranties. Beta features are not subject to the support SLA of official GA features.', + values: { + pluginName: PLUGIN.getI18nName(i18n), + }, } )} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 39c0b7255a7b9..ed8811d26331b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -25,6 +25,7 @@ import { connectorReducer } from './connector_reducer'; import { updateActionConnector } from '../../lib/action_connector_api'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; +import { PLUGIN } from '../../constants/plugin'; export interface ConnectorEditProps { initialConnector: ActionConnectorTableItem; @@ -66,33 +67,27 @@ export const ConnectorEditFlyout = ({ const onActionConnectorSave = async (): Promise => await updateActionConnector({ http, connector, id: connector.id }) .then(savedConnector => { - if (toastNotifications) { - toastNotifications.addSuccess( - i18n.translate( - 'xpack.triggersActionsUI.sections.editConnectorForm.updateSuccessNotificationText', - { - defaultMessage: "Updated '{connectorName}'", - values: { - connectorName: savedConnector.name, - }, - } - ) - ); - } - return savedConnector; - }) - .catch(errorRes => { - toastNotifications.addDanger( + toastNotifications.addSuccess( i18n.translate( - 'xpack.triggersActionsUI.sections.editConnectorForm.updateErrorNotificationText', + 'xpack.triggersActionsUI.sections.editConnectorForm.updateSuccessNotificationText', { - defaultMessage: 'Failed to update connector: {message}', + defaultMessage: "Updated '{connectorName}'", values: { - message: errorRes.body?.message ?? '', + connectorName: savedConnector.name, }, } ) ); + return savedConnector; + }) + .catch(errorRes => { + toastNotifications.addDanger( + errorRes.body?.message ?? + i18n.translate( + 'xpack.triggersActionsUI.sections.editConnectorForm.updateErrorNotificationText', + { defaultMessage: 'Cannot update a connector.' } + ) + ); return undefined; }); @@ -119,7 +114,10 @@ export const ConnectorEditFlyout = ({ 'xpack.triggersActionsUI.sections.editConnectorForm.betaBadgeTooltipContent', { defaultMessage: - 'This module is not GA. Please help us by reporting any bugs.', + '{pluginName} is in beta and is subject to change. The design and code is less mature than official GA features and is being provided as-is with no warranties. Beta features are not subject to the support SLA of official GA features.', + values: { + pluginName: PLUGIN.getI18nName(i18n), + }, } )} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index 8c2565538f718..0cb9bbbbfb261 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -114,7 +114,7 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { title: i18n.translate( 'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionsMessage', { - defaultMessage: 'Unable to load actions', + defaultMessage: 'Unable to load connectors', } ), }); @@ -213,11 +213,11 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { description: canDelete ? i18n.translate( 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDescription', - { defaultMessage: 'Delete this action' } + { defaultMessage: 'Delete this connector' } ) : i18n.translate( 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDisabledDescription', - { defaultMessage: 'Unable to delete actions' } + { defaultMessage: 'Unable to delete connectors' } ), type: 'icon', icon: 'trash', @@ -290,13 +290,13 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { ? undefined : i18n.translate( 'xpack.triggersActionsUI.sections.actionsConnectorsList.buttons.deleteDisabledTitle', - { defaultMessage: 'Unable to delete actions' } + { defaultMessage: 'Unable to delete connectors' } ) } > { } setConnectorsToDelete([]); }} - onCancel={async () => { - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.actionsConnectorsList.failedToDeleteActionsMessage', - { defaultMessage: 'Failed to delete action(s)' } - ), - }); + onErrors={async () => { // Refresh the actions from the server, some actions may have beend deleted await loadActions(); setConnectorsToDelete([]); }} + onCancel={async () => { + setConnectorsToDelete([]); + }} apiDeleteCall={deleteActions} idsToDelete={connectorsToDelete} singleTitle={i18n.translate( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index d781e8b761845..9da4f059f8967 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -12,6 +12,7 @@ import { EuiTitle, EuiBadge, EuiFlexItem, EuiSwitch, EuiBetaBadge } from '@elast import { times, random } from 'lodash'; import { i18n } from '@kbn/i18n'; import { ViewInApp } from './view_in_app'; +import { PLUGIN } from '../../../constants/plugin'; jest.mock('../../../app_context', () => ({ useAppDependencies: jest.fn(() => ({ @@ -63,7 +64,11 @@ describe('alert_details', () => { tooltipContent={i18n.translate( 'xpack.triggersActionsUI.sections.alertDetails.betaBadgeTooltipContent', { - defaultMessage: 'This module is not GA. Please help us by reporting any bugs.', + defaultMessage: + '{pluginName} is in beta and is subject to change. The design and code is less mature than official GA features and is being provided as-is with no warranties. Beta features are not subject to the support SLA of official GA features.', + values: { + pluginName: PLUGIN.getI18nName(i18n), + }, } )} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index 1f55e61e9ee0d..5bfcf9fd2d4e6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -33,6 +33,7 @@ import { } from '../../common/components/with_bulk_alert_api_operations'; import { AlertInstancesRouteWithApi } from './alert_instances_route'; import { ViewInApp } from './view_in_app'; +import { PLUGIN } from '../../../constants/plugin'; type AlertDetailsProps = { alert: Alert; @@ -77,7 +78,10 @@ export const AlertDetails: React.FunctionComponent = ({ 'xpack.triggersActionsUI.sections.alertDetails.betaBadgeTooltipContent', { defaultMessage: - 'This module is not GA. Please help us by reporting any bugs.', + '{pluginName} is in beta and is subject to change. The design and code is less mature than official GA features and is being provided as-is with no warranties. Beta features are not subject to the support SLA of official GA features.', + values: { + pluginName: PLUGIN.getI18nName(i18n), + }, } )} /> @@ -177,7 +181,7 @@ export const AlertDetails: React.FunctionComponent = ({

diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index fc524debe7443..ff83737325e8b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -17,6 +17,7 @@ import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { chartPluginMock } from '../../../../../../../src/plugins/charts/public/mocks'; import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; import { ReactWrapper } from 'enzyme'; +import { AppContextProvider } from '../../app_context'; const actionTypeRegistry = actionTypeRegistryMock.create(); const alertTypeRegistry = alertTypeRegistryMock.create(); @@ -49,7 +50,11 @@ describe('alert_add', () => { charts: chartPluginMock.createStartContract(), actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; + + mockes.http.get.mockResolvedValue({ isSufficientlySecure: true }); + const alertType = { id: 'my-alert-type', iconClass: 'test', @@ -83,22 +88,30 @@ describe('alert_add', () => { actionTypeRegistry.has.mockReturnValue(true); wrapper = mountWithIntl( - { - return new Promise(() => {}); - }, - http: deps.http, - actionTypeRegistry: deps.actionTypeRegistry, - alertTypeRegistry: deps.alertTypeRegistry, - toastNotifications: deps.toastNotifications, - uiSettings: deps.uiSettings, - metadata: { test: 'some value', fields: ['test'] }, - }} - > - {}} /> - + + { + return new Promise(() => {}); + }, + http: deps.http, + actionTypeRegistry: deps.actionTypeRegistry, + alertTypeRegistry: deps.alertTypeRegistry, + toastNotifications: deps.toastNotifications, + uiSettings: deps.uiSettings, + docLinks: deps.docLinks, + metadata: { test: 'some value', fields: ['test'] }, + }} + > + {}} + /> + + ); + // Wait for active space to resolve before requesting the component to update await act(async () => { await nextTick(); @@ -108,12 +121,15 @@ describe('alert_add', () => { it('renders alert add flyout', async () => { await setup(); + expect(wrapper.find('[data-test-subj="addAlertFlyoutTitle"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="saveAlertButton"]').exists()).toBeTruthy(); + wrapper .find('[data-test-subj="my-alert-type-SelectOption"]') .first() .simulate('click'); + expect(wrapper.contains('Metadata: some value. Fields: test.')).toBeTruthy(); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx index 4e6d63e97ec45..e44e20751b315 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx @@ -24,6 +24,8 @@ import { Alert, AlertAction, IErrorObject } from '../../../types'; import { AlertForm, validateBaseProperties } from './alert_form'; import { alertReducer } from './alert_reducer'; import { createAlert } from '../../lib/alert_api'; +import { AlertActionSecurityCallOut } from '../../components/alert_action_security_call_out'; +import { PLUGIN } from '../../constants/plugin'; interface AlertAddProps { consumer: string; @@ -64,6 +66,7 @@ export const AlertAdd = ({ toastNotifications, alertTypeRegistry, actionTypeRegistry, + docLinks, } = useAlertsContext(); const closeFlyout = useCallback(() => { @@ -109,12 +112,10 @@ export const AlertAdd = ({ return newAlert; } catch (errorRes) { toastNotifications.addDanger( - i18n.translate('xpack.triggersActionsUI.sections.alertAdd.saveErrorNotificationText', { - defaultMessage: 'Failed to save alert: {message}', - values: { - message: errorRes.body?.message ?? '', - }, - }) + errorRes.body?.message ?? + i18n.translate('xpack.triggersActionsUI.sections.alertAdd.saveErrorNotificationText', { + defaultMessage: 'Cannot create alert.', + }) ); } } @@ -132,7 +133,7 @@ export const AlertAdd = ({

  @@ -141,13 +142,27 @@ export const AlertAdd = ({ tooltipContent={i18n.translate( 'xpack.triggersActionsUI.sections.alertAdd.betaBadgeTooltipContent', { - defaultMessage: 'This module is not GA. Please help us by reporting any bugs.', + defaultMessage: + '{pluginName} is in beta and is subject to change. The design and code is less mature than official GA features and is being provided as-is with no warranties. Beta features are not subject to the support SLA of official GA features.', + values: { + pluginName: PLUGIN.getI18nName(i18n), + }, } )} />

+ { uiSettings: mockedCoreSetup.uiSettings, actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; + + mockedCoreSetup.http.get.mockResolvedValue({ isSufficientlySecure: true }); + const alertType = { id: 'my-alert-type', iconClass: 'test', @@ -102,24 +107,27 @@ describe('alert_edit', () => { actionTypeRegistry.has.mockReturnValue(true); wrapper = mountWithIntl( - { - return new Promise(() => {}); - }, - http: deps!.http, - actionTypeRegistry: deps!.actionTypeRegistry, - alertTypeRegistry: deps!.alertTypeRegistry, - toastNotifications: deps!.toastNotifications, - uiSettings: deps!.uiSettings, - }} - > - {}} - initialAlert={alert} - /> - + + { + return new Promise(() => {}); + }, + http: deps!.http, + actionTypeRegistry: deps!.actionTypeRegistry, + alertTypeRegistry: deps!.alertTypeRegistry, + toastNotifications: deps!.toastNotifications, + uiSettings: deps!.uiSettings, + docLinks: deps.docLinks, + }} + > + {}} + initialAlert={alert} + /> + + ); // Wait for active space to resolve before requesting the component to update await act(async () => { @@ -147,8 +155,6 @@ describe('alert_edit', () => { .first() .simulate('click'); }); - expect(mockedCoreSetup.notifications.toasts.addDanger).toHaveBeenCalledWith( - 'Failed to save alert: Fail message' - ); + expect(mockedCoreSetup.notifications.toasts.addDanger).toHaveBeenCalledWith('Fail message'); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx index 3feceb42e6ddc..3f27a7860bafa 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx @@ -26,6 +26,8 @@ import { Alert, AlertAction, IErrorObject } from '../../../types'; import { AlertForm, validateBaseProperties } from './alert_form'; import { alertReducer } from './alert_reducer'; import { updateAlert } from '../../lib/alert_api'; +import { AlertActionSecurityCallOut } from '../../components/alert_action_security_call_out'; +import { PLUGIN } from '../../constants/plugin'; interface AlertEditProps { initialAlert: Alert; @@ -48,6 +50,7 @@ export const AlertEdit = ({ toastNotifications, alertTypeRegistry, actionTypeRegistry, + docLinks, } = useAlertsContext(); const closeFlyout = useCallback(() => { @@ -82,28 +85,22 @@ export const AlertEdit = ({ async function onSaveAlert(): Promise { try { const newAlert = await updateAlert({ http, alert, id: alert.id }); - if (toastNotifications) { - toastNotifications.addSuccess( - i18n.translate('xpack.triggersActionsUI.sections.alertEdit.saveSuccessNotificationText', { - defaultMessage: "Updated '{alertName}'", - values: { - alertName: newAlert.name, - }, - }) - ); - } + toastNotifications.addSuccess( + i18n.translate('xpack.triggersActionsUI.sections.alertEdit.saveSuccessNotificationText', { + defaultMessage: "Updated '{alertName}'", + values: { + alertName: newAlert.name, + }, + }) + ); return newAlert; } catch (errorRes) { - if (toastNotifications) { - toastNotifications.addDanger( + toastNotifications.addDanger( + errorRes.body?.message ?? i18n.translate('xpack.triggersActionsUI.sections.alertEdit.saveErrorNotificationText', { - defaultMessage: 'Failed to save alert: {message}', - values: { - message: errorRes.body?.message ?? '', - }, + defaultMessage: 'Cannot update alert.', }) - ); - } + ); } } @@ -120,7 +117,7 @@ export const AlertEdit = ({

  @@ -129,13 +126,27 @@ export const AlertEdit = ({ tooltipContent={i18n.translate( 'xpack.triggersActionsUI.sections.alertEdit.betaBadgeTooltipContent', { - defaultMessage: 'This module is not GA. Please help us by reporting any bugs.', + defaultMessage: + '{pluginName} is in beta and is subject to change. The design and code is less mature than official GA features and is being provided as-is with no warranties. Beta features are not subject to the support SLA of official GA features.', + values: { + pluginName: PLUGIN.getI18nName(i18n), + }, } )} />

+ {hasActionsDisabled && ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx index b87aaacb3ec0e..72c22f46f217e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx @@ -53,6 +53,7 @@ describe('alert_form', () => { uiSettings: mockes.uiSettings, actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, + docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, }; alertTypeRegistry.list.mockReturnValue([alertType]); alertTypeRegistry.has.mockReturnValue(true); @@ -80,6 +81,7 @@ describe('alert_form', () => { return new Promise(() => {}); }, http: deps!.http, + docLinks: deps.docLinks, actionTypeRegistry: deps!.actionTypeRegistry, alertTypeRegistry: deps!.alertTypeRegistry, toastNotifications: deps!.toastNotifications, @@ -159,6 +161,7 @@ describe('alert_form', () => { return new Promise(() => {}); }, http: deps!.http, + docLinks: deps.docLinks, actionTypeRegistry: deps!.actionTypeRegistry, alertTypeRegistry: deps!.alertTypeRegistry, toastNotifications: deps!.toastNotifications, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index c6346ba002a7f..4b8045d1bc8a4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -24,6 +24,8 @@ import { EuiButtonIcon, EuiHorizontalRule, } from '@elastic/eui'; +import { some, filter, map, fold } from 'fp-ts/lib/Option'; +import { pipe } from 'fp-ts/lib/pipeable'; import { getDurationNumberInItsUnit, getDurationUnitValue, @@ -258,7 +260,7 @@ export const AlertForm = ({ position="right" type="questionInCircle" content={i18n.translate('xpack.triggersActionsUI.sections.alertForm.checkWithTooltip', { - defaultMessage: 'This is some help text here for check alert.', + defaultMessage: 'Define how often to evaluate the condition.', })} /> @@ -268,13 +270,13 @@ export const AlertForm = ({ <> {' '} @@ -408,9 +410,23 @@ export const AlertForm = ({ name="throttle" data-test-subj="throttleInput" onChange={e => { - const throttle = e.target.value !== '' ? parseInt(e.target.value, 10) : null; - setAlertThrottle(throttle); - setAlertProperty('throttle', `${e.target.value}${alertThrottleUnit}`); + pipe( + some(e.target.value.trim()), + filter(value => value !== ''), + map(value => parseInt(value, 10)), + filter(value => !isNaN(value)), + fold( + () => { + // unset throttle + setAlertThrottle(null); + setAlertProperty('throttle', null); + }, + throttle => { + setAlertThrottle(throttle); + setAlertProperty('throttle', `${throttle}${alertThrottleUnit}`); + } + ) + ); }} /> @@ -440,7 +456,7 @@ export const AlertForm = ({
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index 84e4d5794859c..8d675148690c7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -59,6 +59,7 @@ export const AlertsList: React.FunctionComponent = () => { alertTypeRegistry, actionTypeRegistry, uiSettings, + docLinks, charts, dataPlugin, } = useAppDependencies(); @@ -120,7 +121,7 @@ export const AlertsList: React.FunctionComponent = () => { (async () => { try { const result = await loadActionTypes({ http }); - setActionTypes(result); + setActionTypes(result.filter(actionType => actionTypeRegistry.has(actionType.id))); } catch (e) { toastNotifications.addDanger({ title: i18n.translate( @@ -285,7 +286,7 @@ export const AlertsList: React.FunctionComponent = () => { > ); @@ -307,7 +308,7 @@ export const AlertsList: React.FunctionComponent = () => {

} @@ -445,15 +446,13 @@ export const AlertsList: React.FunctionComponent = () => { } setAlertsToDelete([]); }} - onCancel={async () => { - toastNotifications.addDanger({ - title: i18n.translate( - 'xpack.triggersActionsUI.sections.alertsList.failedToDeleteAlertsMessage', - { defaultMessage: 'Failed to delete alert(s)' } - ), - }); + onErrors={async () => { // Refresh the alerts from the server, some alerts may have beend deleted await loadAlertsData(); + setAlertsToDelete([]); + }} + onCancel={async () => { + setAlertsToDelete([]); }} apiDeleteCall={deleteAlerts} idsToDelete={alertsToDelete} @@ -480,6 +479,7 @@ export const AlertsList: React.FunctionComponent = () => { alertTypeRegistry, toastNotifications, uiSettings, + docLinks, charts, dataFieldsFormats: dataPlugin.fieldFormats, }} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx index 0ba590ab462a7..a60b7e68f1f94 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx @@ -6,7 +6,7 @@ import React from 'react'; -import { Alert, AlertType, AlertTaskState } from '../../../../types'; +import { Alert, AlertType, AlertTaskState, AlertingFrameworkHealth } from '../../../../types'; import { useAppDependencies } from '../../../app_context'; import { deleteAlerts, @@ -23,6 +23,7 @@ import { loadAlert, loadAlertState, loadAlertTypes, + health, } from '../../../lib/alert_api'; export interface ComponentOpts { @@ -51,6 +52,7 @@ export interface ComponentOpts { loadAlert: (id: Alert['id']) => Promise; loadAlertState: (id: Alert['id']) => Promise; loadAlertTypes: () => Promise; + getHealth: () => Promise; } export type PropsWithOptionalApiHandlers = Omit & Partial; @@ -115,6 +117,7 @@ export function withBulkAlertOperations( loadAlert={async (alertId: Alert['id']) => loadAlert({ http, alertId })} loadAlertState={async (alertId: Alert['id']) => loadAlertState({ http, alertId })} loadAlertTypes={async () => loadAlertTypes({ http })} + getHealth={async () => health({ http })} /> ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 900521830571c..7dfaa7b918f70 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -12,8 +12,9 @@ import { AlertAction, AlertTaskState, RawAlertInstance, + AlertingFrameworkHealth, } from '../../../plugins/alerting/common'; -export { Alert, AlertAction, AlertTaskState, RawAlertInstance }; +export { Alert, AlertAction, AlertTaskState, RawAlertInstance, AlertingFrameworkHealth }; export { ActionType }; export type ActionTypeIndex = Record; diff --git a/x-pack/plugins/uptime/server/kibana.index.ts b/x-pack/plugins/uptime/server/kibana.index.ts index 19506bb316a05..c206cfa06e272 100644 --- a/x-pack/plugins/uptime/server/kibana.index.ts +++ b/x-pack/plugins/uptime/server/kibana.index.ts @@ -6,7 +6,6 @@ import { Request, Server } from 'hapi'; import { PLUGIN } from '../../../legacy/plugins/uptime/common/constants'; -import { KibanaTelemetryAdapter } from './lib/adapters/telemetry'; import { compose } from './lib/compose/kibana'; import { initUptimeServer } from './uptime_server'; import { UptimeCorePlugins, UptimeCoreSetup } from './lib/adapters/framework'; @@ -25,19 +24,21 @@ export interface KibanaServer extends Server { } export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCorePlugins) => { - const { features, usageCollection } = plugins; + const { features } = plugins; const libs = compose(server); - KibanaTelemetryAdapter.registerUsageCollector(usageCollection); features.registerFeature({ id: PLUGIN.ID, name: PLUGIN.NAME, + order: 1000, navLinkId: PLUGIN.ID, icon: 'uptimeApp', app: ['uptime', 'kibana'], catalogue: ['uptime'], privileges: { all: { + app: ['uptime', 'kibana'], + catalogue: ['uptime'], api: ['uptime-read', 'uptime-write'], savedObject: { all: [umDynamicSettings.name], @@ -46,6 +47,8 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor ui: ['save', 'configureSettings', 'show'], }, read: { + app: ['uptime', 'kibana'], + catalogue: ['uptime'], api: ['uptime-read'], savedObject: { all: [], diff --git a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts index a6dd8efd57c14..47fe5f2af4263 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts @@ -6,12 +6,17 @@ import { GraphQLSchema } from 'graphql'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { IRouter, CallAPIOptions, SavedObjectsClientContract } from 'src/core/server'; +import { + IRouter, + CallAPIOptions, + SavedObjectsClientContract, + ISavedObjectsRepository, +} from 'src/core/server'; import { UMKibanaRoute } from '../../../rest_api'; import { PluginSetupContract } from '../../../../../features/server'; import { DynamicSettings } from '../../../../../../legacy/plugins/uptime/common/runtime_types'; -type APICaller = ( +export type APICaller = ( endpoint: string, clientParams: Record, options?: CallAPIOptions @@ -22,7 +27,7 @@ export type UMElasticsearchQueryFn = ( ) => Promise | R; export type UMSavedObjectsQueryFn = ( - client: SavedObjectsClientContract, + client: SavedObjectsClientContract | ISavedObjectsRepository, params?: P ) => Promise | T; diff --git a/x-pack/plugins/uptime/server/lib/adapters/telemetry/__tests__/__snapshots__/kibana_telemetry_adapter.test.ts.snap b/x-pack/plugins/uptime/server/lib/adapters/telemetry/__tests__/__snapshots__/kibana_telemetry_adapter.test.ts.snap index e88a2cdc50cd9..8c55d5da54ac7 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/telemetry/__tests__/__snapshots__/kibana_telemetry_adapter.test.ts.snap +++ b/x-pack/plugins/uptime/server/lib/adapters/telemetry/__tests__/__snapshots__/kibana_telemetry_adapter.test.ts.snap @@ -4,8 +4,32 @@ exports[`KibanaTelemetryAdapter collects monitor and overview data 1`] = ` Object { "last_24_hours": Object { "hits": Object { + "autoRefreshEnabled": true, + "autorefreshInterval": Array [ + 30, + ], + "dateRangeEnd": Array [ + "now", + ], + "dateRangeStart": Array [ + "now-15", + ], + "monitor_frequency": Array [], + "monitor_name_stats": Object { + "avg_length": 0, + "max_length": 0, + "min_length": 0, + }, "monitor_page": 1, - "overview_page": 2, + "no_of_unique_monitors": 0, + "no_of_unique_observer_locations": 0, + "observer_location_name_stats": Object { + "avg_length": 0, + "max_length": 0, + "min_length": 0, + }, + "overview_page": 1, + "settings_page": 1, }, }, } @@ -15,8 +39,32 @@ exports[`KibanaTelemetryAdapter drops old buckets and reduces current window 1`] Object { "last_24_hours": Object { "hits": Object { - "monitor_page": 3, - "overview_page": 4, + "autoRefreshEnabled": true, + "autorefreshInterval": Array [ + 30, + ], + "dateRangeEnd": Array [ + "now", + ], + "dateRangeStart": Array [ + "now-15", + ], + "monitor_frequency": Array [], + "monitor_name_stats": Object { + "avg_length": 0, + "max_length": 0, + "min_length": 0, + }, + "monitor_page": 2, + "no_of_unique_monitors": 0, + "no_of_unique_observer_locations": 0, + "observer_location_name_stats": Object { + "avg_length": 0, + "max_length": 0, + "min_length": 0, + }, + "overview_page": 1, + "settings_page": 2, }, }, } diff --git a/x-pack/plugins/uptime/server/lib/adapters/telemetry/__tests__/kibana_telemetry_adapter.test.ts b/x-pack/plugins/uptime/server/lib/adapters/telemetry/__tests__/kibana_telemetry_adapter.test.ts index 8e4011b4cf0eb..c2437dbf35307 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/telemetry/__tests__/kibana_telemetry_adapter.test.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/telemetry/__tests__/kibana_telemetry_adapter.test.ts @@ -8,6 +8,7 @@ import { KibanaTelemetryAdapter } from '../kibana_telemetry_adapter'; describe('KibanaTelemetryAdapter', () => { let usageCollection: any; + let getSavedObjectsClient: any; let collector: { type: string; fetch: () => Promise; isReady: () => boolean }; beforeEach(() => { usageCollection = { @@ -15,14 +16,35 @@ describe('KibanaTelemetryAdapter', () => { collector = val; }, }; + getSavedObjectsClient = () => { + return {}; + }; }); it('collects monitor and overview data', async () => { expect.assertions(1); - KibanaTelemetryAdapter.initUsageCollector(usageCollection); - KibanaTelemetryAdapter.countMonitor(); - KibanaTelemetryAdapter.countOverview(); - KibanaTelemetryAdapter.countOverview(); + KibanaTelemetryAdapter.initUsageCollector(usageCollection, getSavedObjectsClient); + KibanaTelemetryAdapter.countPageView({ + page: 'Overview', + dateStart: 'now-15', + dateEnd: 'now', + autoRefreshEnabled: true, + autorefreshInterval: 30, + }); + KibanaTelemetryAdapter.countPageView({ + page: 'Monitor', + dateStart: 'now-15', + dateEnd: 'now', + autoRefreshEnabled: true, + autorefreshInterval: 30, + }); + KibanaTelemetryAdapter.countPageView({ + page: 'Settings', + dateStart: 'now-15', + dateEnd: 'now', + autoRefreshEnabled: true, + autorefreshInterval: 30, + }); const result = await collector.fetch(); expect(result).toMatchSnapshot(); }); @@ -31,21 +53,42 @@ describe('KibanaTelemetryAdapter', () => { expect.assertions(1); // give a time of > 24 hours ago Date.now = jest.fn(() => 1559053560000); - KibanaTelemetryAdapter.initUsageCollector(usageCollection); - KibanaTelemetryAdapter.countMonitor(); - KibanaTelemetryAdapter.countOverview(); - // give a time of now + KibanaTelemetryAdapter.initUsageCollector(usageCollection, getSavedObjectsClient); + KibanaTelemetryAdapter.countPageView({ + page: 'Overview', + dateStart: 'now-20', + dateEnd: 'now', + autoRefreshEnabled: true, + autorefreshInterval: 30, + }); + KibanaTelemetryAdapter.countPageView({ + page: 'Monitor', + dateStart: 'now-15', + dateEnd: 'now', + autoRefreshEnabled: true, + autorefreshInterval: 30, + }); // give a time of now Date.now = jest.fn(() => new Date().valueOf()); - KibanaTelemetryAdapter.countMonitor(); - KibanaTelemetryAdapter.countMonitor(); - KibanaTelemetryAdapter.countOverview(); - KibanaTelemetryAdapter.countOverview(); + KibanaTelemetryAdapter.countPageView({ + page: 'Monitor', + dateStart: 'now-15', + dateEnd: 'now', + autoRefreshEnabled: true, + autorefreshInterval: 30, + }); + KibanaTelemetryAdapter.countPageView({ + page: 'Settings', + dateStart: 'now-15', + dateEnd: 'now', + autoRefreshEnabled: true, + autorefreshInterval: 30, + }); const result = await collector.fetch(); expect(result).toMatchSnapshot(); }); it('defaults ready to `true`', async () => { - KibanaTelemetryAdapter.initUsageCollector(usageCollection); + KibanaTelemetryAdapter.initUsageCollector(usageCollection, getSavedObjectsClient); expect(collector.isReady()).toBe(true); }); }); diff --git a/x-pack/plugins/uptime/server/lib/adapters/telemetry/kibana_telemetry_adapter.ts b/x-pack/plugins/uptime/server/lib/adapters/telemetry/kibana_telemetry_adapter.ts index 8dec0c1d2d485..e10a476bcc668 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/telemetry/kibana_telemetry_adapter.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/telemetry/kibana_telemetry_adapter.ts @@ -3,12 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -interface UptimeTelemetry { - overview_page: number; - monitor_page: number; -} +import moment from 'moment'; +import { ISavedObjectsRepository } from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { PageViewParams, UptimeTelemetry } from './types'; +import { APICaller } from '../framework'; +import { savedObjectsAdapter } from '../../saved_objects'; interface UptimeTelemetryCollector { [key: number]: UptimeTelemetry; @@ -20,30 +21,180 @@ const BUCKET_SIZE = 3600; const BUCKET_NUMBER = 24; export class KibanaTelemetryAdapter { - public static registerUsageCollector = (usageCollector: UsageCollectionSetup) => { - const collector = KibanaTelemetryAdapter.initUsageCollector(usageCollector); + public static registerUsageCollector = ( + usageCollector: UsageCollectionSetup, + getSavedObjectsClient: () => ISavedObjectsRepository | undefined + ) => { + if (!usageCollector) { + return; + } + const collector = KibanaTelemetryAdapter.initUsageCollector( + usageCollector, + getSavedObjectsClient + ); usageCollector.registerCollector(collector); }; - public static initUsageCollector(usageCollector: UsageCollectionSetup) { + public static initUsageCollector( + usageCollector: UsageCollectionSetup, + getSavedObjectsClient: () => ISavedObjectsRepository | undefined + ) { return usageCollector.makeUsageCollector({ type: 'uptime', - fetch: async () => { + fetch: async (callCluster: APICaller) => { + const savedObjectsClient = getSavedObjectsClient()!; + if (savedObjectsClient) { + this.countNoOfUniqueMonitorAndLocations(callCluster, savedObjectsClient); + } const report = this.getReport(); return { last_24_hours: { hits: { ...report } } }; }, - isReady: () => true, + isReady: () => typeof getSavedObjectsClient() !== 'undefined', }); } - public static countOverview() { - const bucket = this.getBucketToIncrement(); - this.collector[bucket].overview_page += 1; + public static countPageView(pageView: PageViewParams) { + const bucketId = this.getBucketToIncrement(); + const bucket = this.collector[bucketId]; + if (pageView.page === 'Overview') { + bucket.overview_page += 1; + } + if (pageView.page === 'Monitor') { + bucket.monitor_page += 1; + } + if (pageView.page === 'Settings') { + bucket.settings_page += 1; + } + this.updateDateData(pageView, bucket); + return bucket; + } + + public static updateDateData( + { dateStart, dateEnd, autoRefreshEnabled, autorefreshInterval }: PageViewParams, + bucket: UptimeTelemetry + ) { + const prevDateStart = [...bucket.dateRangeStart].pop(); + if (!prevDateStart || prevDateStart !== dateStart) { + bucket.dateRangeStart.push(dateStart); + bucket.dateRangeEnd.push(dateEnd); + } else { + const prevDateEnd = [...bucket.dateRangeEnd].pop(); + if (!prevDateEnd || prevDateEnd !== dateEnd) { + bucket.dateRangeStart.push(dateStart); + bucket.dateRangeEnd.push(dateEnd); + } + } + + const prevAutorefreshInterval = [...bucket.autorefreshInterval].pop(); + if (!prevAutorefreshInterval || prevAutorefreshInterval !== autorefreshInterval) { + bucket.autorefreshInterval.push(autorefreshInterval); + } + bucket.autoRefreshEnabled = autoRefreshEnabled; } - public static countMonitor() { + public static async countNoOfUniqueMonitorAndLocations( + callCluster: APICaller, + savedObjectsClient: ISavedObjectsRepository + ) { + const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings(savedObjectsClient); + const params = { + index: dynamicSettings.heartbeatIndices, + body: { + query: { + bool: { + must: [ + { + range: { + '@timestamp': { + gte: 'now-1d/d', + lt: 'now', + }, + }, + }, + ], + }, + }, + size: 0, + aggs: { + unique_monitors: { + cardinality: { + field: 'monitor.id', + }, + }, + unique_locations: { + cardinality: { + field: 'observer.geo.name', + missing: 'N/A', + }, + }, + monitor_name: { + string_stats: { + field: 'monitor.name', + }, + }, + observer_loc_name: { + string_stats: { + field: 'observer.geo.name', + }, + }, + monitors: { + terms: { + field: 'monitor.id', + size: 1000, + }, + aggs: { + docs: { + top_hits: { + size: 1, + _source: ['monitor.timespan'], + }, + }, + }, + }, + }, + }, + }; + + const result = await callCluster('search', params); + const numberOfUniqueMonitors: number = result?.aggregations?.unique_monitors?.value ?? 0; + const numberOfUniqueLocations: number = result?.aggregations?.unique_locations?.value ?? 0; + const monitorNameStats: any = result?.aggregations?.monitor_name; + const locationNameStats: any = result?.aggregations?.observer_loc_name; + const uniqueMonitors: any = result?.aggregations?.monitors.buckets; const bucket = this.getBucketToIncrement(); - this.collector[bucket].monitor_page += 1; + + this.collector[bucket].no_of_unique_monitors = numberOfUniqueMonitors; + this.collector[bucket].no_of_unique_observer_locations = numberOfUniqueLocations; + this.collector[bucket].no_of_unique_observer_locations = numberOfUniqueLocations; + this.collector[bucket].monitor_name_stats = { + min_length: monitorNameStats?.min_length ?? 0, + max_length: monitorNameStats?.max_length ?? 0, + avg_length: +monitorNameStats?.avg_length.toFixed(2), + }; + + this.collector[bucket].observer_location_name_stats = { + min_length: locationNameStats?.min_length ?? 0, + max_length: locationNameStats?.max_length ?? 0, + avg_length: +locationNameStats?.avg_length.toFixed(2), + }; + + this.collector[bucket].monitor_frequency = this.getMonitorsFrequency(uniqueMonitors); + } + + private static getMonitorsFrequency(uniqueMonitors = []) { + const frequencies: number[] = []; + uniqueMonitors + .map((item: any) => item!.docs.hits?.hits?.[0] ?? {}) + .forEach(monitor => { + const timespan = monitor?._source?.monitor?.timespan; + if (timespan) { + const timeDiffSec = moment + .duration(moment(timespan.lt).diff(moment(timespan.gte))) + .asSeconds(); + frequencies.push(timeDiffSec); + } + }); + return frequencies; } private static collector: UptimeTelemetryCollector = {}; @@ -59,10 +210,12 @@ export class KibanaTelemetryAdapter { return Object.values(this.collector).reduce( (acc, cum) => ({ + ...cum, overview_page: acc.overview_page + cum.overview_page, monitor_page: acc.monitor_page + cum.monitor_page, + settings_page: acc.settings_page + cum.settings_page, }), - { overview_page: 0, monitor_page: 0 } + { overview_page: 0, monitor_page: 0, settings_page: 0 } ); } @@ -77,6 +230,24 @@ export class KibanaTelemetryAdapter { this.collector[bucketId] = { overview_page: 0, monitor_page: 0, + no_of_unique_monitors: 0, + settings_page: 0, + monitor_frequency: [], + monitor_name_stats: { + min_length: 0, + max_length: 0, + avg_length: 0, + }, + no_of_unique_observer_locations: 0, + observer_location_name_stats: { + min_length: 0, + max_length: 0, + avg_length: 0, + }, + dateRangeStart: [], + dateRangeEnd: [], + autoRefreshEnabled: false, + autorefreshInterval: [], }; } return bucketId; diff --git a/x-pack/plugins/uptime/server/lib/adapters/telemetry/types.ts b/x-pack/plugins/uptime/server/lib/adapters/telemetry/types.ts new file mode 100644 index 0000000000000..059bea6cc3215 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/adapters/telemetry/types.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. + */ + +export interface PageViewParams { + page: string; + dateStart: string; + dateEnd: string; + autoRefreshEnabled: boolean; + autorefreshInterval: number; +} + +export interface Stats { + min_length: number; + max_length: number; + avg_length: number; +} + +export interface UptimeTelemetry { + overview_page: number; + monitor_page: number; + settings_page: number; + no_of_unique_monitors: number; + monitor_frequency: number[]; + no_of_unique_observer_locations: number; + monitor_name_stats: Stats; + observer_location_name_stats: Stats; + + dateRangeStart: string[]; + dateRangeEnd: string[]; + autorefreshInterval: number[]; + autoRefreshEnabled: boolean; +} diff --git a/x-pack/plugins/uptime/server/lib/requests/get_filter_bar.ts b/x-pack/plugins/uptime/server/lib/requests/get_filter_bar.ts index b533c990083ab..95d23ddcbf466 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_filter_bar.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_filter_bar.ts @@ -58,7 +58,7 @@ export const extractFilterAggsResults = ( tags: [], }; keys.forEach(key => { - const buckets = responseAggregations[key]?.term?.buckets ?? []; + const buckets = responseAggregations?.[key]?.term?.buckets ?? []; values[key] = buckets.map((item: { key: string | number }) => item.key); }); return values; diff --git a/x-pack/plugins/uptime/server/plugin.ts b/x-pack/plugins/uptime/server/plugin.ts index 00e36be50d24e..7cc591a6b2db1 100644 --- a/x-pack/plugins/uptime/server/plugin.ts +++ b/x-pack/plugins/uptime/server/plugin.ts @@ -4,16 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext, CoreStart, CoreSetup } from '../../../../src/core/server'; +import { + PluginInitializerContext, + CoreStart, + CoreSetup, + ISavedObjectsRepository, +} from '../../../../src/core/server'; import { initServerWithKibana } from './kibana.index'; -import { UptimeCorePlugins } from './lib/adapters'; +import { KibanaTelemetryAdapter, UptimeCorePlugins } from './lib/adapters'; import { umDynamicSettings } from './lib/saved_objects'; export class Plugin { + private savedObjectsClient?: ISavedObjectsRepository; + constructor(_initializerContext: PluginInitializerContext) {} + public setup(core: CoreSetup, plugins: UptimeCorePlugins) { initServerWithKibana({ route: core.http.createRouter() }, plugins); core.savedObjects.registerType(umDynamicSettings); + KibanaTelemetryAdapter.registerUsageCollector( + plugins.usageCollection, + () => this.savedObjectsClient + ); + } + + public start(_core: CoreStart, _plugins: any) { + this.savedObjectsClient = _core.savedObjects.createInternalRepository(); } - public start(_core: CoreStart, _plugins: any) {} } diff --git a/x-pack/plugins/uptime/server/rest_api/index.ts b/x-pack/plugins/uptime/server/rest_api/index.ts index 000fba69fab00..561997c3567d0 100644 --- a/x-pack/plugins/uptime/server/rest_api/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/index.ts @@ -7,7 +7,7 @@ import { createGetOverviewFilters } from './overview_filters'; import { createGetPingsRoute } from './pings'; import { createGetDynamicSettingsRoute, createPostDynamicSettingsRoute } from './dynamic_settings'; -import { createLogMonitorPageRoute, createLogOverviewPageRoute } from './telemetry'; +import { createLogPageViewRoute } from './telemetry'; import { createGetSnapshotCount } from './snapshot'; import { UMRestApiRouteFactory } from './types'; import { @@ -36,8 +36,7 @@ export const restApiRoutes: UMRestApiRouteFactory[] = [ createGetMonitorLocationsRoute, createGetStatusBarRoute, createGetSnapshotCount, - createLogMonitorPageRoute, - createLogOverviewPageRoute, + createLogPageViewRoute, createGetPingHistogramRoute, createGetMonitorDurationRoute, ]; diff --git a/x-pack/plugins/uptime/server/rest_api/telemetry/index.ts b/x-pack/plugins/uptime/server/rest_api/telemetry/index.ts index 29640d97213a6..f16080296dc67 100644 --- a/x-pack/plugins/uptime/server/rest_api/telemetry/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/telemetry/index.ts @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { createLogMonitorPageRoute } from './log_monitor_page'; -export { createLogOverviewPageRoute } from './log_overview_page'; +export { createLogPageViewRoute } from './log_page_view'; diff --git a/x-pack/plugins/uptime/server/rest_api/telemetry/log_monitor_page.ts b/x-pack/plugins/uptime/server/rest_api/telemetry/log_monitor_page.ts deleted file mode 100644 index 71d6b8025dff2..0000000000000 --- a/x-pack/plugins/uptime/server/rest_api/telemetry/log_monitor_page.ts +++ /dev/null @@ -1,21 +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 { KibanaTelemetryAdapter } from '../../lib/adapters/telemetry'; -import { UMRestApiRouteFactory } from '../types'; - -export const createLogMonitorPageRoute: UMRestApiRouteFactory = () => ({ - method: 'POST', - path: '/api/uptime/logMonitor', - validate: false, - handler: async (_customParams, _context, _request, response): Promise => { - await KibanaTelemetryAdapter.countMonitor(); - return response.ok(); - }, - options: { - tags: ['access:uptime-read'], - }, -}); diff --git a/x-pack/plugins/uptime/server/rest_api/telemetry/log_overview_page.ts b/x-pack/plugins/uptime/server/rest_api/telemetry/log_overview_page.ts deleted file mode 100644 index de1ac5f4ed735..0000000000000 --- a/x-pack/plugins/uptime/server/rest_api/telemetry/log_overview_page.ts +++ /dev/null @@ -1,21 +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 { KibanaTelemetryAdapter } from '../../lib/adapters/telemetry'; -import { UMRestApiRouteFactory } from '../types'; - -export const createLogOverviewPageRoute: UMRestApiRouteFactory = () => ({ - method: 'POST', - path: '/api/uptime/logOverview', - validate: false, - handler: async (_customParams, _context, _request, response): Promise => { - await KibanaTelemetryAdapter.countOverview(); - return response.ok(); - }, - options: { - tags: ['access:uptime-read'], - }, -}); diff --git a/x-pack/plugins/uptime/server/rest_api/telemetry/log_page_view.ts b/x-pack/plugins/uptime/server/rest_api/telemetry/log_page_view.ts new file mode 100644 index 0000000000000..1f6f052019870 --- /dev/null +++ b/x-pack/plugins/uptime/server/rest_api/telemetry/log_page_view.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 { schema } from '@kbn/config-schema'; +import { KibanaTelemetryAdapter } from '../../lib/adapters/telemetry'; +import { UMRestApiRouteFactory } from '../types'; +import { PageViewParams } from '../../lib/adapters/telemetry/types'; + +export const createLogPageViewRoute: UMRestApiRouteFactory = () => ({ + method: 'POST', + path: '/api/uptime/logPageView', + validate: { + body: schema.object({ + page: schema.string(), + dateStart: schema.string(), + dateEnd: schema.string(), + autoRefreshEnabled: schema.boolean(), + autorefreshInterval: schema.number(), + }), + }, + handler: async ({ callES, dynamicSettings }, _context, _request, response): Promise => { + const result = KibanaTelemetryAdapter.countPageView(_request.body as PageViewParams); + return response.ok({ + body: result, + }); + }, + options: { + tags: ['access:uptime-read'], + }, +}); diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts index acd14e8a2bf7b..019b15cc1862a 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts @@ -57,6 +57,7 @@ export default function(kibana: any) { app: ['actions', 'kibana'], privileges: { all: { + app: ['actions', 'kibana'], savedObject: { all: ['action', 'action_task_params'], read: [], @@ -65,6 +66,7 @@ export default function(kibana: any) { api: ['actions-read', 'actions-all'], }, read: { + app: ['actions', 'kibana'], savedObject: { all: ['action_task_params'], read: ['action'], diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts index 9b4a2d14de9ea..fe0f630830a56 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts @@ -20,6 +20,7 @@ export default function(kibana: any) { app: ['alerting', 'kibana'], privileges: { all: { + app: ['alerting', 'kibana'], savedObject: { all: ['alert'], read: [], @@ -28,6 +29,7 @@ export default function(kibana: any) { api: ['alerting-read', 'alerting-all'], }, read: { + app: ['alerting', 'kibana'], savedObject: { all: [], read: ['alert'], diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index.ts index 6d76a00d39b97..01eaf92da33fe 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/es_index.ts @@ -45,6 +45,7 @@ export default function indexTest({ getService }: FtrProviderContext) { config: { index: ES_TEST_INDEX_NAME, refresh: false, + executionTimeField: null, }, }); createdActionID = createdAction.id; @@ -58,7 +59,7 @@ export default function indexTest({ getService }: FtrProviderContext) { id: fetchedAction.id, name: 'An index action', actionTypeId: '.index', - config: { index: ES_TEST_INDEX_NAME, refresh: false }, + config: { index: ES_TEST_INDEX_NAME, refresh: false, executionTimeField: null }, }); // create action with all config props diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/es_index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/es_index.ts index 5cc3d7275a7bd..3713e9c24419f 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/es_index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/es_index.ts @@ -43,6 +43,7 @@ export default function indexTest({ getService }: FtrProviderContext) { config: { index: ES_TEST_INDEX_NAME, refresh: false, + executionTimeField: null, }, }); createdActionID = createdAction.id; @@ -56,7 +57,7 @@ export default function indexTest({ getService }: FtrProviderContext) { id: fetchedAction.id, name: 'An index action', actionTypeId: '.index', - config: { index: ES_TEST_INDEX_NAME, refresh: false }, + config: { index: ES_TEST_INDEX_NAME, refresh: false, executionTimeField: null }, }); // create action with all config props diff --git a/x-pack/test/api_integration/apis/lens/telemetry.ts b/x-pack/test/api_integration/apis/lens/telemetry.ts index 653df453c2560..fce76bfc96e2c 100644 --- a/x-pack/test/api_integration/apis/lens/telemetry.ts +++ b/x-pack/test/api_integration/apis/lens/telemetry.ts @@ -201,8 +201,9 @@ export default ({ getService }: FtrProviderContext) => { expect(results.saved_overall).to.eql({ lnsMetric: 1, + bar_stacked: 1, }); - expect(results.saved_overall_total).to.eql(1); + expect(results.saved_overall_total).to.eql(2); await esArchiver.unload('lens/basic'); }); diff --git a/x-pack/test/api_integration/apis/security/index.js b/x-pack/test/api_integration/apis/security/index.js index df35ec2195dc5..ad1876cb717f1 100644 --- a/x-pack/test/api_integration/apis/security/index.js +++ b/x-pack/test/api_integration/apis/security/index.js @@ -8,6 +8,9 @@ export default function({ loadTestFile }) { describe('security', function() { this.tags('ciGroup6'); + // Updates here should be mirrored in `./security_basic.ts` if tests + // should also run under a basic license. + loadTestFile(require.resolve('./basic_login')); loadTestFile(require.resolve('./builtin_es_privileges')); loadTestFile(require.resolve('./change_password')); diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 0b29fc1cac7de..77293ddff3f9f 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -5,6 +5,8 @@ */ import util from 'util'; import { isEqual } from 'lodash'; +import expect from '@kbn/expect/expect.js'; +import { RawKibanaPrivileges } from '../../../../plugins/security/common/model'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function({ getService }: FtrProviderContext) { @@ -18,9 +20,9 @@ export default function({ getService }: FtrProviderContext) { // Roles are associated with these privileges, and we shouldn't be removing them in a minor version. const expected = { features: { - discover: ['all', 'read'], - visualize: ['all', 'read'], - dashboard: ['all', 'read'], + discover: ['all', 'read', 'minimal_all', 'minimal_read', 'url_create'], + visualize: ['all', 'read', 'minimal_all', 'minimal_read', 'url_create'], + dashboard: ['all', 'read', 'minimal_all', 'minimal_read', 'url_create'], dev_tools: ['all', 'read'], advancedSettings: ['all', 'read'], indexPatterns: ['all', 'read'], @@ -48,13 +50,18 @@ export default function({ getService }: FtrProviderContext) { .send() .expect(200) .expect((res: any) => { - // when comparing privileges, the order of the privileges doesn't matter. + // when comparing privileges, the order of the features doesn't matter (but the order of the privileges does) // supertest uses assert.deepStrictEqual. // expect.js doesn't help us here. // and lodash's isEqual doesn't know how to compare Sets. const success = isEqual(res.body, expected, (value, other, key) => { if (Array.isArray(value) && Array.isArray(other)) { - return isEqual(value.sort(), other.sort()); + if (key === 'reserved') { + // order does not matter for the reserved privilege set. + return isEqual(value.sort(), other.sort()); + } + // order matters for the rest, as the UI assumes they are returned in a descending order of permissiveness. + return isEqual(value, other); } // Lodash types aren't correct, `undefined` should be supported as a return value here and it @@ -71,5 +78,70 @@ export default function({ getService }: FtrProviderContext) { .expect(200); }); }); + + describe('GET /api/security/privileges?includeActions=true', () => { + // The UI assumes that no wildcards are present when calculating the effective set of privileges. + // If this changes, then the "privilege calculators" will need revisiting to account for these wildcards. + it('should return a privilege map with actions which do not include wildcards', async () => { + await supertest + .get('/api/security/privileges?includeActions=true') + .set('kbn-xsrf', 'xxx') + .send() + .expect(200) + .expect((res: any) => { + const { features, global, space, reserved } = res.body as RawKibanaPrivileges; + expect(features).to.be.an('object'); + expect(global).to.be.an('object'); + expect(space).to.be.an('object'); + expect(reserved).to.be.an('object'); + + Object.entries(features).forEach(([featureId, featurePrivs]) => { + Object.values(featurePrivs).forEach(actions => { + expect(actions).to.be.an('array'); + actions.forEach(action => { + expect(action).to.be.a('string'); + expect(action.indexOf('*')).to.eql( + -1, + `Feature ${featureId} with action ${action} cannot contain a wildcard` + ); + }); + }); + }); + + Object.entries(global).forEach(([privilegeId, actions]) => { + expect(actions).to.be.an('array'); + actions.forEach(action => { + expect(action).to.be.a('string'); + expect(action.indexOf('*')).to.eql( + -1, + `Global privilege ${privilegeId} with action ${action} cannot contain a wildcard` + ); + }); + }); + + Object.entries(space).forEach(([privilegeId, actions]) => { + expect(actions).to.be.an('array'); + actions.forEach(action => { + expect(action).to.be.a('string'); + expect(action.indexOf('*')).to.eql( + -1, + `Space privilege ${privilegeId} with action ${action} cannot contain a wildcard` + ); + }); + }); + + Object.entries(reserved).forEach(([privilegeId, actions]) => { + expect(actions).to.be.an('array'); + actions.forEach(action => { + expect(action).to.be.a('string'); + expect(action.indexOf('*')).to.eql( + -1, + `Reserved privilege ${privilegeId} with action ${action} cannot contain a wildcard` + ); + }); + }); + }); + }); + }); }); } diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts new file mode 100644 index 0000000000000..0b29fc1cac7de --- /dev/null +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -0,0 +1,75 @@ +/* + * 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 util from 'util'; +import { isEqual } from 'lodash'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('Privileges', () => { + describe('GET /api/security/privileges', () => { + it('should return a privilege map with all known privileges, without actions', async () => { + // If you're adding a privilege to the following, that's great! + // If you're removing a privilege, this breaks backwards compatibility + // Roles are associated with these privileges, and we shouldn't be removing them in a minor version. + const expected = { + features: { + discover: ['all', 'read'], + visualize: ['all', 'read'], + dashboard: ['all', 'read'], + dev_tools: ['all', 'read'], + advancedSettings: ['all', 'read'], + indexPatterns: ['all', 'read'], + savedObjectsManagement: ['all', 'read'], + timelion: ['all', 'read'], + graph: ['all', 'read'], + maps: ['all', 'read'], + canvas: ['all', 'read'], + infrastructure: ['all', 'read'], + logs: ['all', 'read'], + uptime: ['all', 'read'], + apm: ['all', 'read'], + siem: ['all', 'read'], + endpoint: ['all', 'read'], + ingestManager: ['all', 'read'], + }, + global: ['all', 'read'], + space: ['all', 'read'], + reserved: ['ml', 'monitoring'], + }; + + await supertest + .get('/api/security/privileges') + .set('kbn-xsrf', 'xxx') + .send() + .expect(200) + .expect((res: any) => { + // when comparing privileges, the order of the privileges doesn't matter. + // supertest uses assert.deepStrictEqual. + // expect.js doesn't help us here. + // and lodash's isEqual doesn't know how to compare Sets. + const success = isEqual(res.body, expected, (value, other, key) => { + if (Array.isArray(value) && Array.isArray(other)) { + return isEqual(value.sort(), other.sort()); + } + + // Lodash types aren't correct, `undefined` should be supported as a return value here and it + // has special meaning. + return undefined as any; + }); + + if (!success) { + throw new Error( + `Expected ${util.inspect(res.body)} to equal ${util.inspect(expected)}` + ); + } + }) + .expect(200); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/security/security_basic.ts b/x-pack/test/api_integration/apis/security/security_basic.ts new file mode 100644 index 0000000000000..dcbdb17724249 --- /dev/null +++ b/x-pack/test/api_integration/apis/security/security_basic.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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ loadTestFile }: FtrProviderContext) { + describe('security (basic license)', function() { + this.tags('ciGroup6'); + + // Updates here should be mirrored in `./index.js` if tests + // should also run under a trial/platinum license. + + loadTestFile(require.resolve('./basic_login')); + loadTestFile(require.resolve('./builtin_es_privileges')); + loadTestFile(require.resolve('./change_password')); + loadTestFile(require.resolve('./index_fields')); + loadTestFile(require.resolve('./roles')); + loadTestFile(require.resolve('./privileges_basic')); + loadTestFile(require.resolve('./session')); + }); +} diff --git a/x-pack/test/api_integration/apis/siem/saved_objects/timeline.ts b/x-pack/test/api_integration/apis/siem/saved_objects/timeline.ts index a6ced270e2132..a7e7cf4476f3f 100644 --- a/x-pack/test/api_integration/apis/siem/saved_objects/timeline.ts +++ b/x-pack/test/api_integration/apis/siem/saved_objects/timeline.ts @@ -175,7 +175,7 @@ export default function({ getService }: FtrProviderContext) { expect(version).to.not.be.empty(); }); - it('Update a timeline with a new title', async () => { + it.skip('Update a timeline with a new title', async () => { const titleToSaved = 'hello title'; const response = await createBasicTimeline(client, titleToSaved); const { savedObjectId, version } = response.data && response.data.persistTimeline.timeline; @@ -192,7 +192,7 @@ export default function({ getService }: FtrProviderContext) { }, }); - expect(responseToTest.data!.persistTimeline.timeline.savedObjectId).to.be(savedObjectId); + expect(responseToTest.data!.persistTimeline.timeline.savedObjectId).to.eql(savedObjectId); expect(responseToTest.data!.persistTimeline.timeline.title).to.be(newTitle); expect(responseToTest.data!.persistTimeline.timeline.version).to.not.be.eql(version); }); diff --git a/x-pack/test/api_integration/config_security_basic.js b/x-pack/test/api_integration/config_security_basic.js index c427bf7fa8f28..d21bfa4d7031a 100644 --- a/x-pack/test/api_integration/config_security_basic.js +++ b/x-pack/test/api_integration/config_security_basic.js @@ -14,7 +14,7 @@ export default async function({ readConfigFile }) { 'xpack.license.self_generated.type=basic', 'xpack.security.enabled=true', ]; - config.testFiles = [require.resolve('./apis/security')]; + config.testFiles = [require.resolve('./apis/security/security_basic')]; return config; }); } diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts index b966d37becc3f..de68ec0c64c17 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts @@ -338,6 +338,115 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); }); + describe('global dashboard read-only with url_create privileges', () => { + before(async () => { + await security.role.create('global_dashboard_read_url_create_role', { + elasticsearch: { + indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], + }, + kibana: [ + { + feature: { + dashboard: ['read', 'url_create'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('global_dashboard_read_url_create_user', { + password: 'global_dashboard_read_url_create_user-password', + roles: ['global_dashboard_read_url_create_role'], + full_name: 'test user', + }); + + await PageObjects.security.login( + 'global_dashboard_read_url_create_user', + 'global_dashboard_read_url_create_user-password', + { + expectSpaceSelector: false, + } + ); + }); + + after(async () => { + await security.role.delete('global_dashboard_read_url_create_role'); + await security.user.delete('global_dashboard_read_url_create_user'); + }); + + it('shows dashboard navlink', async () => { + const navLinks = (await appsMenu.readLinks()).map(link => link.text); + expect(navLinks).to.eql(['Dashboard', 'Management']); + }); + + it(`landing page doesn't show "Create new Dashboard" button`, async () => { + await PageObjects.common.navigateToActualUrl( + 'kibana', + DashboardConstants.LANDING_PAGE_PATH, + { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + } + ); + await testSubjects.existOrFail('dashboardLandingPage', { timeout: 10000 }); + await testSubjects.missingOrFail('newItemButton'); + }); + + it(`shows read-only badge`, async () => { + await globalNav.badgeExistsOrFail('Read only'); + }); + + it(`create new dashboard redirects to the home page`, async () => { + await PageObjects.common.navigateToActualUrl( + 'kibana', + DashboardConstants.CREATE_NEW_DASHBOARD_URL, + { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + } + ); + await testSubjects.existOrFail('homeApp', { timeout: 20000 }); + }); + + it(`can view existing Dashboard`, async () => { + await PageObjects.common.navigateToActualUrl('kibana', createDashboardEditUrl('i-exist'), { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + }); + await testSubjects.existOrFail('embeddablePanelHeading-APie', { timeout: 10000 }); + }); + + it(`Permalinks shows create short-url button`, async () => { + await PageObjects.share.openShareMenuItem('Permalinks'); + await PageObjects.share.createShortUrlExistOrFail(); + }); + + it('allows loading a saved query via the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:200'); + }); + + it('does not allow saving via the saved query management component popover with no query loaded', async () => { + await savedQueryManagementComponent.saveNewQueryMissingOrFail(); + }); + + it('does not allow saving changes to saved query from the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQueryMissingOrFail(); + }); + + it('does not allow deleting a saved query from the saved query management component', async () => { + await savedQueryManagementComponent.deleteSavedQueryMissingOrFail('OKJpgs'); + }); + + it('allows clearing the currently loaded saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + }); + }); + describe('no dashboard privileges', () => { before(async () => { await security.role.create('no_dashboard_privileges_role', { diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts index 98ab4c1f15a54..dc8c488460100 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_security.ts @@ -221,6 +221,97 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); }); + describe('global discover read-only privileges with url_create', () => { + before(async () => { + await security.role.create('global_discover_read_url_create_role', { + elasticsearch: { + indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], + }, + kibana: [ + { + feature: { + discover: ['read', 'url_create'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('global_discover_read_url_create_user', { + password: 'global_discover_read_url_create_user-password', + roles: ['global_discover_read_url_create_role'], + full_name: 'test user', + }); + + await PageObjects.security.login( + 'global_discover_read_url_create_user', + 'global_discover_read_url_create_user-password', + { + expectSpaceSelector: false, + } + ); + }); + + after(async () => { + await security.user.delete('global_discover_read_url_create_user'); + await security.role.delete('global_discover_read_url_create_role'); + }); + + it('shows discover navlink', async () => { + const navLinks = (await appsMenu.readLinks()).map(link => link.text); + expect(navLinks).to.eql(['Discover', 'Management']); + }); + + it(`doesn't show save button`, async () => { + await PageObjects.common.navigateToApp('discover'); + await testSubjects.existOrFail('discoverNewButton', { timeout: 10000 }); + await testSubjects.missingOrFail('discoverSaveButton'); + }); + + it(`shows read-only badge`, async () => { + await globalNav.badgeExistsOrFail('Read only'); + }); + + it(`doesn't show visualize button`, async () => { + await PageObjects.common.navigateToApp('discover'); + await setDiscoverTimeRange(); + await PageObjects.discover.clickFieldListItem('bytes'); + await PageObjects.discover.expectMissingFieldListItemVisualize('bytes'); + }); + + it('Permalinks shows create short-url button', async () => { + await PageObjects.share.openShareMenuItem('Permalinks'); + await PageObjects.share.createShortUrlExistOrFail(); + // close the menu + await PageObjects.share.clickShareTopNavButton(); + }); + + it('allows loading a saved query via the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:200'); + }); + + it('does not allow saving via the saved query management component popover with no query loaded', async () => { + await savedQueryManagementComponent.saveNewQueryMissingOrFail(); + }); + + it('does not allow saving changes to saved query from the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQueryMissingOrFail(); + }); + + it('does not allow deleting a saved query from the saved query management component', async () => { + await savedQueryManagementComponent.deleteSavedQueryMissingOrFail('OKJpgs'); + }); + + it('allows clearing the currently loaded saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + }); + }); + describe('discover and visualize privileges', () => { before(async () => { await security.role.create('global_discover_visualize_read_role', { diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 5768e51ae5f9f..be7a2faae6711 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -21,6 +21,10 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { ]); const find = getService('find'); const dashboardAddPanel = getService('dashboardAddPanel'); + const elasticChart = getService('elasticChart'); + const browser = getService('browser'); + const testSubjects = getService('testSubjects'); + const filterBar = getService('filterBar'); async function assertExpectedMetric() { await PageObjects.lens.assertExactText( @@ -41,6 +45,29 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { ); } + async function assertExpectedChart() { + await PageObjects.lens.assertExactText( + '[data-test-subj="embeddablePanelHeading-lnsXYvis"]', + 'lnsXYvis' + ); + } + + async function assertExpectedTimerange() { + const time = await PageObjects.timePicker.getTimeConfig(); + expect(time.start).to.equal('Sep 21, 2015 @ 09:00:00.000'); + expect(time.end).to.equal('Sep 21, 2015 @ 12:00:00.000'); + } + + async function clickOnBarHistogram() { + const el = await elasticChart.getCanvas(); + + await browser + .getActions() + .move({ x: 5, y: 5, origin: el._webElement }) + .click() + .perform(); + } + describe('lens smokescreen tests', () => { it('should allow editing saved visualizations', async () => { await PageObjects.visualize.gotoVisualizationLandingPage(); @@ -49,7 +76,7 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { await assertExpectedMetric(); }); - it('should be embeddable in dashboards', async () => { + it('metric should be embeddable in dashboards', async () => { await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.clickNewDashboard(); await dashboardAddPanel.clickOpenAddPanel(); @@ -59,6 +86,22 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { await assertExpectedMetric(); }); + it('click on the bar in XYChart adds proper filters/timerange', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await dashboardAddPanel.clickOpenAddPanel(); + await find.clickByButtonText('lnsXYvis'); + await dashboardAddPanel.closeAddPanel(); + await PageObjects.lens.goToTimeRange(); + await clickOnBarHistogram(); + await testSubjects.click('applyFiltersPopoverButton'); + + await assertExpectedChart(); + await assertExpectedTimerange(); + const hasIpFilter = await filterBar.hasFilter('ip', '97.220.3.248'); + expect(hasIpFilter).to.be(true); + }); + it('should allow seamless transition to and from table view', async () => { await PageObjects.visualize.gotoVisualizationLandingPage(); await PageObjects.lens.clickVisualizeListItemTitle('Artistpreviouslyknownaslens'); diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts index e5b6512d1c1b0..9f080a056e91f 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_security.ts @@ -276,6 +276,113 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); }); + describe('global visualize read-only with url_create privileges', () => { + before(async () => { + await security.role.create('global_visualize_read_url_create_role', { + elasticsearch: { + indices: [{ names: ['logstash-*'], privileges: ['read', 'view_index_metadata'] }], + }, + kibana: [ + { + feature: { + visualize: ['read', 'url_create'], + }, + spaces: ['*'], + }, + ], + }); + + await security.user.create('global_visualize_read_url_create_user', { + password: 'global_visualize_read_url_create_user-password', + roles: ['global_visualize_read_url_create_role'], + full_name: 'test user', + }); + + await PageObjects.security.login( + 'global_visualize_read_url_create_user', + 'global_visualize_read_url_create_user-password', + { + expectSpaceSelector: false, + } + ); + }); + + after(async () => { + await PageObjects.security.forceLogout(); + await security.role.delete('global_visualize_read_url_create_role'); + await security.user.delete('global_visualize_read_url_create_user'); + }); + + it('shows visualize navlink', async () => { + const navLinks = (await appsMenu.readLinks()).map(link => link.text); + expect(navLinks).to.eql(['Visualize', 'Management']); + }); + + it(`landing page shows "Create new Visualization" button`, async () => { + await PageObjects.visualize.gotoVisualizationLandingPage(); + await testSubjects.existOrFail('visualizeLandingPage', { timeout: 10000 }); + await testSubjects.existOrFail('newItemButton'); + }); + + it(`shows read-only badge`, async () => { + await globalNav.badgeExistsOrFail('Read only'); + }); + + it(`can view existing Visualization`, async () => { + await PageObjects.common.navigateToActualUrl('visualize', '/visualize/edit/i-exist', { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + }); + await testSubjects.existOrFail('visualizationLoader', { timeout: 10000 }); + }); + + it(`can't save existing Visualization`, async () => { + await PageObjects.common.navigateToActualUrl('visualize', '/visualize/edit/i-exist', { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + }); + await testSubjects.existOrFail('shareTopNavButton', { timeout: 10000 }); + await testSubjects.missingOrFail('visualizeSaveButton', { timeout: 10000 }); + }); + + it('Embed code shows create short-url button', async () => { + await PageObjects.share.openShareMenuItem('Embedcode'); + await PageObjects.share.createShortUrlExistOrFail(); + }); + + it('Permalinks shows create short-url button', async () => { + await PageObjects.share.openShareMenuItem('Permalinks'); + await PageObjects.share.createShortUrlExistOrFail(); + // close menu + await PageObjects.share.clickShareTopNavButton(); + }); + + it('allows loading a saved query via the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + const queryString = await queryBar.getQueryString(); + expect(queryString).to.eql('response:200'); + }); + + it('does not allow saving via the saved query management component popover with no query loaded', async () => { + await savedQueryManagementComponent.saveNewQueryMissingOrFail(); + }); + + it('does not allow saving changes to saved query from the saved query management component', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQueryMissingOrFail(); + }); + + it('does not allow deleting a saved query from the saved query management component', async () => { + await savedQueryManagementComponent.deleteSavedQueryMissingOrFail('OKJpgs'); + }); + + it('allows clearing the currently loaded saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OKJpgs'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + }); + }); + describe('no visualize privileges', () => { before(async () => { await security.role.create('no_visualize_privileges_role', { diff --git a/x-pack/test/functional/es_archives/lens/basic/data.json.gz b/x-pack/test/functional/es_archives/lens/basic/data.json.gz index a5079d92e77f0..4ed7c29f7391e 100644 Binary files a/x-pack/test/functional/es_archives/lens/basic/data.json.gz and b/x-pack/test/functional/es_archives/lens/basic/data.json.gz differ diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts index 7e5825d88ec13..beedd6390388d 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts @@ -185,6 +185,108 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ]); }); + it('should set an alert throttle', async () => { + const alertName = `edit throttle ${generateUniqueKey()}`; + const createdAlert = await createAlert({ + alertTypeId: '.index-threshold', + name: alertName, + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000, 5000], + index: ['.kibana_1'], + timeField: 'alert', + }, + }); + await pageObjects.common.navigateToApp('triggersActions'); + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults).to.eql([ + { + name: createdAlert.name, + tagsText: 'foo, bar', + alertType: 'Index Threshold', + interval: '1m', + }, + ]); + + const editLink = await testSubjects.findAll('alertsTableCell-editLink'); + await editLink[0].click(); + + const throttleInputToSetInitialValue = await testSubjects.find('throttleInput'); + await throttleInputToSetInitialValue.click(); + await throttleInputToSetInitialValue.clearValue(); + await throttleInputToSetInitialValue.type('1'); + + await find.clickByCssSelector('[data-test-subj="saveEditedAlertButton"]:not(disabled)'); + + expect(await pageObjects.common.closeToast()).to.eql(`Updated '${createdAlert.name}'`); + + await pageObjects.common.navigateToApp('triggersActions'); + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + await (await testSubjects.findAll('alertsTableCell-editLink'))[0].click(); + const throttleInput = await testSubjects.find('throttleInput'); + expect(await throttleInput.getAttribute('value')).to.eql('1'); + }); + + it('should unset an alert throttle', async () => { + const alertName = `edit throttle ${generateUniqueKey()}`; + const createdAlert = await createAlert({ + alertTypeId: '.index-threshold', + name: alertName, + throttle: '10m', + params: { + aggType: 'count', + termSize: 5, + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + groupBy: 'all', + threshold: [1000, 5000], + index: ['.kibana_1'], + timeField: 'alert', + }, + }); + await pageObjects.common.navigateToApp('triggersActions'); + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults).to.eql([ + { + name: createdAlert.name, + tagsText: 'foo, bar', + alertType: 'Index Threshold', + interval: '1m', + }, + ]); + + const editLink = await testSubjects.findAll('alertsTableCell-editLink'); + await editLink[0].click(); + + const throttleInputToUnsetValue = await testSubjects.find('throttleInput'); + + expect(await throttleInputToUnsetValue.getAttribute('value')).to.eql('10'); + await throttleInputToUnsetValue.click(); + await throttleInputToUnsetValue.clearValueWithKeyboard(); + + expect(await throttleInputToUnsetValue.getAttribute('value')).to.eql(''); + + await find.clickByCssSelector('[data-test-subj="saveEditedAlertButton"]:not(disabled)'); + + expect(await pageObjects.common.closeToast()).to.eql(`Updated '${createdAlert.name}'`); + + await pageObjects.common.navigateToApp('triggersActions'); + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + await (await testSubjects.findAll('alertsTableCell-editLink'))[0].click(); + const throttleInput = await testSubjects.find('throttleInput'); + expect(await throttleInput.getAttribute('value')).to.eql(''); + }); + it('should reset alert when canceling an edit', async () => { const createdAlert = await createAlert({ alertTypeId: '.index-threshold', diff --git a/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/index.js b/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/index.js index 6110996a553dc..89ae0125614b6 100644 --- a/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/index.js +++ b/x-pack/test/ui_capabilities/common/fixtures/plugins/foo_plugin/index.js @@ -28,6 +28,8 @@ export default function(kibana) { catalogue: ['foo'], privileges: { all: { + app: ['kibana'], + catalogue: ['foo'], savedObject: { all: ['foo'], read: ['index-pattern'], @@ -35,6 +37,8 @@ export default function(kibana) { ui: ['create', 'edit', 'delete', 'show'], }, read: { + app: ['kibana'], + catalogue: ['foo'], savedObject: { all: [], read: ['foo', 'index-pattern'], diff --git a/yarn.lock b/yarn.lock index bd9a764cfdb22..3997cfbaeca6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1915,10 +1915,10 @@ once "^1.4.0" pump "^3.0.0" -"@elastic/ems-client@7.7.0": - version "7.7.0" - resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-7.7.0.tgz#7d36d716dd941f060b9fcdae94f186a9aecc5cc2" - integrity sha512-JatsSyLik/8MTEOEimzEZ3NYjvGL1YzjbGujuSCgaXhPRqzu/wvMLEL8dlVpmYFZ7ALbGNsVdho4Hr8tngsIMw== +"@elastic/ems-client@7.7.1": + version "7.7.1" + resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-7.7.1.tgz#cda9277cb851b6d1aa0408fe2814de98f1474fb8" + integrity sha512-8ikEUbsM+wxENqi/cwrmo4+2vwZkVoFDPSIrw3bQG2mQaE3l+3w1eMPKxsvQq0r79ivzXJ51gkvr8CffBkBkGw== dependencies: lodash "^4.17.15" node-fetch "^1.7.3" @@ -2416,257 +2416,284 @@ "@types/yargs" "^15.0.0" chalk "^3.0.0" -"@jimp/bmp@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/bmp/-/bmp-0.8.4.tgz#3246e0c6b073b3e2d9b61075ac0146d9124c9277" - integrity sha512-Cf/V+SUyEVxCCP8q1emkarCHJ8NkLFcLp41VMqBihoR4ke0TIPfCSdgW/JXbM/28vvZ5a2bvMe6uOll6cFggvA== +"@jimp/bmp@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/bmp/-/bmp-0.9.6.tgz#379e261615d7c1f3b52af4d5a0f324666de53d7d" + integrity sha512-T2Fh/k/eN6cDyOx0KQ4y56FMLo8+mKNhBh7GXMQXLK2NNZ0ckpFo3VHDBZ3HnaFeVTZXF/atLiR9CfnXH+rLxA== dependencies: - "@jimp/utils" "^0.8.4" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" bmp-js "^0.1.0" - core-js "^2.5.7" + core-js "^3.4.1" -"@jimp/core@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/core/-/core-0.8.4.tgz#fbdb3cb0823301381736e988674f14c282dc5c63" - integrity sha512-3fK5UEOEQsfSDhsrAgBT6W8Up51qkeCj9RVjusxUaEGmix34PO/KTVfzURlu6NOpOUvtfNXsCq9xS7cxBTWSCA== +"@jimp/core@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/core/-/core-0.9.6.tgz#a553f801bd436526d36e8982b99e58e8afc3d17a" + integrity sha512-sQO04S+HZNid68a9ehb4BC2lmW6iZ5JgU9tC+thC2Lhix+N/XKDJcBJ6HevbLgeTzuIAw24C5EKuUeO3C+rE5w== dependencies: - "@jimp/utils" "^0.8.4" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" any-base "^1.1.0" buffer "^5.2.0" - core-js "^2.5.7" + core-js "^3.4.1" exif-parser "^0.1.12" file-type "^9.0.0" load-bmfont "^1.3.1" - mkdirp "0.5.1" + mkdirp "^0.5.1" phin "^2.9.1" pixelmatch "^4.0.2" tinycolor2 "^1.4.1" -"@jimp/custom@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/custom/-/custom-0.8.4.tgz#abd61281ce12194ae23046ee71d60b754b515bc8" - integrity sha512-iS/RB3QQKpm4QS8lxxtQzvYDMph9YvOn3d68gMM4pDKn95n3nt5/ySHFv6fQq/yzfox1OPdeYaXbOLvC3+ofqw== +"@jimp/custom@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/custom/-/custom-0.9.6.tgz#3d8a19d6ed717f0f1aa3f1b8f42fa374f43bc715" + integrity sha512-ZYKgrBZVoQwvIGlQSO7MFmn7Jn8a9X5g1g+KOTDO9Q0s4vnxdPTtr/qUjG9QYX6zW/6AK4LaIsDinDrrKDnOag== dependencies: - "@jimp/core" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/core" "^0.9.6" + core-js "^3.4.1" -"@jimp/gif@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/gif/-/gif-0.8.4.tgz#1429a71ed3b055f73d63c9b195fa7f0a46e947b5" - integrity sha512-YpHZ7aWzmrviY7YigXRolHs6oBhGJItRry8fh3zebAgKth06GMv58ce84yXXOKX4yQ+QGd6GgOWzePx+KMP9TA== +"@jimp/gif@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/gif/-/gif-0.9.6.tgz#0a7b1e521daca635b02259f941bdb3600569d8e6" + integrity sha512-Z2muC2On8KHEVrWKCCM0L2eua9kw4bQETzT7gmVsizc8MXAKdS8AyVV9T3ZrImiI0o5UkAN/u0cPi1U2pSiD8Q== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" omggif "^1.0.9" -"@jimp/jpeg@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/jpeg/-/jpeg-0.8.4.tgz#effde867116f88f59ac20b44b1a526b11caca026" - integrity sha512-7exKk3LNPKJgsFzUPL+mOJtIEHcLp6yU9sVbULffVDjVUun6/Are2tCX8rCXZq28yiUhofzr61k5UqjkKFJXrA== +"@jimp/jpeg@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/jpeg/-/jpeg-0.9.6.tgz#fb90bdc0111966987c5ba59cdca7040be86ead41" + integrity sha512-igSe0pIX3le/CKdvqW4vLXMxoFjTLjEaW6ZHt/h63OegaEa61TzJ2OM7j7DxrEHcMCMlkhUc9Bapk57MAefCTQ== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" jpeg-js "^0.3.4" -"@jimp/plugin-blit@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-blit/-/plugin-blit-0.8.4.tgz#991b4199cc5506f0faae22b821b14ec93fbce1bb" - integrity sha512-H9bpetmOUgEHpkDSRzbXLMXQhr34i8YicYV3EDeuHU8mKlAjtMbVpbp5ZN4mcadTz+EYdTdVNfQNsRCcIb5Oeg== +"@jimp/plugin-blit@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-blit/-/plugin-blit-0.9.6.tgz#7937e02e3514b95dbe4c70d444054847f6e9ce3c" + integrity sha512-zp7X6uDU1lCu44RaSY88aAvsSKbgqUrfDyWRX1wsamJvvZpRnp1WekWlGyydRtnlUBAGIpiHCHmyh/TJ2I4RWA== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-blur@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-blur/-/plugin-blur-0.8.4.tgz#460f79c45eda7f24adf624a691134d6192d3dbb4" - integrity sha512-gvEDWW7+MI9Hk1KKzuFliRdDPaofkxB4pRJ/n1hipDoOGcNYFqxx5FGNQ4wsGSDpQ+RiHZF+JGKKb+EIwHg+0Q== +"@jimp/plugin-blur@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-blur/-/plugin-blur-0.9.6.tgz#3d74b18c27e9eae11b956ffe26290ece6d250813" + integrity sha512-xEi63hvzewUp7kzw+PI3f9CIrgZbphLI4TDDHWNYuS70RvhTuplbR6RMHD/zFhosrANCkJGr5OZJlrJnsCg6ug== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-color@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-color/-/plugin-color-0.8.4.tgz#a9aa525421ea50bf2c1baec7618f73b7e4fc3464" - integrity sha512-DHCGMxInCI1coXMIfdZJ5G/4hpt5yZLNB5+oUIxT4aClzyhUjqD4xOcnO7hlPY6LuX8+FX7cYMHhdMfhTXB3Dg== +"@jimp/plugin-color@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-color/-/plugin-color-0.9.6.tgz#d0fca0ed4c2c48fd6f929ef4a03cebf9c1342e14" + integrity sha512-o1HSoqBVUUAsWbqSXnpiHU0atKWy/Q1GUbZ3F5GWt/0OSDyl9RWM82V9axT2vePZHInKjIaimhnx1gGj8bfxkQ== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" tinycolor2 "^1.4.1" -"@jimp/plugin-contain@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-contain/-/plugin-contain-0.8.4.tgz#2db8c12de910490cd74f339e9414a968b8c9328e" - integrity sha512-3wwLXig5LkOMg5FrNZrX/r99ehaA+0s3dkro3CiRg0Ez6Y0fz067so+HdsmqmoG78WY/dCdgdps/xLOW2VV4DQ== +"@jimp/plugin-contain@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-contain/-/plugin-contain-0.9.6.tgz#7d7bbd5e9c2fa4391a3d63620e13a28f51e1e7e8" + integrity sha512-Xz467EN1I104yranET4ff1ViVKMtwKLg1uRe8j3b5VOrjtiXpDbjirNZjP3HTlv8IEUreWNz4BK7ZtfHSptufA== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-cover@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-cover/-/plugin-cover-0.8.4.tgz#a09bfbbe88129754ca35e281707bc5ed3f3f0c63" - integrity sha512-U0xmSfGLmw0Ieiw00CM8DQ+XoQVBxbjsLE5To8EejnyLx5X+oNZ8r7E5EsQaushUlzij95IqMCloo+nCGhdYMw== +"@jimp/plugin-cover@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-cover/-/plugin-cover-0.9.6.tgz#2853de7f8302f655ae8e95f51ab25a0ed77e3756" + integrity sha512-Ocr27AvtvH4ZT/9EWZgT3+HQV9fG5njwh2CYMHbdpx09O62Asj6pZ4QI0kKzOcux1oLgv59l7a93pEfMOfkfwQ== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-crop@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-crop/-/plugin-crop-0.8.4.tgz#ca5bd359c4e4b2374777bae6130e8b94552932fa" - integrity sha512-Neqs0K4cr7SU9nSte2qvGVh/8+K9ArH8mH1fWhZw4Zq8qD9NicX+g5hqmpmeSjOKD73t/jOmwvBevfJDu2KKSA== +"@jimp/plugin-crop@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-crop/-/plugin-crop-0.9.6.tgz#82e539af2a2417783abbd143124a57672ff4cc31" + integrity sha512-d9rNdmz3+eYLbSKcTyyp+b8Nmhf6HySnimDXlTej4UP6LDtkq2VAyVaJ12fz9x6dfd8qcXOBXMozSfNCcgpXYA== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-displace@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-displace/-/plugin-displace-0.8.4.tgz#c6d5cff889e52cb64194979967e6bd7fff4d5d1b" - integrity sha512-qKCwAP2lAO3R8ofYaEF/Gh+sfcjzZLtEiYHzjx/mYvPpXS6Yvkvl28aUH8pwdJYT+QYGelHmOne0RJvjsac1NQ== +"@jimp/plugin-displace@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-displace/-/plugin-displace-0.9.6.tgz#67564d081dc6b19007248ca222d025fd6f90c03b" + integrity sha512-SWpbrxiHmUYBVWtDDMjaG3eRDBASrTPaad7l07t73/+kmU6owAKWQW6KtVs05MYSJgXz7Ggdr0fhEn9AYLH1Rg== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-dither@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-dither/-/plugin-dither-0.8.4.tgz#a2320d6a8c467cf7697109e0c5ed4ee3d3898b73" - integrity sha512-19+y5VAO6d0keRne9eJCdOeB9X0LFuRdRSjgwl/57JtREeoPj+iKBg6REBl4atiSGd7/UCFg3wRtFOw24XFKgw== +"@jimp/plugin-dither@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-dither/-/plugin-dither-0.9.6.tgz#dc48669cf51f3933761aa9137e99ebfa000b8cce" + integrity sha512-abm1GjfYK7ru/PoxH9fAUmhl+meHhGEClbVvjjMMe5g2S0BSTvMJl3SrkQD/FMkRLniaS/Qci6aQhIi+8rZmSw== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-flip@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-flip/-/plugin-flip-0.8.4.tgz#08bf46470c3c0b4890691f554c28ccf17813746f" - integrity sha512-1BtKtc8cANuGgiWyOmltQZaR3Y5Og/GS/db8wBpFNLJ33Ir5UAGN2raDtx4EYEd5okuRVFj3OP+wAZl69m72LQ== +"@jimp/plugin-flip@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-flip/-/plugin-flip-0.9.6.tgz#f81f9b886da8cd56e23dd04d5aa359f2b94f939e" + integrity sha512-KFZTzAzQQ5bct3ii7gysOhWrTKVdUOghkkoSzLi+14nO3uS/dxiu8fPeH1m683ligbdnuM/b22OuLwEwrboTHA== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-gaussian@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-gaussian/-/plugin-gaussian-0.8.4.tgz#f3be12c5f16c5670959ab711e69b2963f66f7b4f" - integrity sha512-qYcVmiJn8l8uDZqk4FlB/qTV8fJgiJAh/xc/WKNEp2E8qFEgxoIPeimPHO8cJorEHqlh8I8l24OZkTkkEKaFfw== +"@jimp/plugin-gaussian@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-gaussian/-/plugin-gaussian-0.9.6.tgz#6c93897ee0ff979466184d7d0ec0fbc95c679be4" + integrity sha512-WXKLtJKWchXfWHT5HIOq1HkPKpbH7xBLWPgVRxw00NV/6I8v4xT63A7/Nag78m00JgjwwiE7eK2tLGDbbrPYig== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-invert@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-invert/-/plugin-invert-0.8.4.tgz#fd4577beba2973f663164f5ee454b2172ca56b34" - integrity sha512-OQ/dFDbBUmEd935Gitl5Pmgz+nLVyszwS0RqL6+G1U9EHYBeiHDrmY2sj7NgDjDEJYlRLxGlBRsTIPHzF3tdNw== +"@jimp/plugin-invert@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-invert/-/plugin-invert-0.9.6.tgz#4b3fa7b81ea976b09b82b3db59ee00ac3093d2fd" + integrity sha512-Pab/cupZrYxeRp07N4L5a4C/3ksTN9k6Knm/o2G5C789OF0rYsGGLcnBR/6h69nPizRZHBYdXCEyXYgujlIFiw== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-mask@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-mask/-/plugin-mask-0.8.4.tgz#0dfe02a14530c3bddfc258e83bd3c979e53d15ef" - integrity sha512-uqLdRGShHwCd9RHv8bMntTfDNDI2pcEeE7+F868P6PngWLKrzQCpuAyTnK6WK0ZN95fSsgy7TzCoesYk+FchkQ== +"@jimp/plugin-mask@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-mask/-/plugin-mask-0.9.6.tgz#d70be0030ab3294b191f5b487fb655d776820b19" + integrity sha512-ikypRoDJkbxXlo6gW+EZOcTiLDIt0DrPwOFMt1bvL8UV2QPgX+GJ685IYwhIfEhBf/GSNFgB/NYsVvuSufTGeg== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-normalize@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-normalize/-/plugin-normalize-0.8.4.tgz#aa2c3131082b6ceef2fb6222323db9f7d837447c" - integrity sha512-+ihgQeVD8syWxw12F5ngUUdtlIcGDqH7hEoHcwVVGOFfaJqR4YBQR4FM3QLFFFdi2X/uK2nGJt9cMh0UaINEgw== +"@jimp/plugin-normalize@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-normalize/-/plugin-normalize-0.9.6.tgz#c9128412a53485d91236a1da241f3166e572be4a" + integrity sha512-V3GeuAJ1NeL7qsLoDjnypJq24RWDCwbXpKhtxB+Yg9zzgOCkmb041p7ysxbcpkuJsRpKLNABZeNCCqd83bRawA== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-print@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-print/-/plugin-print-0.8.4.tgz#c110d6e7632e3c9cf47ce395e36b0f3c1461a9ca" - integrity sha512-Wg5tZI3hW5DG9Caz4wg4ZolS3Lvv4MFAxORPAeWeahDpHs38XZ7ydJ0KR39p2oWJPP0yIFv1fETYpU7BiJPRRw== +"@jimp/plugin-print@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-print/-/plugin-print-0.9.6.tgz#fea31ffeafee18ae7b5cfd6fa131bb205abfee51" + integrity sha512-gKkqZZPQtMSufHOL0mtJm5d/KI2O6+0kUpOBVSYdGedtPXA61kmVnsOd3wwajIMlXA3E0bDxLXLdAguWqjjGgw== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" load-bmfont "^1.4.0" -"@jimp/plugin-resize@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-resize/-/plugin-resize-0.8.4.tgz#6690f50c98cfd89ac3682b58ba9623e7c46e0be6" - integrity sha512-z9tumvsQja/YFTSeGvofYLvVws8LZYLYVW8l17hBETzfZQdVEvPOdWKkXqsAsK5uY9m8M5rH7kR8NZbCDVbyzA== +"@jimp/plugin-resize@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-resize/-/plugin-resize-0.9.6.tgz#7fb939c8a42e2a3639d661cc7ab24057598693bd" + integrity sha512-r5wJcVII7ZWMuY2l6WSbHPG6gKMFemtCHmJRXGUu+/ZhPGBz3IFluycBpHkWW3OB+jfvuyv1EGQWHU50N1l8Og== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-rotate@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-rotate/-/plugin-rotate-0.8.4.tgz#bf3ea70d10123f1372507b74d06bfefb40b3e526" - integrity sha512-PVxpt3DjqaUnHP6Nd3tzZjl4SYe/FYXszGTshtx51AMuvZLnpvekrrclYyc7Dc1Ry3kx3ma6UuLCvmf85hrdmw== +"@jimp/plugin-rotate@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-rotate/-/plugin-rotate-0.9.6.tgz#06d725155e5cdb615133f57a52f5a860a9d03f3e" + integrity sha512-B2nm/eO2nbvn1DgmnzMd79yt3V6kffhRNrKoo2VKcKFiVze1vGP3MD3fVyw5U1PeqwAFu7oTICFnCf9wKDWSqg== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugin-scale@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugin-scale/-/plugin-scale-0.8.4.tgz#2de9cc80d49f6a36e4177b22e0ab1d4305331c2e" - integrity sha512-PrBTOMJ5n4gbIvRNxWfc1MdgHw4vd5r1UOHRVuc6ZQ9Z/FueBuvIidnz7GBRHbsRm3IjckvsLfEL1nIK0Kqh3A== +"@jimp/plugin-scale@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugin-scale/-/plugin-scale-0.9.6.tgz#3fa939c1a4f44383e12afeb7c434eb41f20e4a1c" + integrity sha512-DLsLB5S3mh9+TZY5ycwfLgOJvUcoS7bP0Mi3I8vE1J91qmA+TXoWFFgrIVgnEPw5jSKzNTt8WhykQ0x2lKXncw== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" -"@jimp/plugins@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/plugins/-/plugins-0.8.4.tgz#af24c0686aec327f3abcc50ba5bbae1df2113fb0" - integrity sha512-Vd0oCe0bj7c+crHL6ee178q2c1o50UnbCmc0imHYg7M+pY8S1kl4ubZWwkAg2W96FCarGrm9eqPvCUyAdFOi9w== - dependencies: - "@jimp/plugin-blit" "^0.8.4" - "@jimp/plugin-blur" "^0.8.4" - "@jimp/plugin-color" "^0.8.4" - "@jimp/plugin-contain" "^0.8.4" - "@jimp/plugin-cover" "^0.8.4" - "@jimp/plugin-crop" "^0.8.4" - "@jimp/plugin-displace" "^0.8.4" - "@jimp/plugin-dither" "^0.8.4" - "@jimp/plugin-flip" "^0.8.4" - "@jimp/plugin-gaussian" "^0.8.4" - "@jimp/plugin-invert" "^0.8.4" - "@jimp/plugin-mask" "^0.8.4" - "@jimp/plugin-normalize" "^0.8.4" - "@jimp/plugin-print" "^0.8.4" - "@jimp/plugin-resize" "^0.8.4" - "@jimp/plugin-rotate" "^0.8.4" - "@jimp/plugin-scale" "^0.8.4" - core-js "^2.5.7" +"@jimp/plugins@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/plugins/-/plugins-0.9.6.tgz#a1cfdf9f3e1adf5b124686486343888a16c8fd27" + integrity sha512-eQI29e+K+3L/fb5GbPgsBdoftvaYetSOO2RL5z+Gjk6R4EF4QFRo63YcFl+f72Kc1b0JTOoDxClvn/s5GMV0tg== + dependencies: + "@babel/runtime" "^7.7.2" + "@jimp/plugin-blit" "^0.9.6" + "@jimp/plugin-blur" "^0.9.6" + "@jimp/plugin-color" "^0.9.6" + "@jimp/plugin-contain" "^0.9.6" + "@jimp/plugin-cover" "^0.9.6" + "@jimp/plugin-crop" "^0.9.6" + "@jimp/plugin-displace" "^0.9.6" + "@jimp/plugin-dither" "^0.9.6" + "@jimp/plugin-flip" "^0.9.6" + "@jimp/plugin-gaussian" "^0.9.6" + "@jimp/plugin-invert" "^0.9.6" + "@jimp/plugin-mask" "^0.9.6" + "@jimp/plugin-normalize" "^0.9.6" + "@jimp/plugin-print" "^0.9.6" + "@jimp/plugin-resize" "^0.9.6" + "@jimp/plugin-rotate" "^0.9.6" + "@jimp/plugin-scale" "^0.9.6" + core-js "^3.4.1" timm "^1.6.1" -"@jimp/png@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/png/-/png-0.8.4.tgz#d150ddaaebafcda83d820390f62a4d3c409acc03" - integrity sha512-DLj260SwQr9ZNhSto1BacXGNRhIQiLNOESPoq5DGjbqiPCmYNxE7CPlXB1BVh0T3AmZBjnZkZORU0Y9wTi3gJw== +"@jimp/png@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/png/-/png-0.9.6.tgz#00ed7e6fb783b94f2f1a9fadf9a42bd75f70cc7f" + integrity sha512-9vhOG2xylcDqPbBf4lzpa2Sa1WNJrEZNGvPvWcM+XVhqYa8+DJBLYkoBlpI/qWIYA+eVWDnLF3ygtGj8CElICw== dependencies: - "@jimp/utils" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/utils" "^0.9.6" + core-js "^3.4.1" pngjs "^3.3.3" -"@jimp/tiff@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/tiff/-/tiff-0.8.4.tgz#bc18c32cef996ad986a92bb7d926d6abd1db9d10" - integrity sha512-SQmf1B/TbCtbwzJReLw/lzGqbeu8MOfT+wkaia0XWS72H6bEW66PTQKhB4/3uzC/Xnmsep1WNQITlwcWdgc36Q== +"@jimp/tiff@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/tiff/-/tiff-0.9.6.tgz#9ff12122e727ee15f27f40a710516102a636f66b" + integrity sha512-pKKEMqPzX9ak8mek2iVVoW34+h/TSWUyI4NjbYWJMQ2WExfuvEJvLocy9Q9xi6HqRuJmUxgNIiC5iZM1PDEEfg== dependencies: - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + core-js "^3.4.1" utif "^2.0.1" -"@jimp/types@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/types/-/types-0.8.4.tgz#01df00a5adb955cb4ba79df1288408faa3bb40ed" - integrity sha512-BCehQ5hrTOGDGdeROwXOYqgFGAzJPkuXmVJXgMgBoW1YjoGWhXJ5iShaJ/l7DRErrdezoWUdAhTFlV5bJf51dg== - dependencies: - "@jimp/bmp" "^0.8.4" - "@jimp/gif" "^0.8.4" - "@jimp/jpeg" "^0.8.4" - "@jimp/png" "^0.8.4" - "@jimp/tiff" "^0.8.4" - core-js "^2.5.7" +"@jimp/types@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/types/-/types-0.9.6.tgz#7be7f415ad93be733387c03b8a228c587a868a18" + integrity sha512-PSjdbLZ8d50En+Wf1XkWFfrXaf/GqyrxxgIwFWPbL+wrW4pmbYovfxSLCY61s8INsOFOft9dzzllhLBtg1aQ6A== + dependencies: + "@babel/runtime" "^7.7.2" + "@jimp/bmp" "^0.9.6" + "@jimp/gif" "^0.9.6" + "@jimp/jpeg" "^0.9.6" + "@jimp/png" "^0.9.6" + "@jimp/tiff" "^0.9.6" + core-js "^3.4.1" timm "^1.6.1" -"@jimp/utils@^0.8.4": - version "0.8.4" - resolved "https://registry.yarnpkg.com/@jimp/utils/-/utils-0.8.4.tgz#a6bbdc13dba99b95d4cabf0bde87b1bcd2230d25" - integrity sha512-6Cwplao7IgwhFRijMvvyjdV7Sa7Fw71vS1aDsUDCVpi3XHsiLUM+nPTno6OKjzg2z2EufuolWPEvuq/GSte4lA== +"@jimp/utils@^0.9.6": + version "0.9.6" + resolved "https://registry.yarnpkg.com/@jimp/utils/-/utils-0.9.6.tgz#a3e6c29e835e2b9ea9f3899c9d3d230dc63bd518" + integrity sha512-kzxcp0i4ecSdMXFEmtH+NYdBQysINEUTsrjm7v0zH8t/uwaEMOG46I16wo/iPBXJkUeNdL2rbXoGoxxoeSfrrA== dependencies: - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + core-js "^3.4.1" "@mapbox/extent@0.4.0": version "0.4.0" @@ -2734,10 +2761,10 @@ resolved "https://registry.yarnpkg.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz#ce56e539f83552b58d10d672ea4d6fc9adc7b234" integrity sha1-zlblOfg1UrWNENZy6k1vya3HsjQ= -"@mapbox/mapbox-gl-draw@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-draw/-/mapbox-gl-draw-1.1.1.tgz#b88a7919c8de04eb7946885e747e22049c3a3138" - integrity sha512-Xg+R0VUXKdXC7MaSSMiWfz96eLssJZa28/D6MxK/Xc19G5HvU6w/wytm8EeI28T7Sa2C7FII/0/XOwuAfJgDJw== +"@mapbox/mapbox-gl-draw@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-draw/-/mapbox-gl-draw-1.1.2.tgz#247b3f0727db34c2641ab718df5eebeee69a2585" + integrity sha512-DWtATUAnJaGZYoH/y2O+QTRybxrp5y3w3eV5FXHFNVcKsCAojKEMB8ALKUG2IsiCKqV/JCAguK9AlPWR7Bjafw== dependencies: "@mapbox/geojson-area" "^0.2.1" "@mapbox/geojson-extent" "^0.3.2" @@ -2748,7 +2775,7 @@ lodash.isequal "^4.2.0" xtend "^4.0.1" -"@mapbox/mapbox-gl-rtl-text@0.2.3": +"@mapbox/mapbox-gl-rtl-text@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@mapbox/mapbox-gl-rtl-text/-/mapbox-gl-rtl-text-0.2.3.tgz#a26ecfb3f0061456d93ee8570dd9587d226ea8bd" integrity sha512-RaCYfnxULUUUxNwcUimV9C/o2295ktTyLEUzD/+VWkqXqvaVfFcZ5slytGzb2Sd/Jj4MlbxD0DCZbfa6CzcmMw== @@ -4504,7 +4531,7 @@ dependencies: "@types/jquery" "*" -"@types/geojson@*": +"@types/geojson@*", "@types/geojson@7946.0.7": version "7946.0.7" resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.7.tgz#c8fa532b60a0042219cdf173ca21a975ef0666ad" integrity sha512-wE2v81i4C4Ol09RtsWFAqg3BUitWbHSpSlIo+bNdsCJijO9sjme+zm+73ZMCa/qMC8UEERxzGbvmr1cffo2SiQ== @@ -4832,10 +4859,10 @@ resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03" integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w== -"@types/mapbox-gl@^0.54.1": - version "0.54.3" - resolved "https://registry.yarnpkg.com/@types/mapbox-gl/-/mapbox-gl-0.54.3.tgz#6215fbf4dbb555d2ca6ce3be0b1de045eec0f967" - integrity sha512-/G06vUcV5ucNB7G9ka6J+VbGtffyUYvfe6A3oae/+csTlHIEHcvyJop3Ic4yeMDxycsQCmBvuwz+owseMuiQ3w== +"@types/mapbox-gl@^1.8.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@types/mapbox-gl/-/mapbox-gl-1.8.1.tgz#dbc12da1324d5bdb3dbf71b90b77cac17994a1a3" + integrity sha512-DdT/YzpGiYITkj2cUwyqPilPbtZURr1E0vZX0KTyyeNP0t0bxNyKoXo0seAcvUd2MsMgFYwFQh1WRC3x2V0kKQ== dependencies: "@types/geojson" "*" @@ -6037,6 +6064,13 @@ agent-base@4: dependencies: es6-promisify "^5.0.0" +agent-base@6: + version "6.0.0" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.0.tgz#5d0101f19bbfaed39980b22ae866de153b93f09a" + integrity sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw== + dependencies: + debug "4" + agent-base@^4.1.0: version "4.2.1" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9" @@ -6044,13 +6078,6 @@ agent-base@^4.1.0: dependencies: es6-promisify "^5.0.0" -agent-base@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" - integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== - dependencies: - es6-promisify "^5.0.0" - agentkeepalive@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-3.4.1.tgz#aa95aebc3a749bca5ed53e3880a09f5235b48f0c" @@ -10219,10 +10246,10 @@ core-js@^2.2.0, core-js@^2.4.0, core-js@^2.5.0, core-js@^2.5.1, core-js@^2.5.3, resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2" integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A== -core-js@^3.0.1, core-js@^3.0.4, core-js@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.2.1.tgz#cd41f38534da6cc59f7db050fe67307de9868b09" - integrity sha512-Qa5XSVefSVPRxy2XfUC13WbvqkxhkwB3ve+pgCQveNgYzbM/UxZeu1dcOX/xr4UmfUd+muuvsaxilQzCyUurMw== +core-js@^3.0.1, core-js@^3.0.4, core-js@^3.2.1, core-js@^3.4.1, core-js@^3.6.4: + version "3.6.4" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.4.tgz#440a83536b458114b9cb2ac1580ba377dc470647" + integrity sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw== core-util-is@1.0.2, core-util-is@^1.0.2, core-util-is@~1.0.0: version "1.0.2" @@ -10525,9 +10552,9 @@ crypto-browserify@^3.11.0: randomfill "^1.0.3" crypto-js@^3.1.9-1: - version "3.1.9-1" - resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.1.9-1.tgz#fda19e761fc077e01ffbfdc6e9fdfc59e8806cd8" - integrity sha1-/aGedh/Ad+Af+/3G6f38WeiAbNg= + version "3.3.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.3.0.tgz#846dd1cce2f68aacfa156c8578f926a609b7976b" + integrity sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q== crypto-random-string@^1.0.0: version "1.0.0" @@ -11171,6 +11198,13 @@ debug@3.2.6, debug@3.X, debug@^3.1.0, debug@^3.2.5, debug@^3.2.6: dependencies: ms "^2.1.1" +debug@4, debug@4.1.1, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" + integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + dependencies: + ms "^2.1.1" + debug@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.0.tgz#373687bffa678b38b1cd91f861b63850035ddc87" @@ -11178,13 +11212,6 @@ debug@4.1.0: dependencies: ms "^2.1.1" -debug@4.1.1, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" - integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== - dependencies: - ms "^2.1.1" - debuglog@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" @@ -12058,10 +12085,10 @@ earcut@^2.0.0: resolved "https://registry.yarnpkg.com/earcut/-/earcut-2.1.3.tgz#ca579545f351941af7c3d0df49c9f7d34af99b0c" integrity sha512-AxdCdWUk1zzK/NuZ7e1ljj6IGC+VAdC3Qb7QQDsXpfNrc5IM8tL9nNXUmEGE6jRHTfZ10zhzRhtDmWVsR5pd3A== -earcut@^2.1.5: - version "2.1.5" - resolved "https://registry.yarnpkg.com/earcut/-/earcut-2.1.5.tgz#829280a9a3a0f5fee0529f0a47c3e4eff09b21e4" - integrity sha512-QFWC7ywTVLtvRAJTVp8ugsuuGQ5mVqNmJ1cRYeLrSHgP3nycr2RHTJob9OtM0v8ujuoKN0NY1a93J/omeTL1PA== +earcut@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/earcut/-/earcut-2.2.2.tgz#41b0bc35f63e0fe80da7cddff28511e7e2e80d11" + integrity sha512-eZoZPPJcUHnfRZ0PjLvx2qBordSiO8ofC3vt+qACLM95u+4DovnbYNpQtJh0DNsWj8RnxrQytD4WA8gj5cRIaQ== ecc-jsbn@~0.1.1: version "0.1.2" @@ -16519,13 +16546,13 @@ https-proxy-agent@2.2.1, https-proxy-agent@^2.2.1: agent-base "^4.1.0" debug "^3.1.0" -https-proxy-agent@^2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.2.tgz#271ea8e90f836ac9f119daccd39c19ff7dfb0793" - integrity sha512-c8Ndjc9Bkpfx/vCJueCPy0jlP4ccCCSNDp8xwCZzPjKJUm+B+u9WX2x98Qx4n1PiMNTWo3D7KK5ifNV/yJyRzg== +https-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" + integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== dependencies: - agent-base "^4.3.0" - debug "^3.1.0" + agent-base "6" + debug "4" human-signals@^1.1.1: version "1.1.1" @@ -16627,15 +16654,10 @@ iedriver@^3.14.1: request "^2.88.0" rimraf "~2.0.2" -ieee754@^1.1.4: - version "1.1.8" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4" - integrity sha1-vjPUCsEO8ZJnAfbwii2G+/0a0+Q= - -ieee754@^1.1.6: - version "1.1.12" - resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.12.tgz#50bf24e5b9c8bb98af4964c941cdb0918da7b60b" - integrity sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA== +ieee754@^1.1.12, ieee754@^1.1.4: + version "1.1.13" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" + integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== iferr@^0.1.5: version "0.1.5" @@ -18479,15 +18501,16 @@ jest@^24.9.0: import-local "^2.0.0" jest-cli "^24.9.0" -jimp@0.8.4: - version "0.8.4" - resolved "https://registry.yarnpkg.com/jimp/-/jimp-0.8.4.tgz#9c7c6ee4c8992e585a60914c62aee0c5e5c7955b" - integrity sha512-xCPvd2HIH8iR7+gWVnivzXwiQGnLBmLDpaEj5M0vQf3uur5MuLCOWbBduAdk6r3ur8X0kwgM4eEM0i7o+k9x9g== +jimp@^0.9.6: + version "0.9.6" + resolved "https://registry.yarnpkg.com/jimp/-/jimp-0.9.6.tgz#abf381daf193a4fa335cb4ee0e22948049251eb9" + integrity sha512-DBDHYeNVqVpoPkcvo0PKTNGvD+i7NYvkKTsl0I3k7ql36uN8wGTptRg0HtgQyYE/bhGSLI6Lq5qLwewaOPXNfg== dependencies: - "@jimp/custom" "^0.8.4" - "@jimp/plugins" "^0.8.4" - "@jimp/types" "^0.8.4" - core-js "^2.5.7" + "@babel/runtime" "^7.7.2" + "@jimp/custom" "^0.9.6" + "@jimp/plugins" "^0.9.6" + "@jimp/types" "^0.9.6" + core-js "^3.4.1" regenerator-runtime "^0.13.3" jit-grunt@0.10.0: @@ -20185,10 +20208,10 @@ mapbox-gl-draw-rectangle-mode@^1.0.4: resolved "https://registry.yarnpkg.com/mapbox-gl-draw-rectangle-mode/-/mapbox-gl-draw-rectangle-mode-1.0.4.tgz#42987d68872a5fb5cc5d76d3375ee20cd8bab8f7" integrity sha512-BdF6nwEK2p8n9LQoMPzBO8LhddW1fe+d5vK8HQIei+4VcRnUbKNsEj7Z15FsJxCHzsc2BQKXbESx5GaE8x0imQ== -mapbox-gl@1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/mapbox-gl/-/mapbox-gl-1.3.1.tgz#6be39a207afec3cc6ea4bc241d596140a664e46b" - integrity sha512-IF7b0LZd/caTiknPhm8DAcv7bhvOCXO6rsW18rmFxi8Vw0syJXKK8DLLabI5oiJXtUIgLe57XRgduQzAYrb4og== +mapbox-gl@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/mapbox-gl/-/mapbox-gl-1.9.0.tgz#53e3e13c99483f362b07a8a763f2d61d580255a5" + integrity sha512-PKpoiB2pPUMrqFfBJpt/oA8On3zcp0adEoDS2YIC2RA6o4EZ9Sq2NPZocb64y7ra3mLUvEb7ps1pLVlPMh6y7w== dependencies: "@mapbox/geojson-rewind" "^0.4.0" "@mapbox/geojson-types" "^1.0.2" @@ -20200,17 +20223,17 @@ mapbox-gl@1.3.1: "@mapbox/vector-tile" "^1.3.1" "@mapbox/whoots-js" "^3.1.0" csscolorparser "~1.0.2" - earcut "^2.1.5" + earcut "^2.2.2" geojson-vt "^3.2.1" gl-matrix "^3.0.0" grid-index "^1.1.0" minimist "0.0.8" murmurhash-js "^1.0.0" - pbf "^3.0.5" + pbf "^3.2.1" potpack "^1.0.1" quickselect "^2.0.0" rw "^1.3.3" - supercluster "^6.0.1" + supercluster "^7.0.0" tinyqueue "^2.0.0" vt-pbf "^3.1.1" @@ -22958,13 +22981,13 @@ path2d-polyfill@^0.4.2: resolved "https://registry.yarnpkg.com/path2d-polyfill/-/path2d-polyfill-0.4.2.tgz#594d3103838ef6b9dd4a7fd498fe9a88f1f28531" integrity sha512-JSeAnUfkFjl+Ml/EZL898ivMSbGHrOH63Mirx5EQ1ycJiryHDmj1Q7Are+uEPvenVGCUN9YbolfGfyUewJfJEg== -pbf@^3.0.5: - version "3.1.0" - resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.1.0.tgz#f70004badcb281761eabb1e76c92f179f08189e9" - integrity sha512-/hYJmIsTmh7fMkHAWWXJ5b8IKLWdjdlAFb3IHkRBn1XUhIYBChVGfVwmHEAV3UfXTxsP/AKfYTXTS/dCPxJd5w== +pbf@^3.0.5, pbf@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.2.1.tgz#b4c1b9e72af966cd82c6531691115cc0409ffe2a" + integrity sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ== dependencies: - ieee754 "^1.1.6" - resolve-protobuf-schema "^2.0.0" + ieee754 "^1.1.12" + resolve-protobuf-schema "^2.1.0" pbkdf2@^3.0.3: version "3.0.14" @@ -26054,7 +26077,7 @@ resolve-pkg@^2.0.0: dependencies: resolve-from "^5.0.0" -resolve-protobuf-schema@^2.0.0: +resolve-protobuf-schema@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz#9ca9a9e69cf192bbdaf1006ec1973948aa4a3758" integrity sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ== @@ -28308,10 +28331,10 @@ superagent@3.8.2: qs "^6.5.1" readable-stream "^2.0.5" -supercluster@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/supercluster/-/supercluster-6.0.1.tgz#4c0177d96daa195d58a5bad9f55dbf12fb727a4c" - integrity sha512-NTth/FBFUt9mwW03+Z6Byscex+UHu0utroIe6uXjGu9PrTuWtW70LYv9I1vPSYYIHQL74S5zAkrXrHEk0L7dGA== +supercluster@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/supercluster/-/supercluster-7.0.0.tgz#75d474fafb0a055db552ed7bd7bbda583f6ab321" + integrity sha512-8VuHI8ynylYQj7Qf6PBMWy1PdgsnBiIxujOgc9Z83QvJ8ualIYWNx2iMKyKeC4DZI5ntD9tz/CIwwZvIelixsA== dependencies: kdbush "^3.0.0"