diff --git a/.eslintrc.js b/.eslintrc.js index 0382500916ee8..a7bb204da4775 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -341,9 +341,8 @@ module.exports = { 'src/fixtures/**/*.js', // TODO: this directory needs to be more obviously "public" (or go away) ], settings: { - // instructs import/no-extraneous-dependencies to treat modules - // in plugins/ or ui/ namespace as "core modules" so they don't - // trigger failures for not being listed in package.json + // instructs import/no-extraneous-dependencies to treat certain modules + // as core modules, even if they aren't listed in package.json 'import/core-modules': [ 'plugins', 'legacy/ui', diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a0a22446ba31d..9a4f2b71da1ff 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -86,6 +86,7 @@ /packages/kbn-es/ @elastic/kibana-operations /packages/kbn-pm/ @elastic/kibana-operations /packages/kbn-test/ @elastic/kibana-operations +/packages/kbn-ui-shared-deps/ @elastic/kibana-operations /src/legacy/server/keystore/ @elastic/kibana-operations /src/legacy/server/pid/ @elastic/kibana-operations /src/legacy/server/sass/ @elastic/kibana-operations diff --git a/docs/development/core/public/kibana-plugin-public.appbase.chromeless.md b/docs/development/core/public/kibana-plugin-public.appbase.chromeless.md new file mode 100644 index 0000000000000..ddbf9aafbd28a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.chromeless.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [chromeless](./kibana-plugin-public.appbase.chromeless.md) + +## AppBase.chromeless property + +Hide the UI chrome when the application is mounted. Defaults to `false`. Takes precedence over chrome service visibility settings. + +Signature: + +```typescript +chromeless?: boolean; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.id.md b/docs/development/core/public/kibana-plugin-public.appbase.id.md index 57daa0c94bdf6..89dd32d296104 100644 --- a/docs/development/core/public/kibana-plugin-public.appbase.id.md +++ b/docs/development/core/public/kibana-plugin-public.appbase.id.md @@ -4,6 +4,8 @@ ## AppBase.id property +The unique identifier of the application + Signature: ```typescript diff --git a/docs/development/core/public/kibana-plugin-public.appbase.md b/docs/development/core/public/kibana-plugin-public.appbase.md index a93a195c559b1..eb6d91cb92488 100644 --- a/docs/development/core/public/kibana-plugin-public.appbase.md +++ b/docs/development/core/public/kibana-plugin-public.appbase.md @@ -16,10 +16,14 @@ export interface AppBase | Property | Type | Description | | --- | --- | --- | | [capabilities](./kibana-plugin-public.appbase.capabilities.md) | Partial<Capabilities> | Custom capabilities defined by the app. | +| [chromeless](./kibana-plugin-public.appbase.chromeless.md) | boolean | Hide the UI chrome when the application is mounted. Defaults to false. Takes precedence over chrome service visibility settings. | | [euiIconType](./kibana-plugin-public.appbase.euiicontype.md) | string | A EUI iconType that will be used for the app's icon. This icon takes precendence over the icon property. | | [icon](./kibana-plugin-public.appbase.icon.md) | string | A URL to an image file used as an icon. Used as a fallback if euiIconType is not provided. | -| [id](./kibana-plugin-public.appbase.id.md) | string | | +| [id](./kibana-plugin-public.appbase.id.md) | string | The unique identifier of the application | +| [navLinkStatus](./kibana-plugin-public.appbase.navlinkstatus.md) | AppNavLinkStatus | The initial status of the application's navLink. Defaulting to visible if status is accessible and hidden if status is inaccessible See [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) | | [order](./kibana-plugin-public.appbase.order.md) | number | An ordinal used to sort nav links relative to one another for display. | +| [status](./kibana-plugin-public.appbase.status.md) | AppStatus | The initial status of the application. Defaulting to accessible | | [title](./kibana-plugin-public.appbase.title.md) | string | The title of the application. | -| [tooltip$](./kibana-plugin-public.appbase.tooltip_.md) | Observable<string> | An observable for a tooltip shown when hovering over app link. | +| [tooltip](./kibana-plugin-public.appbase.tooltip.md) | string | A tooltip shown when hovering over app link. | +| [updater$](./kibana-plugin-public.appbase.updater_.md) | Observable<AppUpdater> | An [AppUpdater](./kibana-plugin-public.appupdater.md) observable that can be used to update the application [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) at runtime. | diff --git a/docs/development/core/public/kibana-plugin-public.appbase.navlinkstatus.md b/docs/development/core/public/kibana-plugin-public.appbase.navlinkstatus.md new file mode 100644 index 0000000000000..d6744c3e75756 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.navlinkstatus.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [navLinkStatus](./kibana-plugin-public.appbase.navlinkstatus.md) + +## AppBase.navLinkStatus property + +The initial status of the application's navLink. Defaulting to `visible` if `status` is `accessible` and `hidden` if status is `inaccessible` See [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) + +Signature: + +```typescript +navLinkStatus?: AppNavLinkStatus; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.tooltip_.md b/docs/development/core/public/kibana-plugin-public.appbase.status.md similarity index 56% rename from docs/development/core/public/kibana-plugin-public.appbase.tooltip_.md rename to docs/development/core/public/kibana-plugin-public.appbase.status.md index 0767ead5f1455..a5fbadbeea1ff 100644 --- a/docs/development/core/public/kibana-plugin-public.appbase.tooltip_.md +++ b/docs/development/core/public/kibana-plugin-public.appbase.status.md @@ -1,13 +1,13 @@ -[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [tooltip$](./kibana-plugin-public.appbase.tooltip_.md) +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [status](./kibana-plugin-public.appbase.status.md) -## AppBase.tooltip$ property +## AppBase.status property -An observable for a tooltip shown when hovering over app link. +The initial status of the application. Defaulting to `accessible` Signature: ```typescript -tooltip$?: Observable; +status?: AppStatus; ``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.tooltip.md b/docs/development/core/public/kibana-plugin-public.appbase.tooltip.md new file mode 100644 index 0000000000000..85921a5a321dd --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.tooltip.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [tooltip](./kibana-plugin-public.appbase.tooltip.md) + +## AppBase.tooltip property + +A tooltip shown when hovering over app link. + +Signature: + +```typescript +tooltip?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appbase.updater_.md b/docs/development/core/public/kibana-plugin-public.appbase.updater_.md new file mode 100644 index 0000000000000..3edd357383449 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appbase.updater_.md @@ -0,0 +1,44 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppBase](./kibana-plugin-public.appbase.md) > [updater$](./kibana-plugin-public.appbase.updater_.md) + +## AppBase.updater$ property + +An [AppUpdater](./kibana-plugin-public.appupdater.md) observable that can be used to update the application [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) at runtime. + +Signature: + +```typescript +updater$?: Observable; +``` + +## Example + +How to update an application navLink at runtime + +```ts +// inside your plugin's setup function +export class MyPlugin implements Plugin { + private appUpdater = new BehaviorSubject(() => ({})); + + setup({ application }) { + application.register({ + id: 'my-app', + title: 'My App', + updater$: this.appUpdater, + async mount(params) { + const { renderApp } = await import('./application'); + return renderApp(params); + }, + }); + } + + start() { + // later, when the navlink needs to be updated + appUpdater.next(() => { + navLinkStatus: AppNavLinkStatus.disabled, + }) + } + +``` + diff --git a/docs/development/core/public/kibana-plugin-public.applicationsetup.md b/docs/development/core/public/kibana-plugin-public.applicationsetup.md index a63de399c2ecb..cf9bc5189af40 100644 --- a/docs/development/core/public/kibana-plugin-public.applicationsetup.md +++ b/docs/development/core/public/kibana-plugin-public.applicationsetup.md @@ -16,5 +16,6 @@ export interface ApplicationSetup | Method | Description | | --- | --- | | [register(app)](./kibana-plugin-public.applicationsetup.register.md) | Register an mountable application to the system. | +| [registerAppUpdater(appUpdater$)](./kibana-plugin-public.applicationsetup.registerappupdater.md) | Register an application updater that can be used to change the [AppUpdatableFields](./kibana-plugin-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-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-public.coresetup.getstartservices.md). | diff --git a/docs/development/core/public/kibana-plugin-public.applicationsetup.registerappupdater.md b/docs/development/core/public/kibana-plugin-public.applicationsetup.registerappupdater.md new file mode 100644 index 0000000000000..39b4f878a3f79 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.applicationsetup.registerappupdater.md @@ -0,0 +1,47 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) > [registerAppUpdater](./kibana-plugin-public.applicationsetup.registerappupdater.md) + +## ApplicationSetup.registerAppUpdater() method + +Register an application updater that can be used to change the [AppUpdatableFields](./kibana-plugin-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. + +Signature: + +```typescript +registerAppUpdater(appUpdater$: Observable): void; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| appUpdater$ | Observable<AppUpdater> | | + +Returns: + +`void` + +## Example + +How to register an application updater that disables some applications: + +```ts +// inside your plugin's setup function +export class MyPlugin implements Plugin { + setup({ application }) { + application.registerAppUpdater( + new BehaviorSubject(app => { + if (myPluginApi.shouldDisable(app)) + return { + status: AppStatus.inaccessible, + }; + }) + ); + } +} + +``` + diff --git a/docs/development/core/public/kibana-plugin-public.appnavlinkstatus.md b/docs/development/core/public/kibana-plugin-public.appnavlinkstatus.md new file mode 100644 index 0000000000000..d6b22ac2b9217 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appnavlinkstatus.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) + +## AppNavLinkStatus enum + +Status of the application's navLink. + +Signature: + +```typescript +export declare enum AppNavLinkStatus +``` + +## Enumeration Members + +| Member | Value | Description | +| --- | --- | --- | +| default | 0 | The application navLink will be visible if the application's [AppStatus](./kibana-plugin-public.appstatus.md) is set to accessible and hidden if the application status is set to inaccessible. | +| disabled | 2 | The application navLink is visible but inactive and not clickable in the navigation bar. | +| hidden | 3 | The application navLink does not appear in the navigation bar. | +| visible | 1 | The application navLink is visible and clickable in the navigation bar. | + diff --git a/docs/development/core/public/kibana-plugin-public.appstatus.md b/docs/development/core/public/kibana-plugin-public.appstatus.md new file mode 100644 index 0000000000000..23fb7186569da --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appstatus.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppStatus](./kibana-plugin-public.appstatus.md) + +## AppStatus enum + +Accessibility status of an application. + +Signature: + +```typescript +export declare enum AppStatus +``` + +## Enumeration Members + +| Member | Value | Description | +| --- | --- | --- | +| accessible | 0 | Application is accessible. | +| inaccessible | 1 | Application is not accessible. | + diff --git a/docs/development/core/public/kibana-plugin-public.appupdatablefields.md b/docs/development/core/public/kibana-plugin-public.appupdatablefields.md new file mode 100644 index 0000000000000..b9260c79cd972 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appupdatablefields.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) + +## AppUpdatableFields type + +Defines the list of fields that can be updated via an [AppUpdater](./kibana-plugin-public.appupdater.md). + +Signature: + +```typescript +export declare type AppUpdatableFields = Pick; +``` diff --git a/docs/development/core/public/kibana-plugin-public.appupdater.md b/docs/development/core/public/kibana-plugin-public.appupdater.md new file mode 100644 index 0000000000000..f1b965cc2fc22 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.appupdater.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [AppUpdater](./kibana-plugin-public.appupdater.md) + +## AppUpdater type + +Updater for applications. see [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) + +Signature: + +```typescript +export declare type AppUpdater = (app: AppBase) => Partial | undefined; +``` diff --git a/docs/development/core/public/kibana-plugin-public.md b/docs/development/core/public/kibana-plugin-public.md index f03f3457ca93f..64cbdd880fed1 100644 --- a/docs/development/core/public/kibana-plugin-public.md +++ b/docs/development/core/public/kibana-plugin-public.md @@ -1,147 +1,151 @@ - - -[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) - -## kibana-plugin-public package - -The Kibana Core APIs for client-side plugins. - -A plugin's `public/index` file must contain a named import, `plugin`, that implements [PluginInitializer](./kibana-plugin-public.plugininitializer.md) which returns an object that implements [Plugin](./kibana-plugin-public.plugin.md). - -The plugin integrates with the core system via lifecycle events: `setup`, `start`, and `stop`. In each lifecycle method, the plugin will receive the corresponding core services available (either [CoreSetup](./kibana-plugin-public.coresetup.md) or [CoreStart](./kibana-plugin-public.corestart.md)) and any interfaces returned by dependency plugins' lifecycle method. Anything returned by the plugin's lifecycle method will be exposed to downstream dependencies when their corresponding lifecycle methods are invoked. - -## Classes - -| Class | Description | -| --- | --- | -| [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing plugin state. The client-side SavedObjectsClient is a thin convenience library around the SavedObjects HTTP API for interacting with Saved Objects. | -| [SimpleSavedObject](./kibana-plugin-public.simplesavedobject.md) | This class is a very simple wrapper for SavedObjects loaded from the server with the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md).It provides basic functionality for creating/saving/deleting saved objects, but doesn't include any type-specific implementations. | -| [ToastsApi](./kibana-plugin-public.toastsapi.md) | Methods for adding and removing global toast messages. | - -## Enumerations - -| Enumeration | Description | -| --- | --- | -| [AppLeaveActionType](./kibana-plugin-public.appleaveactiontype.md) | Possible type of actions on application leave. | - -## Interfaces - -| Interface | Description | -| --- | --- | -| [App](./kibana-plugin-public.app.md) | Extension of [common app properties](./kibana-plugin-public.appbase.md) with the mount function. | -| [AppBase](./kibana-plugin-public.appbase.md) | | -| [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to show a confirmation message when trying to leave an application.See | -| [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to execute the default behaviour when leaving the application.See | -| [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) | | -| [ApplicationStart](./kibana-plugin-public.applicationstart.md) | | -| [AppMountContext](./kibana-plugin-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-public.coresetup.getstartservices.md). | -| [AppMountParameters](./kibana-plugin-public.appmountparameters.md) | | -| [Capabilities](./kibana-plugin-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-public.chromebadge.md) | | -| [ChromeBrand](./kibana-plugin-public.chromebrand.md) | | -| [ChromeDocTitle](./kibana-plugin-public.chromedoctitle.md) | APIs for accessing and updating the document title. | -| [ChromeHelpExtension](./kibana-plugin-public.chromehelpextension.md) | | -| [ChromeNavControl](./kibana-plugin-public.chromenavcontrol.md) | | -| [ChromeNavControls](./kibana-plugin-public.chromenavcontrols.md) | [APIs](./kibana-plugin-public.chromenavcontrols.md) for registering new controls to be displayed in the navigation bar. | -| [ChromeNavLink](./kibana-plugin-public.chromenavlink.md) | | -| [ChromeNavLinks](./kibana-plugin-public.chromenavlinks.md) | [APIs](./kibana-plugin-public.chromenavlinks.md) for manipulating nav links. | -| [ChromeRecentlyAccessed](./kibana-plugin-public.chromerecentlyaccessed.md) | [APIs](./kibana-plugin-public.chromerecentlyaccessed.md) for recently accessed history. | -| [ChromeRecentlyAccessedHistoryItem](./kibana-plugin-public.chromerecentlyaccessedhistoryitem.md) | | -| [ChromeStart](./kibana-plugin-public.chromestart.md) | ChromeStart allows plugins to customize the global chrome header UI and enrich the UX with additional information about the current location of the browser. | -| [ContextSetup](./kibana-plugin-public.contextsetup.md) | An object that handles registration of context providers and configuring handlers with context. | -| [CoreSetup](./kibana-plugin-public.coresetup.md) | Core services exposed to the Plugin setup lifecycle | -| [CoreStart](./kibana-plugin-public.corestart.md) | Core services exposed to the Plugin start lifecycle | -| [DocLinksStart](./kibana-plugin-public.doclinksstart.md) | | -| [EnvironmentMode](./kibana-plugin-public.environmentmode.md) | | -| [ErrorToastOptions](./kibana-plugin-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | -| [FatalErrorInfo](./kibana-plugin-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | -| [FatalErrorsSetup](./kibana-plugin-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | -| [HttpErrorRequest](./kibana-plugin-public.httperrorrequest.md) | | -| [HttpErrorResponse](./kibana-plugin-public.httperrorresponse.md) | | -| [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) | All options that may be used with a [HttpHandler](./kibana-plugin-public.httphandler.md). | -| [HttpFetchQuery](./kibana-plugin-public.httpfetchquery.md) | | -| [HttpHandler](./kibana-plugin-public.httphandler.md) | A function for making an HTTP requests to Kibana's backend. See [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) for options and [IHttpResponse](./kibana-plugin-public.ihttpresponse.md) for the response. | -| [HttpHeadersInit](./kibana-plugin-public.httpheadersinit.md) | | -| [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) | An object that may define global interceptor functions for different parts of the request and response lifecycle. See [IHttpInterceptController](./kibana-plugin-public.ihttpinterceptcontroller.md). | -| [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) | Fetch API options available to [HttpHandler](./kibana-plugin-public.httphandler.md)s. | -| [HttpSetup](./kibana-plugin-public.httpsetup.md) | | -| [I18nStart](./kibana-plugin-public.i18nstart.md) | I18nStart.Context is required by any localizable React component from @kbn/i18n and @elastic/eui packages and is supposed to be used as the topmost component for any i18n-compatible React tree. | -| [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) | APIs for denoting paths as not requiring authentication | -| [IBasePath](./kibana-plugin-public.ibasepath.md) | APIs for manipulating the basePath on URL segments. | -| [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | -| [IHttpFetchError](./kibana-plugin-public.ihttpfetcherror.md) | | -| [IHttpInterceptController](./kibana-plugin-public.ihttpinterceptcontroller.md) | Used to halt a request Promise chain in a [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md). | -| [IHttpResponse](./kibana-plugin-public.ihttpresponse.md) | | -| [IHttpResponseInterceptorOverrides](./kibana-plugin-public.ihttpresponseinterceptoroverrides.md) | Properties that can be returned by HttpInterceptor.request to override the response. | -| [IUiSettingsClient](./kibana-plugin-public.iuisettingsclient.md) | Client-side client that provides access to the advanced settings stored in elasticsearch. The settings provide control over the behavior of the Kibana application. For example, a user can specify how to display numeric or date fields. Users can adjust the settings via Management UI. [IUiSettingsClient](./kibana-plugin-public.iuisettingsclient.md) | -| [LegacyCoreSetup](./kibana-plugin-public.legacycoresetup.md) | Setup interface exposed to the legacy platform via the ui/new_platform module. | -| [LegacyCoreStart](./kibana-plugin-public.legacycorestart.md) | Start interface exposed to the legacy platform via the ui/new_platform module. | -| [LegacyNavLink](./kibana-plugin-public.legacynavlink.md) | | -| [NotificationsSetup](./kibana-plugin-public.notificationssetup.md) | | -| [NotificationsStart](./kibana-plugin-public.notificationsstart.md) | | -| [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) | | -| [OverlayRef](./kibana-plugin-public.overlayref.md) | Returned by [OverlayStart](./kibana-plugin-public.overlaystart.md) methods for closing a mounted overlay. | -| [OverlayStart](./kibana-plugin-public.overlaystart.md) | | -| [PackageInfo](./kibana-plugin-public.packageinfo.md) | | -| [Plugin](./kibana-plugin-public.plugin.md) | The interface that should be returned by a PluginInitializer. | -| [PluginInitializerContext](./kibana-plugin-public.plugininitializercontext.md) | The available core services passed to a PluginInitializer | -| [SavedObject](./kibana-plugin-public.savedobject.md) | | -| [SavedObjectAttributes](./kibana-plugin-public.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the attributes property. | -| [SavedObjectReference](./kibana-plugin-public.savedobjectreference.md) | A reference to another saved object. | -| [SavedObjectsBaseOptions](./kibana-plugin-public.savedobjectsbaseoptions.md) | | -| [SavedObjectsBatchResponse](./kibana-plugin-public.savedobjectsbatchresponse.md) | | -| [SavedObjectsBulkCreateObject](./kibana-plugin-public.savedobjectsbulkcreateobject.md) | | -| [SavedObjectsBulkCreateOptions](./kibana-plugin-public.savedobjectsbulkcreateoptions.md) | | -| [SavedObjectsBulkUpdateObject](./kibana-plugin-public.savedobjectsbulkupdateobject.md) | | -| [SavedObjectsBulkUpdateOptions](./kibana-plugin-public.savedobjectsbulkupdateoptions.md) | | -| [SavedObjectsCreateOptions](./kibana-plugin-public.savedobjectscreateoptions.md) | | -| [SavedObjectsFindOptions](./kibana-plugin-public.savedobjectsfindoptions.md) | | -| [SavedObjectsFindResponsePublic](./kibana-plugin-public.savedobjectsfindresponsepublic.md) | Return type of the Saved Objects find() method.\*Note\*: this type is different between the Public and Server Saved Objects clients. | -| [SavedObjectsImportConflictError](./kibana-plugin-public.savedobjectsimportconflicterror.md) | Represents a failure to import due to a conflict. | -| [SavedObjectsImportError](./kibana-plugin-public.savedobjectsimporterror.md) | Represents a failure to import. | -| [SavedObjectsImportMissingReferencesError](./kibana-plugin-public.savedobjectsimportmissingreferenceserror.md) | Represents a failure to import due to missing references. | -| [SavedObjectsImportResponse](./kibana-plugin-public.savedobjectsimportresponse.md) | The response describing the result of an import. | -| [SavedObjectsImportRetry](./kibana-plugin-public.savedobjectsimportretry.md) | Describes a retry operation for importing a saved object. | -| [SavedObjectsImportUnknownError](./kibana-plugin-public.savedobjectsimportunknownerror.md) | Represents a failure to import due to an unknown reason. | -| [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-public.savedobjectsimportunsupportedtypeerror.md) | Represents a failure to import due to having an unsupported saved object type. | -| [SavedObjectsMigrationVersion](./kibana-plugin-public.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | -| [SavedObjectsStart](./kibana-plugin-public.savedobjectsstart.md) | | -| [SavedObjectsUpdateOptions](./kibana-plugin-public.savedobjectsupdateoptions.md) | | -| [UiSettingsState](./kibana-plugin-public.uisettingsstate.md) | | - -## Type Aliases - -| Type Alias | Description | -| --- | --- | -| [AppLeaveAction](./kibana-plugin-public.appleaveaction.md) | Possible actions to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md)See [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) and [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) | -| [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) | A handler that will be executed before leaving the application, either when going to another application or when closing the browser tab or manually changing the url. Should return confirm to to prompt a message to the user before leaving the page, or default to keep the default behavior (doing nothing).See [AppMountParameters](./kibana-plugin-public.appmountparameters.md) for detailed usage examples. | -| [AppMount](./kibana-plugin-public.appmount.md) | A mount function called when the user navigates to this app's route. | -| [AppMountDeprecated](./kibana-plugin-public.appmountdeprecated.md) | A mount function called when the user navigates to this app's route. | -| [AppUnmount](./kibana-plugin-public.appunmount.md) | A function called when an application should be unmounted from the page. This function should be synchronous. | -| [ChromeBreadcrumb](./kibana-plugin-public.chromebreadcrumb.md) | | -| [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-public.chromehelpextensionmenucustomlink.md) | | -| [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-public.chromehelpextensionmenudiscusslink.md) | | -| [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-public.chromehelpextensionmenudocumentationlink.md) | | -| [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-public.chromehelpextensionmenugithublink.md) | | -| [ChromeHelpExtensionMenuLink](./kibana-plugin-public.chromehelpextensionmenulink.md) | | -| [ChromeNavLinkUpdateableFields](./kibana-plugin-public.chromenavlinkupdateablefields.md) | | -| [HandlerContextType](./kibana-plugin-public.handlercontexttype.md) | Extracts the type of the first argument of a [HandlerFunction](./kibana-plugin-public.handlerfunction.md) to represent the type of the context. | -| [HandlerFunction](./kibana-plugin-public.handlerfunction.md) | A function that accepts a context object and an optional number of additional arguments. Used for the generic types in [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | -| [HandlerParameters](./kibana-plugin-public.handlerparameters.md) | Extracts the types of the additional arguments of a [HandlerFunction](./kibana-plugin-public.handlerfunction.md), excluding the [HandlerContextType](./kibana-plugin-public.handlercontexttype.md). | -| [HttpStart](./kibana-plugin-public.httpstart.md) | See [HttpSetup](./kibana-plugin-public.httpsetup.md) | -| [IContextProvider](./kibana-plugin-public.icontextprovider.md) | A function that returns a context value for a specific key of given context type. | -| [IToasts](./kibana-plugin-public.itoasts.md) | Methods for adding and removing global toast messages. See [ToastsApi](./kibana-plugin-public.toastsapi.md). | -| [MountPoint](./kibana-plugin-public.mountpoint.md) | A function that should mount DOM content inside the provided container element and return a handler to unmount it. | -| [PluginInitializer](./kibana-plugin-public.plugininitializer.md) | The plugin export at the root of a plugin's public directory should conform to this interface. | -| [PluginOpaqueId](./kibana-plugin-public.pluginopaqueid.md) | | -| [RecursiveReadonly](./kibana-plugin-public.recursivereadonly.md) | | -| [SavedObjectAttribute](./kibana-plugin-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value | -| [SavedObjectAttributeSingle](./kibana-plugin-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-public.savedobjectattribute.md) | -| [SavedObjectsClientContract](./kibana-plugin-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | -| [Toast](./kibana-plugin-public.toast.md) | | -| [ToastInput](./kibana-plugin-public.toastinput.md) | Inputs for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | -| [ToastInputFields](./kibana-plugin-public.toastinputfields.md) | Allowed fields for [ToastInput](./kibana-plugin-public.toastinput.md). | -| [ToastsSetup](./kibana-plugin-public.toastssetup.md) | [IToasts](./kibana-plugin-public.itoasts.md) | -| [ToastsStart](./kibana-plugin-public.toastsstart.md) | [IToasts](./kibana-plugin-public.itoasts.md) | -| [UnmountCallback](./kibana-plugin-public.unmountcallback.md) | A function that will unmount the element previously mounted by the associated [MountPoint](./kibana-plugin-public.mountpoint.md) | - + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) + +## kibana-plugin-public package + +The Kibana Core APIs for client-side plugins. + +A plugin's `public/index` file must contain a named import, `plugin`, that implements [PluginInitializer](./kibana-plugin-public.plugininitializer.md) which returns an object that implements [Plugin](./kibana-plugin-public.plugin.md). + +The plugin integrates with the core system via lifecycle events: `setup`, `start`, and `stop`. In each lifecycle method, the plugin will receive the corresponding core services available (either [CoreSetup](./kibana-plugin-public.coresetup.md) or [CoreStart](./kibana-plugin-public.corestart.md)) and any interfaces returned by dependency plugins' lifecycle method. Anything returned by the plugin's lifecycle method will be exposed to downstream dependencies when their corresponding lifecycle methods are invoked. + +## Classes + +| Class | Description | +| --- | --- | +| [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing plugin state. The client-side SavedObjectsClient is a thin convenience library around the SavedObjects HTTP API for interacting with Saved Objects. | +| [SimpleSavedObject](./kibana-plugin-public.simplesavedobject.md) | This class is a very simple wrapper for SavedObjects loaded from the server with the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md).It provides basic functionality for creating/saving/deleting saved objects, but doesn't include any type-specific implementations. | +| [ToastsApi](./kibana-plugin-public.toastsapi.md) | Methods for adding and removing global toast messages. | + +## Enumerations + +| Enumeration | Description | +| --- | --- | +| [AppLeaveActionType](./kibana-plugin-public.appleaveactiontype.md) | Possible type of actions on application leave. | +| [AppNavLinkStatus](./kibana-plugin-public.appnavlinkstatus.md) | Status of the application's navLink. | +| [AppStatus](./kibana-plugin-public.appstatus.md) | Accessibility status of an application. | + +## Interfaces + +| Interface | Description | +| --- | --- | +| [App](./kibana-plugin-public.app.md) | Extension of [common app properties](./kibana-plugin-public.appbase.md) with the mount function. | +| [AppBase](./kibana-plugin-public.appbase.md) | | +| [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to show a confirmation message when trying to leave an application.See | +| [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) | Action to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) to execute the default behaviour when leaving the application.See | +| [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) | | +| [ApplicationStart](./kibana-plugin-public.applicationstart.md) | | +| [AppMountContext](./kibana-plugin-public.appmountcontext.md) | The context object received when applications are mounted to the DOM. Deprecated, use [CoreSetup.getStartServices()](./kibana-plugin-public.coresetup.getstartservices.md). | +| [AppMountParameters](./kibana-plugin-public.appmountparameters.md) | | +| [Capabilities](./kibana-plugin-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-public.chromebadge.md) | | +| [ChromeBrand](./kibana-plugin-public.chromebrand.md) | | +| [ChromeDocTitle](./kibana-plugin-public.chromedoctitle.md) | APIs for accessing and updating the document title. | +| [ChromeHelpExtension](./kibana-plugin-public.chromehelpextension.md) | | +| [ChromeNavControl](./kibana-plugin-public.chromenavcontrol.md) | | +| [ChromeNavControls](./kibana-plugin-public.chromenavcontrols.md) | [APIs](./kibana-plugin-public.chromenavcontrols.md) for registering new controls to be displayed in the navigation bar. | +| [ChromeNavLink](./kibana-plugin-public.chromenavlink.md) | | +| [ChromeNavLinks](./kibana-plugin-public.chromenavlinks.md) | [APIs](./kibana-plugin-public.chromenavlinks.md) for manipulating nav links. | +| [ChromeRecentlyAccessed](./kibana-plugin-public.chromerecentlyaccessed.md) | [APIs](./kibana-plugin-public.chromerecentlyaccessed.md) for recently accessed history. | +| [ChromeRecentlyAccessedHistoryItem](./kibana-plugin-public.chromerecentlyaccessedhistoryitem.md) | | +| [ChromeStart](./kibana-plugin-public.chromestart.md) | ChromeStart allows plugins to customize the global chrome header UI and enrich the UX with additional information about the current location of the browser. | +| [ContextSetup](./kibana-plugin-public.contextsetup.md) | An object that handles registration of context providers and configuring handlers with context. | +| [CoreSetup](./kibana-plugin-public.coresetup.md) | Core services exposed to the Plugin setup lifecycle | +| [CoreStart](./kibana-plugin-public.corestart.md) | Core services exposed to the Plugin start lifecycle | +| [DocLinksStart](./kibana-plugin-public.doclinksstart.md) | | +| [EnvironmentMode](./kibana-plugin-public.environmentmode.md) | | +| [ErrorToastOptions](./kibana-plugin-public.errortoastoptions.md) | Options available for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | +| [FatalErrorInfo](./kibana-plugin-public.fatalerrorinfo.md) | Represents the message and stack of a fatal Error | +| [FatalErrorsSetup](./kibana-plugin-public.fatalerrorssetup.md) | FatalErrors stop the Kibana Public Core and displays a fatal error screen with details about the Kibana build and the error. | +| [HttpErrorRequest](./kibana-plugin-public.httperrorrequest.md) | | +| [HttpErrorResponse](./kibana-plugin-public.httperrorresponse.md) | | +| [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) | All options that may be used with a [HttpHandler](./kibana-plugin-public.httphandler.md). | +| [HttpFetchQuery](./kibana-plugin-public.httpfetchquery.md) | | +| [HttpHandler](./kibana-plugin-public.httphandler.md) | A function for making an HTTP requests to Kibana's backend. See [HttpFetchOptions](./kibana-plugin-public.httpfetchoptions.md) for options and [IHttpResponse](./kibana-plugin-public.ihttpresponse.md) for the response. | +| [HttpHeadersInit](./kibana-plugin-public.httpheadersinit.md) | | +| [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md) | An object that may define global interceptor functions for different parts of the request and response lifecycle. See [IHttpInterceptController](./kibana-plugin-public.ihttpinterceptcontroller.md). | +| [HttpRequestInit](./kibana-plugin-public.httprequestinit.md) | Fetch API options available to [HttpHandler](./kibana-plugin-public.httphandler.md)s. | +| [HttpSetup](./kibana-plugin-public.httpsetup.md) | | +| [I18nStart](./kibana-plugin-public.i18nstart.md) | I18nStart.Context is required by any localizable React component from @kbn/i18n and @elastic/eui packages and is supposed to be used as the topmost component for any i18n-compatible React tree. | +| [IAnonymousPaths](./kibana-plugin-public.ianonymouspaths.md) | APIs for denoting paths as not requiring authentication | +| [IBasePath](./kibana-plugin-public.ibasepath.md) | APIs for manipulating the basePath on URL segments. | +| [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | An object that handles registration of context providers and configuring handlers with context. | +| [IHttpFetchError](./kibana-plugin-public.ihttpfetcherror.md) | | +| [IHttpInterceptController](./kibana-plugin-public.ihttpinterceptcontroller.md) | Used to halt a request Promise chain in a [HttpInterceptor](./kibana-plugin-public.httpinterceptor.md). | +| [IHttpResponse](./kibana-plugin-public.ihttpresponse.md) | | +| [IHttpResponseInterceptorOverrides](./kibana-plugin-public.ihttpresponseinterceptoroverrides.md) | Properties that can be returned by HttpInterceptor.request to override the response. | +| [IUiSettingsClient](./kibana-plugin-public.iuisettingsclient.md) | Client-side client that provides access to the advanced settings stored in elasticsearch. The settings provide control over the behavior of the Kibana application. For example, a user can specify how to display numeric or date fields. Users can adjust the settings via Management UI. [IUiSettingsClient](./kibana-plugin-public.iuisettingsclient.md) | +| [LegacyCoreSetup](./kibana-plugin-public.legacycoresetup.md) | Setup interface exposed to the legacy platform via the ui/new_platform module. | +| [LegacyCoreStart](./kibana-plugin-public.legacycorestart.md) | Start interface exposed to the legacy platform via the ui/new_platform module. | +| [LegacyNavLink](./kibana-plugin-public.legacynavlink.md) | | +| [NotificationsSetup](./kibana-plugin-public.notificationssetup.md) | | +| [NotificationsStart](./kibana-plugin-public.notificationsstart.md) | | +| [OverlayBannersStart](./kibana-plugin-public.overlaybannersstart.md) | | +| [OverlayRef](./kibana-plugin-public.overlayref.md) | Returned by [OverlayStart](./kibana-plugin-public.overlaystart.md) methods for closing a mounted overlay. | +| [OverlayStart](./kibana-plugin-public.overlaystart.md) | | +| [PackageInfo](./kibana-plugin-public.packageinfo.md) | | +| [Plugin](./kibana-plugin-public.plugin.md) | The interface that should be returned by a PluginInitializer. | +| [PluginInitializerContext](./kibana-plugin-public.plugininitializercontext.md) | The available core services passed to a PluginInitializer | +| [SavedObject](./kibana-plugin-public.savedobject.md) | | +| [SavedObjectAttributes](./kibana-plugin-public.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the attributes property. | +| [SavedObjectReference](./kibana-plugin-public.savedobjectreference.md) | A reference to another saved object. | +| [SavedObjectsBaseOptions](./kibana-plugin-public.savedobjectsbaseoptions.md) | | +| [SavedObjectsBatchResponse](./kibana-plugin-public.savedobjectsbatchresponse.md) | | +| [SavedObjectsBulkCreateObject](./kibana-plugin-public.savedobjectsbulkcreateobject.md) | | +| [SavedObjectsBulkCreateOptions](./kibana-plugin-public.savedobjectsbulkcreateoptions.md) | | +| [SavedObjectsBulkUpdateObject](./kibana-plugin-public.savedobjectsbulkupdateobject.md) | | +| [SavedObjectsBulkUpdateOptions](./kibana-plugin-public.savedobjectsbulkupdateoptions.md) | | +| [SavedObjectsCreateOptions](./kibana-plugin-public.savedobjectscreateoptions.md) | | +| [SavedObjectsFindOptions](./kibana-plugin-public.savedobjectsfindoptions.md) | | +| [SavedObjectsFindResponsePublic](./kibana-plugin-public.savedobjectsfindresponsepublic.md) | Return type of the Saved Objects find() method.\*Note\*: this type is different between the Public and Server Saved Objects clients. | +| [SavedObjectsImportConflictError](./kibana-plugin-public.savedobjectsimportconflicterror.md) | Represents a failure to import due to a conflict. | +| [SavedObjectsImportError](./kibana-plugin-public.savedobjectsimporterror.md) | Represents a failure to import. | +| [SavedObjectsImportMissingReferencesError](./kibana-plugin-public.savedobjectsimportmissingreferenceserror.md) | Represents a failure to import due to missing references. | +| [SavedObjectsImportResponse](./kibana-plugin-public.savedobjectsimportresponse.md) | The response describing the result of an import. | +| [SavedObjectsImportRetry](./kibana-plugin-public.savedobjectsimportretry.md) | Describes a retry operation for importing a saved object. | +| [SavedObjectsImportUnknownError](./kibana-plugin-public.savedobjectsimportunknownerror.md) | Represents a failure to import due to an unknown reason. | +| [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-public.savedobjectsimportunsupportedtypeerror.md) | Represents a failure to import due to having an unsupported saved object type. | +| [SavedObjectsMigrationVersion](./kibana-plugin-public.savedobjectsmigrationversion.md) | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [SavedObjectsStart](./kibana-plugin-public.savedobjectsstart.md) | | +| [SavedObjectsUpdateOptions](./kibana-plugin-public.savedobjectsupdateoptions.md) | | +| [UiSettingsState](./kibana-plugin-public.uisettingsstate.md) | | + +## Type Aliases + +| Type Alias | Description | +| --- | --- | +| [AppLeaveAction](./kibana-plugin-public.appleaveaction.md) | Possible actions to return from a [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md)See [AppLeaveConfirmAction](./kibana-plugin-public.appleaveconfirmaction.md) and [AppLeaveDefaultAction](./kibana-plugin-public.appleavedefaultaction.md) | +| [AppLeaveHandler](./kibana-plugin-public.appleavehandler.md) | A handler that will be executed before leaving the application, either when going to another application or when closing the browser tab or manually changing the url. Should return confirm to to prompt a message to the user before leaving the page, or default to keep the default behavior (doing nothing).See [AppMountParameters](./kibana-plugin-public.appmountparameters.md) for detailed usage examples. | +| [AppMount](./kibana-plugin-public.appmount.md) | A mount function called when the user navigates to this app's route. | +| [AppMountDeprecated](./kibana-plugin-public.appmountdeprecated.md) | A mount function called when the user navigates to this app's route. | +| [AppUnmount](./kibana-plugin-public.appunmount.md) | A function called when an application should be unmounted from the page. This function should be synchronous. | +| [AppUpdatableFields](./kibana-plugin-public.appupdatablefields.md) | Defines the list of fields that can be updated via an [AppUpdater](./kibana-plugin-public.appupdater.md). | +| [AppUpdater](./kibana-plugin-public.appupdater.md) | Updater for applications. see [ApplicationSetup](./kibana-plugin-public.applicationsetup.md) | +| [ChromeBreadcrumb](./kibana-plugin-public.chromebreadcrumb.md) | | +| [ChromeHelpExtensionMenuCustomLink](./kibana-plugin-public.chromehelpextensionmenucustomlink.md) | | +| [ChromeHelpExtensionMenuDiscussLink](./kibana-plugin-public.chromehelpextensionmenudiscusslink.md) | | +| [ChromeHelpExtensionMenuDocumentationLink](./kibana-plugin-public.chromehelpextensionmenudocumentationlink.md) | | +| [ChromeHelpExtensionMenuGitHubLink](./kibana-plugin-public.chromehelpextensionmenugithublink.md) | | +| [ChromeHelpExtensionMenuLink](./kibana-plugin-public.chromehelpextensionmenulink.md) | | +| [ChromeNavLinkUpdateableFields](./kibana-plugin-public.chromenavlinkupdateablefields.md) | | +| [HandlerContextType](./kibana-plugin-public.handlercontexttype.md) | Extracts the type of the first argument of a [HandlerFunction](./kibana-plugin-public.handlerfunction.md) to represent the type of the context. | +| [HandlerFunction](./kibana-plugin-public.handlerfunction.md) | A function that accepts a context object and an optional number of additional arguments. Used for the generic types in [IContextContainer](./kibana-plugin-public.icontextcontainer.md) | +| [HandlerParameters](./kibana-plugin-public.handlerparameters.md) | Extracts the types of the additional arguments of a [HandlerFunction](./kibana-plugin-public.handlerfunction.md), excluding the [HandlerContextType](./kibana-plugin-public.handlercontexttype.md). | +| [HttpStart](./kibana-plugin-public.httpstart.md) | See [HttpSetup](./kibana-plugin-public.httpsetup.md) | +| [IContextProvider](./kibana-plugin-public.icontextprovider.md) | A function that returns a context value for a specific key of given context type. | +| [IToasts](./kibana-plugin-public.itoasts.md) | Methods for adding and removing global toast messages. See [ToastsApi](./kibana-plugin-public.toastsapi.md). | +| [MountPoint](./kibana-plugin-public.mountpoint.md) | A function that should mount DOM content inside the provided container element and return a handler to unmount it. | +| [PluginInitializer](./kibana-plugin-public.plugininitializer.md) | The plugin export at the root of a plugin's public directory should conform to this interface. | +| [PluginOpaqueId](./kibana-plugin-public.pluginopaqueid.md) | | +| [RecursiveReadonly](./kibana-plugin-public.recursivereadonly.md) | | +| [SavedObjectAttribute](./kibana-plugin-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value | +| [SavedObjectAttributeSingle](./kibana-plugin-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-public.savedobjectattribute.md) | +| [SavedObjectsClientContract](./kibana-plugin-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) | +| [Toast](./kibana-plugin-public.toast.md) | | +| [ToastInput](./kibana-plugin-public.toastinput.md) | Inputs for [IToasts](./kibana-plugin-public.itoasts.md) APIs. | +| [ToastInputFields](./kibana-plugin-public.toastinputfields.md) | Allowed fields for [ToastInput](./kibana-plugin-public.toastinput.md). | +| [ToastsSetup](./kibana-plugin-public.toastssetup.md) | [IToasts](./kibana-plugin-public.itoasts.md) | +| [ToastsStart](./kibana-plugin-public.toastsstart.md) | [IToasts](./kibana-plugin-public.itoasts.md) | +| [UnmountCallback](./kibana-plugin-public.unmountcallback.md) | A function that will unmount the element previously mounted by the associated [MountPoint](./kibana-plugin-public.mountpoint.md) | + diff --git a/docs/user/security/index.asciidoc b/docs/user/security/index.asciidoc index eab3833b3f5ae..e3d6e0d97c73a 100644 --- a/docs/user/security/index.asciidoc +++ b/docs/user/security/index.asciidoc @@ -37,4 +37,5 @@ cause Kibana's authorization to behave unexpectedly. include::authorization/index.asciidoc[] include::authorization/kibana-privileges.asciidoc[] include::api-keys/index.asciidoc[] +include::role-mappings/index.asciidoc[] include::rbac_tutorial.asciidoc[] diff --git a/docs/user/security/role-mappings/images/role-mappings-create-step-1.png b/docs/user/security/role-mappings/images/role-mappings-create-step-1.png new file mode 100644 index 0000000000000..2b4ad16459529 Binary files /dev/null and b/docs/user/security/role-mappings/images/role-mappings-create-step-1.png differ diff --git a/docs/user/security/role-mappings/images/role-mappings-create-step-2.gif b/docs/user/security/role-mappings/images/role-mappings-create-step-2.gif new file mode 100644 index 0000000000000..0a10126ea3cce Binary files /dev/null and b/docs/user/security/role-mappings/images/role-mappings-create-step-2.gif differ diff --git a/docs/user/security/role-mappings/images/role-mappings-grid.png b/docs/user/security/role-mappings/images/role-mappings-grid.png new file mode 100644 index 0000000000000..96c9ee8e4cd95 Binary files /dev/null and b/docs/user/security/role-mappings/images/role-mappings-grid.png differ diff --git a/docs/user/security/role-mappings/index.asciidoc b/docs/user/security/role-mappings/index.asciidoc new file mode 100644 index 0000000000000..01028ab4d59e0 --- /dev/null +++ b/docs/user/security/role-mappings/index.asciidoc @@ -0,0 +1,51 @@ +[role="xpack"] +[[role-mappings]] +=== Role mappings + +Role mappings allow you to describe which roles to assign to your users +using a set of rules. Role mappings are required when authenticating via +an external identity provider, such as Active Directory, Kerberos, PKI, OIDC, +or SAML. + +Role mappings have no effect for users inside the `native` or `file` realms. + +To manage your role mappings, use *Management > Security > Role Mappings*. + +With *Role mappings*, you can: + +* View your configured role mappings +* Create/Edit/Delete role mappings + +[role="screenshot"] +image:user/security/role-mappings/images/role-mappings-grid.png["Role mappings"] + + +[float] +=== Create a role mapping + +To create a role mapping, navigate to *Management > Security > Role Mappings*, and click **Create role mapping**. +Give your role mapping a unique name, and choose which roles you wish to assign to your users. +If you need more flexibility, you can use {ref}/security-api-put-role-mapping.html#_role_templates[role templates] instead. + +Next, define the rules describing which users should receive the roles you defined. Rules can optionally grouped and nested, allowing for sophisticated logic to suite complex requirements. +View the {ref}/role-mapping-resources.html[role mapping resources for an overview of the allowed rule types]. + + +[float] +=== Example + +Let's create a `sales-users` role mapping, which assigns a `sales` role to users whose username +starts with `sls_`, *or* belongs to the `executive` group. + +First, we give the role mapping a name, and assign the `sales` role: + +[role="screenshot"] +image:user/security/role-mappings/images/role-mappings-create-step-1.png["Create role mapping, step 1"] + +Next, we define the two rules, making sure to set the group to *Any are true*: + +[role="screenshot"] +image:user/security/role-mappings/images/role-mappings-create-step-2.gif["Create role mapping, step 2"] + +Click *Save role mapping* once you're finished. + diff --git a/examples/state_containers_examples/public/todo.tsx b/examples/state_containers_examples/public/todo.tsx index 84defb4a91e3f..84f64f99d0179 100644 --- a/examples/state_containers_examples/public/todo.tsx +++ b/examples/state_containers_examples/public/todo.tsx @@ -41,6 +41,7 @@ import { PureTransition, syncStates, getStateFromKbnUrl, + BaseState, } from '../../../src/plugins/kibana_utils/public'; import { useUrlTracker } from '../../../src/plugins/kibana_react/public'; import { @@ -79,7 +80,7 @@ const TodoApp: React.FC = ({ filter }) => { const { setText } = GlobalStateHelpers.useTransitions(); const { text } = GlobalStateHelpers.useState(); const { edit: editTodo, delete: deleteTodo, add: addTodo } = useTransitions(); - const todos = useState(); + const todos = useState().todos; const filteredTodos = todos.filter(todo => { if (!filter) return true; if (filter === 'completed') return todo.completed; @@ -306,7 +307,7 @@ export const TodoAppPage: React.FC<{ ); }; -function withDefaultState( +function withDefaultState( stateContainer: BaseStateContainer, // eslint-disable-next-line no-shadow defaultState: State @@ -314,14 +315,10 @@ function withDefaultState( return { ...stateContainer, set: (state: State | null) => { - if (Array.isArray(defaultState)) { - stateContainer.set(state || defaultState); - } else { - stateContainer.set({ - ...defaultState, - ...state, - }); - } + stateContainer.set({ + ...defaultState, + ...state, + }); }, }; } diff --git a/package.json b/package.json index 24bf2956bb16f..6b9640d214a5e 100644 --- a/package.json +++ b/package.json @@ -134,6 +134,7 @@ "@kbn/pm": "1.0.0", "@kbn/test-subj-selector": "0.2.1", "@kbn/ui-framework": "1.0.0", + "@kbn/ui-shared-deps": "1.0.0", "@types/json-stable-stringify": "^1.0.32", "@types/lodash.clonedeep": "^4.5.4", "@types/node-forge": "^0.9.0", @@ -165,6 +166,7 @@ "custom-event-polyfill": "^0.3.0", "d3": "3.5.17", "d3-cloud": "1.2.5", + "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "del": "^5.1.0", "elastic-apm-node": "^3.2.0", @@ -313,6 +315,7 @@ "@types/classnames": "^2.2.9", "@types/d3": "^3.5.43", "@types/dedent": "^0.7.0", + "@types/deep-freeze-strict": "^1.1.0", "@types/delete-empty": "^2.0.0", "@types/elasticsearch": "^5.0.33", "@types/enzyme": "^3.9.0", diff --git a/packages/kbn-eslint-import-resolver-kibana/lib/get_webpack_config.js b/packages/kbn-eslint-import-resolver-kibana/lib/get_webpack_config.js index c51168ae2d91c..e02c38494991a 100755 --- a/packages/kbn-eslint-import-resolver-kibana/lib/get_webpack_config.js +++ b/packages/kbn-eslint-import-resolver-kibana/lib/get_webpack_config.js @@ -30,8 +30,6 @@ exports.getWebpackConfig = function(kibanaPath, projectRoot, config) { ui: fromKibana('src/legacy/ui/public'), test_harness: fromKibana('src/test_harness/public'), querystring: 'querystring-browser', - moment$: fromKibana('webpackShims/moment'), - 'moment-timezone$': fromKibana('webpackShims/moment-timezone'), // Dev defaults for test bundle https://github.com/elastic/kibana/blob/6998f074542e8c7b32955db159d15661aca253d7/src/core_plugins/tests_bundle/index.js#L73-L78 ng_mock$: fromKibana('src/test_utils/public/ng_mock'), diff --git a/packages/kbn-ui-shared-deps/README.md b/packages/kbn-ui-shared-deps/README.md new file mode 100644 index 0000000000000..3d3ee37ca5a75 --- /dev/null +++ b/packages/kbn-ui-shared-deps/README.md @@ -0,0 +1,3 @@ +# `@kbn/ui-shared-deps` + +Shared dependencies that must only have a single instance are installed and re-exported from here. To consume them, import the package and merge the `externals` export into your webpack config so that all references to the supported modules will be remapped to use the global versions. \ No newline at end of file diff --git a/packages/kbn-ui-shared-deps/entry.js b/packages/kbn-ui-shared-deps/entry.js new file mode 100644 index 0000000000000..250abd162f91d --- /dev/null +++ b/packages/kbn-ui-shared-deps/entry.js @@ -0,0 +1,39 @@ +/* + * 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. + */ + +// must load before angular +export const Jquery = require('jquery'); +window.$ = window.jQuery = Jquery; + +export const Angular = require('angular'); +export const ElasticCharts = require('@elastic/charts'); +export const ElasticEui = require('@elastic/eui'); +export const ElasticEuiLibServices = require('@elastic/eui/lib/services'); +export const ElasticEuiLightTheme = require('@elastic/eui/dist/eui_theme_light.json'); +export const ElasticEuiDarkTheme = require('@elastic/eui/dist/eui_theme_dark.json'); +export const Moment = require('moment'); +export const MomentTimezone = require('moment-timezone/moment-timezone'); +export const React = require('react'); +export const ReactDom = require('react-dom'); +export const ReactIntl = require('react-intl'); +export const ReactRouter = require('react-router'); // eslint-disable-line +export const ReactRouterDom = require('react-router-dom'); + +// load timezone data into moment-timezone +Moment.tz.load(require('moment-timezone/data/packed/latest.json')); diff --git a/webpackShims/jquery.js b/packages/kbn-ui-shared-deps/index.d.ts similarity index 58% rename from webpackShims/jquery.js rename to packages/kbn-ui-shared-deps/index.d.ts index da81dd18cf71e..132445bbde745 100644 --- a/webpackShims/jquery.js +++ b/packages/kbn-ui-shared-deps/index.d.ts @@ -17,4 +17,29 @@ * under the License. */ -window.jQuery = window.$ = module.exports = require('../node_modules/jquery/dist/jquery'); +/** + * Absolute path to the distributable directory + */ +export const distDir: string; + +/** + * Filename of the main bundle file in the distributable directory + */ +export const distFilename: string; + +/** + * Filename of the dark-theme css file in the distributable directory + */ +export const darkCssDistFilename: string; + +/** + * Filename of the light-theme css file in the distributable directory + */ +export const lightCssDistFilename: string; + +/** + * Externals mapping inteded to be used in a webpack config + */ +export const externals: { + [key: string]: string; +}; diff --git a/packages/kbn-ui-shared-deps/index.js b/packages/kbn-ui-shared-deps/index.js new file mode 100644 index 0000000000000..cef25295b35d7 --- /dev/null +++ b/packages/kbn-ui-shared-deps/index.js @@ -0,0 +1,41 @@ +/* + * 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. + */ + +const Path = require('path'); + +exports.distDir = Path.resolve(__dirname, 'target'); +exports.distFilename = 'kbn-ui-shared-deps.js'; +exports.lightCssDistFilename = 'kbn-ui-shared-deps.light.css'; +exports.darkCssDistFilename = 'kbn-ui-shared-deps.dark.css'; +exports.externals = { + angular: '__kbnSharedDeps__.Angular', + '@elastic/charts': '__kbnSharedDeps__.ElasticCharts', + '@elastic/eui': '__kbnSharedDeps__.ElasticEui', + '@elastic/eui/lib/services': '__kbnSharedDeps__.ElasticEuiLibServices', + '@elastic/eui/dist/eui_theme_light.json': '__kbnSharedDeps__.ElasticEuiLightTheme', + '@elastic/eui/dist/eui_theme_dark.json': '__kbnSharedDeps__.ElasticEuiDarkTheme', + jquery: '__kbnSharedDeps__.Jquery', + moment: '__kbnSharedDeps__.Moment', + 'moment-timezone': '__kbnSharedDeps__.MomentTimezone', + react: '__kbnSharedDeps__.React', + 'react-dom': '__kbnSharedDeps__.ReactDom', + 'react-intl': '__kbnSharedDeps__.ReactIntl', + 'react-router': '__kbnSharedDeps__.ReactRouter', + 'react-router-dom': '__kbnSharedDeps__.ReactRouterDom', +}; diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json new file mode 100644 index 0000000000000..014467d204d96 --- /dev/null +++ b/packages/kbn-ui-shared-deps/package.json @@ -0,0 +1,29 @@ +{ + "name": "@kbn/ui-shared-deps", + "version": "1.0.0", + "license": "Apache-2.0", + "private": true, + "scripts": { + "build": "node scripts/build", + "kbn:bootstrap": "node scripts/build --dev", + "kbn:watch": "node scripts/build --watch" + }, + "devDependencies": { + "@elastic/eui": "17.3.1", + "@elastic/charts": "^16.1.0", + "@kbn/dev-utils": "1.0.0", + "@yarnpkg/lockfile": "^1.1.0", + "angular": "^1.7.9", + "css-loader": "^2.1.1", + "del": "^5.1.0", + "jquery": "^3.4.1", + "mini-css-extract-plugin": "0.8.0", + "moment": "^2.24.0", + "moment-timezone": "^0.5.27", + "react-dom": "^16.12.0", + "react-intl": "^2.8.0", + "react": "^16.12.0", + "read-pkg": "^5.2.0", + "webpack": "4.41.0" + } +} \ No newline at end of file diff --git a/packages/kbn-ui-shared-deps/scripts/build.js b/packages/kbn-ui-shared-deps/scripts/build.js new file mode 100644 index 0000000000000..8b7c22dac24ff --- /dev/null +++ b/packages/kbn-ui-shared-deps/scripts/build.js @@ -0,0 +1,105 @@ +/* + * 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. + */ + +const Path = require('path'); + +const { run, createFailError } = require('@kbn/dev-utils'); +const webpack = require('webpack'); +const Stats = require('webpack/lib/Stats'); +const del = require('del'); + +const { getWebpackConfig } = require('../webpack.config'); + +run( + async ({ log, flags }) => { + log.info('cleaning previous build output'); + await del(Path.resolve(__dirname, '../target')); + + const compiler = webpack( + getWebpackConfig({ + dev: flags.dev, + }) + ); + + /** @param {webpack.Stats} stats */ + const onCompilationComplete = stats => { + const took = Math.round((stats.endTime - stats.startTime) / 1000); + + if (!stats.hasErrors() && !stats.hasWarnings()) { + log.success(`webpack completed in about ${took} seconds`); + return; + } + + throw createFailError( + `webpack failure in about ${took} seconds\n${stats.toString({ + colors: true, + ...Stats.presetToOptions('minimal'), + })}` + ); + }; + + if (flags.watch) { + compiler.hooks.done.tap('report on stats', stats => { + try { + onCompilationComplete(stats); + } catch (error) { + log.error(error.message); + } + }); + + compiler.hooks.watchRun.tap('report on start', () => { + process.stdout.cursorTo(0, 0); + process.stdout.clearScreenDown(); + log.info('Running webpack compilation...'); + }); + + compiler.watch({}, error => { + if (error) { + log.error('Fatal webpack error'); + log.error(error); + process.exit(1); + } + }); + + return; + } + + onCompilationComplete( + await new Promise((resolve, reject) => { + compiler.run((error, stats) => { + if (error) { + reject(error); + } else { + resolve(stats); + } + }); + }) + ); + }, + { + description: 'build @kbn/ui-shared-deps', + flags: { + boolean: ['watch', 'dev'], + help: ` + --watch Run in watch mode + --dev Build development friendly version + `, + }, + } +); diff --git a/packages/kbn-ui-shared-deps/tsconfig.json b/packages/kbn-ui-shared-deps/tsconfig.json new file mode 100644 index 0000000000000..c5c3cba147fcf --- /dev/null +++ b/packages/kbn-ui-shared-deps/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "index.d.ts" + ] +} diff --git a/packages/kbn-ui-shared-deps/webpack.config.js b/packages/kbn-ui-shared-deps/webpack.config.js new file mode 100644 index 0000000000000..87cca2cc897f8 --- /dev/null +++ b/packages/kbn-ui-shared-deps/webpack.config.js @@ -0,0 +1,90 @@ +/* + * 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. + */ + +const Path = require('path'); + +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const { REPO_ROOT } = require('@kbn/dev-utils'); +const webpack = require('webpack'); + +const SharedDeps = require('./index'); + +const MOMENT_SRC = require.resolve('moment/min/moment-with-locales.js'); + +exports.getWebpackConfig = ({ dev = false } = {}) => ({ + mode: dev ? 'development' : 'production', + entry: { + [SharedDeps.distFilename.replace(/\.js$/, '')]: './entry.js', + [SharedDeps.darkCssDistFilename.replace(/\.css$/, '')]: [ + '@elastic/eui/dist/eui_theme_dark.css', + '@elastic/charts/dist/theme_only_dark.css', + ], + [SharedDeps.lightCssDistFilename.replace(/\.css$/, '')]: [ + '@elastic/eui/dist/eui_theme_light.css', + '@elastic/charts/dist/theme_only_light.css', + ], + }, + context: __dirname, + devtool: dev ? '#cheap-source-map' : false, + output: { + path: SharedDeps.distDir, + filename: '[name].js', + sourceMapFilename: '[file].map', + publicPath: '__REPLACE_WITH_PUBLIC_PATH__', + devtoolModuleFilenameTemplate: info => + `kbn-ui-shared-deps/${Path.relative(REPO_ROOT, info.absoluteResourcePath)}`, + library: '__kbnSharedDeps__', + }, + + module: { + noParse: [MOMENT_SRC], + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, 'css-loader'], + }, + ], + }, + + resolve: { + alias: { + moment: MOMENT_SRC, + }, + }, + + optimization: { + noEmitOnErrors: true, + }, + + performance: { + // NOTE: we are disabling this as those hints + // are more tailored for the final bundles result + // and not for the webpack compilations performance itself + hints: false, + }, + + plugins: [ + new MiniCssExtractPlugin({ + filename: '[name].css', + }), + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': dev ? '"development"' : '"production"', + }), + ], +}); diff --git a/renovate.json5 b/renovate.json5 index 560403046b0a5..7f67fae894110 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -210,6 +210,14 @@ '@types/dedent', ], }, + { + groupSlug: 'deep-freeze-strict', + groupName: 'deep-freeze-strict related packages', + packageNames: [ + 'deep-freeze-strict', + '@types/deep-freeze-strict', + ], + }, { groupSlug: 'delete-empty', groupName: 'delete-empty related packages', diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md index b70ac610f24a7..173d73ffab664 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -55,6 +55,7 @@ - [Provide Legacy Platform API to the New platform plugin](#provide-legacy-platform-api-to-the-new-platform-plugin) - [On the server side](#on-the-server-side) - [On the client side](#on-the-client-side) + - [Updates an application navlink at runtime](#updates-an-app-navlink-at-runtime) Make no mistake, it is going to take a lot of work to move certain plugins to the new platform. Our target is to migrate the entire repo over to the new platform throughout 7.x and to remove the legacy plugin system no later than 8.0, and this is only possible if teams start on the effort now. @@ -1624,3 +1625,31 @@ class MyPlugin { It's not currently possible to use a similar pattern on the client-side. Because Legacy platform plugins heavily rely on global angular modules, which aren't available on the new platform. So you can utilize the same approach for only *stateless Angular components*, as long as they are not consumed by a New Platform application. When New Platform applications are on the page, no legacy code is executed, so the `registerLegacyAPI` function would not be called. + +### Updates an application navlink at runtime + +The application API now provides a way to updates some of a registered application's properties after registration. + +```typescript +// inside your plugin's setup function +export class MyPlugin implements Plugin { + private appUpdater = new BehaviorSubject(() => ({})); + setup({ application }) { + application.register({ + id: 'my-app', + title: 'My App', + updater$: this.appUpdater, + async mount(params) { + const { renderApp } = await import('./application'); + return renderApp(params); + }, + }); + } + start() { + // later, when the navlink needs to be updated + appUpdater.next(() => { + navLinkStatus: AppNavLinkStatus.disabled, + tooltip: 'Application disabled', + }) + } +``` \ No newline at end of file diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index b2e2161c92cc8..dee47315fc322 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Subject } from 'rxjs'; +import { BehaviorSubject, Subject } from 'rxjs'; import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; import { @@ -25,17 +25,21 @@ import { InternalApplicationStart, ApplicationStart, InternalApplicationSetup, + App, + LegacyApp, } from './types'; import { ApplicationServiceContract } from './test_types'; const createSetupContractMock = (): jest.Mocked => ({ register: jest.fn(), + registerAppUpdater: jest.fn(), registerMountContext: jest.fn(), }); const createInternalSetupContractMock = (): jest.Mocked => ({ register: jest.fn(), registerLegacyApp: jest.fn(), + registerAppUpdater: jest.fn(), registerMountContext: jest.fn(), }); @@ -50,8 +54,7 @@ const createInternalStartContractMock = (): jest.Mocked(); return { - availableApps: new Map(), - availableLegacyApps: new Map(), + applications$: new BehaviorSubject>(new Map()), capabilities: capabilitiesServiceMock.createStartContract().capabilities, currentAppId$: currentAppId$.asObservable(), getComponent: jest.fn(), diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index 1132abc11703f..4672a42c9eb06 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -18,8 +18,8 @@ */ import { createElement } from 'react'; -import { Subject } from 'rxjs'; -import { bufferCount, skip, takeUntil } from 'rxjs/operators'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { bufferCount, skip, take, takeUntil } from 'rxjs/operators'; import { shallow } from 'enzyme'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; @@ -29,8 +29,25 @@ import { overlayServiceMock } from '../overlays/overlay_service.mock'; import { MockCapabilitiesService, MockHistory } from './application_service.test.mocks'; import { MockLifecycle } from './test_types'; import { ApplicationService } from './application_service'; - -function mount() {} +import { App, AppNavLinkStatus, AppStatus, AppUpdater, LegacyApp } from './types'; + +const createApp = (props: Partial): App => { + return { + id: 'some-id', + title: 'some-title', + mount: () => () => undefined, + ...props, + }; +}; + +const createLegacyApp = (props: Partial): LegacyApp => { + return { + id: 'some-id', + title: 'some-title', + appUrl: '/my-url', + ...props, + }; +}; let setupDeps: MockLifecycle<'setup'>; let startDeps: MockLifecycle<'start'>; @@ -53,9 +70,9 @@ describe('#setup()', () => { it('throws an error if two apps with the same id are registered', () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); + register(Symbol(), createApp({ id: 'app1' })); expect(() => - register(Symbol(), { id: 'app1', mount } as any) + register(Symbol(), createApp({ id: 'app1' })) ).toThrowErrorMatchingInlineSnapshot( `"An application is already registered with the id \\"app1\\""` ); @@ -66,37 +83,91 @@ describe('#setup()', () => { await service.start(startDeps); expect(() => - register(Symbol(), { id: 'app1', mount } as any) + register(Symbol(), createApp({ id: 'app1' })) ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); }); + it('allows to register a statusUpdater for the application', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + const updater$ = new BehaviorSubject(app => ({})); + setup.register(pluginId, createApp({ id: 'app1', updater$ })); + setup.register(pluginId, createApp({ id: 'app2' })); + const { applications$ } = await service.start(startDeps); + + let applications = await applications$.pipe(take(1)).toPromise(); + expect(applications.size).toEqual(2); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); + expect(applications.get('app2')).toEqual( + expect.objectContaining({ + id: 'app2', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); + + updater$.next(app => ({ + status: AppStatus.inaccessible, + tooltip: 'App inaccessible due to reason', + })); + + applications = await applications$.pipe(take(1)).toPromise(); + expect(applications.size).toEqual(2); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.inaccessible, + tooltip: 'App inaccessible due to reason', + }) + ); + expect(applications.get('app2')).toEqual( + expect.objectContaining({ + id: 'app2', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); + }); + it('throws an error if an App with the same appRoute is registered', () => { const { register, registerLegacyApp } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); + register(Symbol(), createApp({ id: 'app1' })); expect(() => - register(Symbol(), { id: 'app2', mount, appRoute: '/app/app1' } as any) + register(Symbol(), createApp({ id: 'app2', appRoute: '/app/app1' })) ).toThrowErrorMatchingInlineSnapshot( `"An application is already registered with the appRoute \\"/app/app1\\""` ); - expect(() => registerLegacyApp({ id: 'app1' } as any)).not.toThrow(); + expect(() => registerLegacyApp(createLegacyApp({ id: 'app1' }))).toThrow(); - register(Symbol(), { id: 'app-next', mount, appRoute: '/app/app3' } as any); + register(Symbol(), createApp({ id: 'app-next', appRoute: '/app/app3' })); expect(() => - register(Symbol(), { id: 'app2', mount, appRoute: '/app/app3' } as any) + register(Symbol(), createApp({ id: 'app2', appRoute: '/app/app3' })) ).toThrowErrorMatchingInlineSnapshot( `"An application is already registered with the appRoute \\"/app/app3\\""` ); - expect(() => registerLegacyApp({ id: 'app3' } as any)).not.toThrow(); + expect(() => registerLegacyApp(createLegacyApp({ id: 'app3' }))).not.toThrow(); }); it('throws an error if an App starts with the HTTP base path', () => { const { register } = service.setup(setupDeps); expect(() => - register(Symbol(), { id: 'app2', mount, appRoute: '/test/app2' } as any) + register(Symbol(), createApp({ id: 'app2', appRoute: '/test/app2' })) ).toThrowErrorMatchingInlineSnapshot( `"Cannot register an application route that includes HTTP base path"` ); @@ -107,9 +178,11 @@ describe('#setup()', () => { it('throws an error if two apps with the same id are registered', () => { const { registerLegacyApp } = service.setup(setupDeps); - registerLegacyApp({ id: 'app2' } as any); - expect(() => registerLegacyApp({ id: 'app2' } as any)).toThrowErrorMatchingInlineSnapshot( - `"A legacy application is already registered with the id \\"app2\\""` + registerLegacyApp(createLegacyApp({ id: 'app2' })); + expect(() => + registerLegacyApp(createLegacyApp({ id: 'app2' })) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the id \\"app2\\""` ); }); @@ -117,22 +190,228 @@ describe('#setup()', () => { const { registerLegacyApp } = service.setup(setupDeps); await service.start(startDeps); - expect(() => registerLegacyApp({ id: 'app2' } as any)).toThrowErrorMatchingInlineSnapshot( - `"Applications cannot be registered after \\"setup\\""` - ); + expect(() => + registerLegacyApp(createLegacyApp({ id: 'app2' })) + ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); }); it('throws an error if a LegacyApp with the same appRoute is registered', () => { const { register, registerLegacyApp } = service.setup(setupDeps); - registerLegacyApp({ id: 'app1' } as any); + registerLegacyApp(createLegacyApp({ id: 'app1' })); expect(() => - register(Symbol(), { id: 'app2', mount, appRoute: '/app/app1' } as any) + register(Symbol(), createApp({ id: 'app2', appRoute: '/app/app1' })) ).toThrowErrorMatchingInlineSnapshot( `"An application is already registered with the appRoute \\"/app/app1\\""` ); - expect(() => registerLegacyApp({ id: 'app1:other' } as any)).not.toThrow(); + expect(() => registerLegacyApp(createLegacyApp({ id: 'app1:other' }))).not.toThrow(); + }); + }); + + describe('registerAppStatusUpdater', () => { + it('updates status fields', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + setup.register(pluginId, createApp({ id: 'app1' })); + setup.register(pluginId, createApp({ id: 'app2' })); + setup.registerAppUpdater( + new BehaviorSubject(app => { + if (app.id === 'app1') { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + tooltip: 'App inaccessible due to reason', + }; + } + return { + tooltip: 'App accessible', + }; + }) + ); + const start = await service.start(startDeps); + const applications = await start.applications$.pipe(take(1)).toPromise(); + + expect(applications.size).toEqual(2); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.disabled, + status: AppStatus.inaccessible, + tooltip: 'App inaccessible due to reason', + }) + ); + expect(applications.get('app2')).toEqual( + expect.objectContaining({ + id: 'app2', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + tooltip: 'App accessible', + }) + ); + }); + + it(`properly combine with application's updater$`, async () => { + const setup = service.setup(setupDeps); + const pluginId = Symbol('plugin'); + const appStatusUpdater$ = new BehaviorSubject(app => ({ + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + })); + setup.register(pluginId, createApp({ id: 'app1', updater$: appStatusUpdater$ })); + setup.register(pluginId, createApp({ id: 'app2' })); + + setup.registerAppUpdater( + new BehaviorSubject(app => { + if (app.id === 'app1') { + return { + status: AppStatus.accessible, + tooltip: 'App inaccessible due to reason', + }; + } + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + }; + }) + ); + + const { applications$ } = await service.start(startDeps); + const applications = await applications$.pipe(take(1)).toPromise(); + + expect(applications.size).toEqual(2); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.disabled, + status: AppStatus.inaccessible, + tooltip: 'App inaccessible due to reason', + }) + ); + expect(applications.get('app2')).toEqual( + expect.objectContaining({ + id: 'app2', + legacy: false, + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + }) + ); + }); + + it('applies the most restrictive status in case of multiple updaters', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + setup.register(pluginId, createApp({ id: 'app1' })); + setup.registerAppUpdater( + new BehaviorSubject(app => { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + }; + }) + ); + setup.registerAppUpdater( + new BehaviorSubject(app => { + return { + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.default, + }; + }) + ); + + const start = await service.start(startDeps); + const applications = await start.applications$.pipe(take(1)).toPromise(); + + expect(applications.size).toEqual(1); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.disabled, + status: AppStatus.inaccessible, + }) + ); + }); + + it('emits on applications$ when a status updater changes', async () => { + const setup = service.setup(setupDeps); + + const pluginId = Symbol('plugin'); + setup.register(pluginId, createApp({ id: 'app1' })); + + const statusUpdater = new BehaviorSubject(app => { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + }; + }); + setup.registerAppUpdater(statusUpdater); + + const start = await service.start(startDeps); + let latestValue: ReadonlyMap = new Map(); + start.applications$.subscribe(apps => { + latestValue = apps; + }); + + expect(latestValue.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.disabled, + }) + ); + + statusUpdater.next(app => { + return { + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.hidden, + }; + }); + + expect(latestValue.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: false, + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.hidden, + }) + ); + }); + + it('also updates legacy apps', async () => { + const setup = service.setup(setupDeps); + + setup.registerLegacyApp(createLegacyApp({ id: 'app1' })); + + setup.registerAppUpdater( + new BehaviorSubject(app => { + return { + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + tooltip: 'App inaccessible due to reason', + }; + }) + ); + + const start = await service.start(startDeps); + const applications = await start.applications$.pipe(take(1)).toPromise(); + + expect(applications.size).toEqual(1); + expect(applications.get('app1')).toEqual( + expect.objectContaining({ + id: 'app1', + legacy: true, + status: AppStatus.inaccessible, + navLinkStatus: AppNavLinkStatus.hidden, + tooltip: 'App inaccessible due to reason', + }) + ); }); }); @@ -141,7 +420,8 @@ describe('#setup()', () => { const container = setupDeps.context.createContextContainer.mock.results[0].value; const pluginId = Symbol(); - registerMountContext(pluginId, 'test' as any, mount as any); + const mount = () => () => undefined; + registerMountContext(pluginId, 'test' as any, mount); expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', mount); }); }); @@ -171,35 +451,40 @@ describe('#start()', () => { setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); const { register, registerLegacyApp } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); - registerLegacyApp({ id: 'app2' } as any); - - const { availableApps, availableLegacyApps } = await service.start(startDeps); - - expect(availableApps).toMatchInlineSnapshot(` - Map { - "app1" => Object { - "appRoute": "/app/app1", - "id": "app1", - "mount": [Function], - }, - } - `); - expect(availableLegacyApps).toMatchInlineSnapshot(` - Map { - "app2" => Object { - "id": "app2", - }, - } - `); + register(Symbol(), createApp({ id: 'app1' })); + registerLegacyApp(createLegacyApp({ id: 'app2' })); + + const { applications$ } = await service.start(startDeps); + const availableApps = await applications$.pipe(take(1)).toPromise(); + + expect(availableApps.size).toEqual(2); + expect([...availableApps.keys()]).toEqual(['app1', 'app2']); + expect(availableApps.get('app1')).toEqual( + expect.objectContaining({ + appRoute: '/app/app1', + id: 'app1', + legacy: false, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); + expect(availableApps.get('app2')).toEqual( + expect.objectContaining({ + appUrl: '/my-url', + id: 'app2', + legacy: true, + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ); }); it('passes appIds to capabilities', async () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); - register(Symbol(), { id: 'app2', mount } as any); - register(Symbol(), { id: 'app3', mount } as any); + register(Symbol(), createApp({ id: 'app1' })); + register(Symbol(), createApp({ id: 'app2' })); + register(Symbol(), createApp({ id: 'app3' })); await service.start(startDeps); expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ @@ -222,29 +507,15 @@ describe('#start()', () => { const { register, registerLegacyApp } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); - registerLegacyApp({ id: 'legacyApp1' } as any); - register(Symbol(), { id: 'app2', mount } as any); - registerLegacyApp({ id: 'legacyApp2' } as any); + register(Symbol(), createApp({ id: 'app1' })); + registerLegacyApp(createLegacyApp({ id: 'legacyApp1' })); + register(Symbol(), createApp({ id: 'app2' })); + registerLegacyApp(createLegacyApp({ id: 'legacyApp2' })); - const { availableApps, availableLegacyApps } = await service.start(startDeps); + const { applications$ } = await service.start(startDeps); + const availableApps = await applications$.pipe(take(1)).toPromise(); - expect(availableApps).toMatchInlineSnapshot(` - Map { - "app1" => Object { - "appRoute": "/app/app1", - "id": "app1", - "mount": [Function], - }, - } - `); - expect(availableLegacyApps).toMatchInlineSnapshot(` - Map { - "legacyApp1" => Object { - "id": "legacyApp1", - }, - } - `); + expect([...availableApps.keys()]).toEqual(['app1', 'legacyApp1']); }); describe('getComponent', () => { @@ -290,9 +561,9 @@ describe('#start()', () => { it('creates URL for registered appId', async () => { const { register, registerLegacyApp } = service.setup(setupDeps); - register(Symbol(), { id: 'app1', mount } as any); - registerLegacyApp({ id: 'legacyApp1' } as any); - register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + register(Symbol(), createApp({ id: 'app1' })); + registerLegacyApp(createLegacyApp({ id: 'legacyApp1' })); + register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' })); const { getUrlForApp } = await service.start(startDeps); @@ -329,7 +600,7 @@ describe('#start()', () => { it('changes the browser history for custom appRoutes', async () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' })); const { navigateToApp } = await service.start(startDeps); @@ -343,7 +614,7 @@ describe('#start()', () => { it('appends a path if specified', async () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' })); const { navigateToApp } = await service.start(startDeps); @@ -363,7 +634,7 @@ describe('#start()', () => { it('includes state if specified', async () => { const { register } = service.setup(setupDeps); - register(Symbol(), { id: 'app2', mount, appRoute: '/custom/path' } as any); + register(Symbol(), createApp({ id: 'app2', appRoute: '/custom/path' })); const { navigateToApp } = await service.start(startDeps); @@ -429,7 +700,7 @@ describe('#start()', () => { const { registerLegacyApp } = service.setup(setupDeps); - registerLegacyApp({ id: 'baseApp:legacyApp1' } as any); + registerLegacyApp(createLegacyApp({ id: 'baseApp:legacyApp1' })); const { navigateToApp } = await service.start(startDeps); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 5b464737ffe07..c69b96274aa95 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -18,8 +18,8 @@ */ import React from 'react'; -import { BehaviorSubject, Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; import { createBrowserHistory, History } from 'history'; import { InjectedMetadataSetup } from '../injected_metadata'; @@ -27,18 +27,23 @@ import { HttpSetup, HttpStart } from '../http'; import { OverlayStart } from '../overlays'; import { ContextSetup, IContextContainer } from '../context'; import { AppRouter } from './ui'; -import { CapabilitiesService, Capabilities } from './capabilities'; +import { Capabilities, CapabilitiesService } from './capabilities'; import { App, + AppBase, AppLeaveHandler, - LegacyApp, AppMount, AppMountDeprecated, AppMounter, - LegacyAppMounter, - Mounter, + AppNavLinkStatus, + AppStatus, + AppUpdatableFields, + AppUpdater, InternalApplicationSetup, InternalApplicationStart, + LegacyApp, + LegacyAppMounter, + Mounter, } from './types'; import { getLeaveAction, isConfirmAction } from './application_leave'; @@ -62,12 +67,13 @@ interface StartDeps { // Mount functions with two arguments are assumed to expect deprecated `context` object. const isAppMountDeprecated = (mount: (...args: any[]) => any): mount is AppMountDeprecated => mount.length === 2; -const filterAvailable = (map: Map, capabilities: Capabilities) => - new Map( - [...map].filter( +function filterAvailable(m: Map, capabilities: Capabilities) { + return new Map( + [...m].filter( ([id]) => capabilities.navLinks[id] === undefined || capabilities.navLinks[id] === true ) ); +} const findMounter = (mounters: Map, appRoute?: string) => [...mounters].find(([, mounter]) => mounter.appRoute === appRoute); const getAppUrl = (mounters: Map, appId: string, path: string = '') => @@ -75,17 +81,25 @@ const getAppUrl = (mounters: Map, appId: string, path: string = .replace(/\/{2,}/g, '/') // Remove duplicate slashes .replace(/\/$/, ''); // Remove trailing slash +const allApplicationsFilter = '__ALL__'; + +interface AppUpdaterWrapper { + application: string; + updater: AppUpdater; +} + /** * Service that is responsible for registering new applications. * @internal */ export class ApplicationService { - private readonly apps = new Map(); - private readonly legacyApps = new Map(); + private readonly apps = new Map(); private readonly mounters = new Map(); private readonly capabilities = new CapabilitiesService(); private readonly appLeaveHandlers = new Map(); private currentAppId$ = new BehaviorSubject(undefined); + private readonly statusUpdaters$ = new BehaviorSubject>(new Map()); + private readonly subscriptions: Subscription[] = []; private stop$ = new Subject(); private registrationClosed = false; private history?: History; @@ -109,8 +123,22 @@ export class ApplicationService { this.navigate = (url, state) => // basePath not needed here because `history` is configured with basename this.history ? this.history.push(url, state) : redirectTo(basePath.prepend(url)); + this.mountContext = context.createContextContainer(); + const registerStatusUpdater = (application: string, updater$: Observable) => { + const updaterId = Symbol(); + const subscription = updater$.subscribe(updater => { + const nextValue = new Map(this.statusUpdaters$.getValue()); + nextValue.set(updaterId, { + application, + updater, + }); + this.statusUpdaters$.next(nextValue); + }); + this.subscriptions.push(subscription); + }; + return { registerMountContext: this.mountContext!.registerContext, register: (plugin, app) => { @@ -145,7 +173,17 @@ export class ApplicationService { this.currentAppId$.next(app.id); return unmount; }; - this.apps.set(app.id, app); + + const { updater$, ...appProps } = app; + this.apps.set(app.id, { + ...appProps, + status: app.status ?? AppStatus.accessible, + navLinkStatus: app.navLinkStatus ?? AppNavLinkStatus.default, + legacy: false, + }); + if (updater$) { + registerStatusUpdater(app.id, updater$); + } this.mounters.set(app.id, { appRoute: app.appRoute!, appBasePath: basePath.prepend(app.appRoute!), @@ -158,15 +196,25 @@ export class ApplicationService { if (this.registrationClosed) { throw new Error('Applications cannot be registered after "setup"'); - } else if (this.legacyApps.has(app.id)) { - throw new Error(`A legacy application is already registered with the id "${app.id}"`); + } else if (this.apps.has(app.id)) { + throw new Error(`An application is already registered with the id "${app.id}"`); } else if (basename && appRoute!.startsWith(basename)) { throw new Error('Cannot register an application route that includes HTTP base path'); } const appBasePath = basePath.prepend(appRoute); const mount: LegacyAppMounter = () => redirectTo(appBasePath); - this.legacyApps.set(app.id, app); + + const { updater$, ...appProps } = app; + this.apps.set(app.id, { + ...appProps, + status: app.status ?? AppStatus.accessible, + navLinkStatus: app.navLinkStatus ?? AppNavLinkStatus.default, + legacy: true, + }); + if (updater$) { + registerStatusUpdater(app.id, updater$); + } this.mounters.set(app.id, { appRoute, appBasePath, @@ -174,6 +222,8 @@ export class ApplicationService { unmountBeforeMounting: true, }); }, + registerAppUpdater: (appUpdater$: Observable) => + registerStatusUpdater(allApplicationsFilter, appUpdater$), }; } @@ -190,16 +240,35 @@ export class ApplicationService { http, }); const availableMounters = filterAvailable(this.mounters, capabilities); + const availableApps = filterAvailable(this.apps, capabilities); + + const applications$ = new BehaviorSubject(availableApps); + this.statusUpdaters$ + .pipe( + map(statusUpdaters => { + return new Map( + [...availableApps].map(([id, app]) => [ + id, + updateStatus(app, [...statusUpdaters.values()]), + ]) + ); + }) + ) + .subscribe(apps => applications$.next(apps)); return { - availableApps: filterAvailable(this.apps, capabilities), - availableLegacyApps: filterAvailable(this.legacyApps, capabilities), + applications$, capabilities, currentAppId$: this.currentAppId$.pipe(takeUntil(this.stop$)), registerMountContext: this.mountContext.registerContext, getUrlForApp: (appId, { path }: { path?: string } = {}) => getAppUrl(availableMounters, appId, path), navigateToApp: async (appId, { path, state }: { path?: string; state?: any } = {}) => { + const app = applications$.value.get(appId); + if (app && app.status !== AppStatus.accessible) { + // should probably redirect to the error page instead + throw new Error(`Trying to navigate to an inaccessible application: ${appId}`); + } if (await this.shouldNavigate(overlays)) { this.appLeaveHandlers.delete(this.currentAppId$.value!); this.navigate!(getAppUrl(availableMounters, appId, path), state); @@ -259,6 +328,32 @@ export class ApplicationService { public stop() { this.stop$.next(); this.currentAppId$.complete(); + this.statusUpdaters$.complete(); + this.subscriptions.forEach(sub => sub.unsubscribe()); window.removeEventListener('beforeunload', this.onBeforeUnload); } } + +const updateStatus = (app: T, statusUpdaters: AppUpdaterWrapper[]): T => { + let changes: Partial = {}; + statusUpdaters.forEach(wrapper => { + if (wrapper.application !== allApplicationsFilter && wrapper.application !== app.id) { + return; + } + const fields = wrapper.updater(app); + if (fields) { + changes = { + ...changes, + ...fields, + // status and navLinkStatus enums are ordered by reversed priority + // if multiple updaters wants to change these fields, we will always follow the priority order. + status: Math.max(changes.status ?? 0, fields.status ?? 0), + navLinkStatus: Math.max(changes.navLinkStatus ?? 0, fields.navLinkStatus ?? 0), + }; + } + }); + return { + ...app, + ...changes, + }; +}; diff --git a/src/core/public/application/index.ts b/src/core/public/application/index.ts index 17fec9261accf..e7ea330657648 100644 --- a/src/core/public/application/index.ts +++ b/src/core/public/application/index.ts @@ -27,6 +27,10 @@ export { AppUnmount, AppMountContext, AppMountParameters, + AppStatus, + AppNavLinkStatus, + AppUpdatableFields, + AppUpdater, ApplicationSetup, ApplicationStart, AppLeaveHandler, diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 4caf236979c37..0d955482d2226 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -34,6 +34,9 @@ import { SavedObjectsStart } from '../saved_objects'; /** @public */ export interface AppBase { + /** + * The unique identifier of the application + */ id: string; /** @@ -41,15 +44,62 @@ export interface AppBase { */ title: string; + /** + * The initial status of the application. + * Defaulting to `accessible` + */ + status?: AppStatus; + + /** + * The initial status of the application's navLink. + * Defaulting to `visible` if `status` is `accessible` and `hidden` if status is `inaccessible` + * See {@link AppNavLinkStatus} + */ + navLinkStatus?: AppNavLinkStatus; + + /** + * An {@link AppUpdater} observable that can be used to update the application {@link AppUpdatableFields} at runtime. + * + * @example + * + * How to update an application navLink at runtime + * + * ```ts + * // inside your plugin's setup function + * export class MyPlugin implements Plugin { + * private appUpdater = new BehaviorSubject(() => ({})); + * + * setup({ application }) { + * application.register({ + * id: 'my-app', + * title: 'My App', + * updater$: this.appUpdater, + * async mount(params) { + * const { renderApp } = await import('./application'); + * return renderApp(params); + * }, + * }); + * } + * + * start() { + * // later, when the navlink needs to be updated + * appUpdater.next(() => { + * navLinkStatus: AppNavLinkStatus.disabled, + * }) + * } + * ``` + */ + updater$?: Observable; + /** * An ordinal used to sort nav links relative to one another for display. */ order?: number; /** - * An observable for a tooltip shown when hovering over app link. + * A tooltip shown when hovering over app link. */ - tooltip$?: Observable; + tooltip?: string; /** * A EUI iconType that will be used for the app's icon. This icon @@ -67,8 +117,76 @@ export interface AppBase { * Custom capabilities defined by the app. */ capabilities?: Partial; + + /** + * Flag to keep track of legacy applications. + * For internal use only. any value will be overridden when registering an App. + * + * @internal + */ + legacy?: boolean; + + /** + * Hide the UI chrome when the application is mounted. Defaults to `false`. + * Takes precedence over chrome service visibility settings. + */ + chromeless?: boolean; } +/** + * Accessibility status of an application. + * + * @public + */ +export enum AppStatus { + /** + * Application is accessible. + */ + accessible = 0, + /** + * Application is not accessible. + */ + inaccessible = 1, +} + +/** + * Status of the application's navLink. + * + * @public + */ +export enum AppNavLinkStatus { + /** + * The application navLink will be `visible` if the application's {@link AppStatus} is set to `accessible` + * and `hidden` if the application status is set to `inaccessible`. + */ + default = 0, + /** + * The application navLink is visible and clickable in the navigation bar. + */ + visible = 1, + /** + * The application navLink is visible but inactive and not clickable in the navigation bar. + */ + disabled = 2, + /** + * The application navLink does not appear in the navigation bar. + */ + hidden = 3, +} + +/** + * Defines the list of fields that can be updated via an {@link AppUpdater}. + * @public + */ +export type AppUpdatableFields = Pick; + +/** + * Updater for applications. + * see {@link ApplicationSetup} + * @public + */ +export type AppUpdater = (app: AppBase) => Partial | undefined; + /** * Extension of {@link AppBase | common app properties} with the mount function. * @public @@ -374,6 +492,35 @@ export interface ApplicationSetup { */ register(app: App): void; + /** + * Register an application updater that can be used to change the {@link AppUpdatableFields} 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. + * + * @example + * + * How to register an application updater that disables some applications: + * + * ```ts + * // inside your plugin's setup function + * export class MyPlugin implements Plugin { + * setup({ application }) { + * application.registerAppUpdater( + * new BehaviorSubject(app => { + * if (myPluginApi.shouldDisable(app)) + * return { + * status: AppStatus.inaccessible, + * }; + * }) + * ); + * } + * } + * ``` + */ + registerAppUpdater(appUpdater$: Observable): void; + /** * Register a context provider for application mounting. Will only be available to applications that depend on the * plugin that registered this context. Deprecated, use {@link CoreSetup.getStartServices}. @@ -389,7 +536,7 @@ export interface ApplicationSetup { } /** @internal */ -export interface InternalApplicationSetup { +export interface InternalApplicationSetup extends Pick { /** * Register an mountable application to the system. * @param plugin - opaque ID of the plugin that registers this application @@ -462,16 +609,11 @@ export interface ApplicationStart { export interface InternalApplicationStart extends Pick { /** - * Apps available based on the current capabilities. Should be used - * to show navigation links and make routing decisions. - */ - availableApps: ReadonlyMap; - /** - * Apps available based on the current capabilities. Should be used - * to show navigation links and make routing decisions. - * @internal + * Apps available based on the current capabilities. + * Should be used to show navigation links and make routing decisions. + * Applications manually disabled from the client-side using {@link AppUpdater} */ - availableLegacyApps: ReadonlyMap; + applications$: Observable>; /** * Register a context provider for application mounting. Will only be available to applications that depend on the diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index d9c35b20db03b..abd04722a49f2 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -18,7 +18,7 @@ */ import * as Rx from 'rxjs'; -import { toArray } from 'rxjs/operators'; +import { take, toArray } from 'rxjs/operators'; import { shallow } from 'enzyme'; import React from 'react'; @@ -54,7 +54,9 @@ function defaultStartDeps(availableApps?: App[]) { }; if (availableApps) { - deps.application.availableApps = new Map(availableApps.map(app => [app.id, app])); + deps.application.applications$ = new Rx.BehaviorSubject>( + new Map(availableApps.map(app => [app.id, app])) + ); } return deps; @@ -211,13 +213,14 @@ describe('start', () => { new FakeApp('beta', true), new FakeApp('gamma', false), ]); - const { availableApps, navigateToApp } = startDeps.application; + const { applications$, navigateToApp } = startDeps.application; const { chrome, service } = await start({ startDeps }); const promise = chrome .getIsVisible$() .pipe(toArray()) .toPromise(); + const availableApps = await applications$.pipe(take(1)).toPromise(); [...availableApps.keys()].forEach(appId => navigateToApp(appId)); service.stop(); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 18c0c9870d72f..a674b49a8e134 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { BehaviorSubject, Observable, ReplaySubject, combineLatest, of, merge } from 'rxjs'; -import { map, takeUntil } from 'rxjs/operators'; +import { flatMap, map, takeUntil } from 'rxjs/operators'; import { parse } from 'url'; import { i18n } from '@kbn/i18n'; @@ -118,11 +118,12 @@ export class ChromeService { // combineLatest below regardless of having an application value yet. of(isEmbedded), application.currentAppId$.pipe( - map( - appId => - !!appId && - application.availableApps.has(appId) && - !!application.availableApps.get(appId)!.chromeless + flatMap(appId => + application.applications$.pipe( + map(applications => { + return !!appId && applications.has(appId) && !!applications.get(appId)!.chromeless; + }) + ) ) ) ); diff --git a/src/core/public/chrome/nav_links/nav_links_service.test.ts b/src/core/public/chrome/nav_links/nav_links_service.test.ts index 5a45491df28e7..3d9a4bfdb6a56 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.test.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.test.ts @@ -20,34 +20,47 @@ import { NavLinksService } from './nav_links_service'; import { take, map, takeLast } from 'rxjs/operators'; import { App, LegacyApp } from '../../application'; +import { BehaviorSubject } from 'rxjs'; -const mockAppService = { - availableApps: new Map( - ([ - { id: 'app1', order: 0, title: 'App 1', icon: 'app1' }, - { - id: 'app2', - order: -10, - title: 'App 2', - euiIconType: 'canvasApp', - }, - { id: 'chromelessApp', order: 20, title: 'Chromless App', chromeless: true }, - ] as App[]).map(app => [app.id, app]) - ), - availableLegacyApps: new Map( - ([ - { id: 'legacyApp1', order: 5, title: 'Legacy App 1', icon: 'legacyApp1', appUrl: '/app1' }, - { - id: 'legacyApp2', - order: -5, - title: 'Legacy App 2', - euiIconType: 'canvasApp', - appUrl: '/app2', - }, - { id: 'legacyApp3', order: 15, title: 'Legacy App 3', appUrl: '/app3' }, - ] as LegacyApp[]).map(app => [app.id, app]) - ), -} as any; +const availableApps = new Map([ + ['app1', { id: 'app1', order: 0, title: 'App 1', icon: 'app1' }], + [ + 'app2', + { + id: 'app2', + order: -10, + title: 'App 2', + euiIconType: 'canvasApp', + }, + ], + ['chromelessApp', { id: 'chromelessApp', order: 20, title: 'Chromless App', chromeless: true }], + [ + 'legacyApp1', + { + id: 'legacyApp1', + order: 5, + title: 'Legacy App 1', + icon: 'legacyApp1', + appUrl: '/app1', + legacy: true, + }, + ], + [ + 'legacyApp2', + { + id: 'legacyApp2', + order: -10, + title: 'Legacy App 2', + euiIconType: 'canvasApp', + appUrl: '/app2', + legacy: true, + }, + ], + [ + 'legacyApp3', + { id: 'legacyApp3', order: 20, title: 'Legacy App 3', appUrl: '/app3', legacy: true }, + ], +]); const mockHttp = { basePath: { @@ -57,10 +70,16 @@ const mockHttp = { describe('NavLinksService', () => { let service: NavLinksService; + let mockAppService: any; let start: ReturnType; beforeEach(() => { service = new NavLinksService(); + mockAppService = { + applications$: new BehaviorSubject>( + availableApps as any + ), + }; start = service.start({ application: mockAppService, http: mockHttp }); }); @@ -183,22 +202,36 @@ describe('NavLinksService', () => { .toPromise() ).toEqual(['legacyApp1']); }); + + it('still removes all other links when availableApps are re-emitted', async () => { + start.showOnly('legacyApp2'); + mockAppService.applications$.next(mockAppService.applications$.value); + expect( + await start + .getNavLinks$() + .pipe( + take(1), + map(links => links.map(l => l.id)) + ) + .toPromise() + ).toEqual(['legacyApp2']); + }); }); describe('#update()', () => { it('updates the navlinks and returns the updated link', async () => { - expect(start.update('legacyApp1', { hidden: true })).toMatchInlineSnapshot(` - Object { - "appUrl": "/app1", - "baseUrl": "http://localhost/wow/app1", - "hidden": true, - "icon": "legacyApp1", - "id": "legacyApp1", - "legacy": true, - "order": 5, - "title": "Legacy App 1", - } - `); + expect(start.update('legacyApp1', { hidden: true })).toEqual( + expect.objectContaining({ + appUrl: '/app1', + disabled: false, + hidden: true, + icon: 'legacyApp1', + id: 'legacyApp1', + legacy: true, + order: 5, + title: 'Legacy App 1', + }) + ); const hiddenLinkIds = await start .getNavLinks$() .pipe( @@ -212,6 +245,19 @@ describe('NavLinksService', () => { it('returns undefined if link does not exist', () => { expect(start.update('fake', { hidden: true })).toBeUndefined(); }); + + it('keeps the updated link when availableApps are re-emitted', async () => { + start.update('legacyApp1', { hidden: true }); + mockAppService.applications$.next(mockAppService.applications$.value); + const hiddenLinkIds = await start + .getNavLinks$() + .pipe( + take(1), + map(links => links.filter(l => l.hidden).map(l => l.id)) + ) + .toPromise(); + expect(hiddenLinkIds).toEqual(['legacyApp1']); + }); }); describe('#enableForcedAppSwitcherNavigation()', () => { diff --git a/src/core/public/chrome/nav_links/nav_links_service.ts b/src/core/public/chrome/nav_links/nav_links_service.ts index 31a729f90cd93..650ef77b6fe42 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.ts @@ -18,11 +18,13 @@ */ import { sortBy } from 'lodash'; -import { BehaviorSubject, ReplaySubject, Observable } from 'rxjs'; +import { BehaviorSubject, combineLatest, Observable, ReplaySubject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; -import { NavLinkWrapper, ChromeNavLinkUpdateableFields, ChromeNavLink } from './nav_link'; + import { InternalApplicationStart } from '../../application'; import { HttpStart } from '../../http'; +import { ChromeNavLink, ChromeNavLinkUpdateableFields, NavLinkWrapper } from './nav_link'; +import { toNavLink } from './to_nav_link'; interface StartDeps { application: InternalApplicationStart; @@ -95,39 +97,38 @@ export interface ChromeNavLinks { getForceAppSwitcherNavigation$(): Observable; } +type LinksUpdater = (navLinks: Map) => Map; + export class NavLinksService { private readonly stop$ = new ReplaySubject(1); public start({ application, http }: StartDeps): ChromeNavLinks { - const appLinks = [...application.availableApps] - .filter(([, app]) => !app.chromeless) - .map( - ([appId, app]) => - [ - appId, - new NavLinkWrapper({ - ...app, - legacy: false, - baseUrl: relativeToAbsolute(http.basePath.prepend(`/app/${appId}`)), - }), - ] as [string, NavLinkWrapper] - ); - - const legacyAppLinks = [...application.availableLegacyApps].map( - ([appId, app]) => - [ - appId, - new NavLinkWrapper({ - ...app, - legacy: true, - baseUrl: relativeToAbsolute(http.basePath.prepend(app.appUrl)), - }), - ] as [string, NavLinkWrapper] + const appLinks$ = application.applications$.pipe( + map(apps => { + return new Map( + [...apps] + .filter(([, app]) => !app.chromeless) + .map(([appId, app]) => [appId, toNavLink(app, http.basePath)]) + ); + }) ); - const navLinks$ = new BehaviorSubject>( - new Map([...legacyAppLinks, ...appLinks]) - ); + // now that availableApps$ is an observable, we need to keep record of all + // manual link modifications to be able to re-apply then after every + // availableApps$ changes. + const linkUpdaters$ = new BehaviorSubject([]); + const navLinks$ = new BehaviorSubject>(new Map()); + + combineLatest([appLinks$, linkUpdaters$]) + .pipe( + map(([appLinks, linkUpdaters]) => { + return linkUpdaters.reduce((links, updater) => updater(links), appLinks); + }) + ) + .subscribe(navlinks => { + navLinks$.next(navlinks); + }); + const forceAppSwitcherNavigation$ = new BehaviorSubject(false); return { @@ -153,7 +154,10 @@ export class NavLinksService { return; } - navLinks$.next(new Map([...navLinks$.value.entries()].filter(([linkId]) => linkId === id))); + const updater: LinksUpdater = navLinks => + new Map([...navLinks.entries()].filter(([linkId]) => linkId === id)); + + linkUpdaters$.next([...linkUpdaters$.value, updater]); }, update(id: string, values: ChromeNavLinkUpdateableFields) { @@ -161,17 +165,17 @@ export class NavLinksService { return; } - navLinks$.next( + const updater: LinksUpdater = navLinks => new Map( - [...navLinks$.value.entries()].map(([linkId, link]) => { + [...navLinks.entries()].map(([linkId, link]) => { return [linkId, link.id === id ? link.update(values) : link] as [ string, NavLinkWrapper ]; }) - ) - ); + ); + linkUpdaters$.next([...linkUpdaters$.value, updater]); return this.get(id); }, @@ -196,10 +200,3 @@ function sortNavLinks(navLinks: ReadonlyMap) { 'order' ); } - -function relativeToAbsolute(url: string) { - // convert all link urls to absolute urls - const a = document.createElement('a'); - a.setAttribute('href', url); - return a.href; -} diff --git a/src/core/public/chrome/nav_links/to_nav_link.test.ts b/src/core/public/chrome/nav_links/to_nav_link.test.ts new file mode 100644 index 0000000000000..23fdabe0f3430 --- /dev/null +++ b/src/core/public/chrome/nav_links/to_nav_link.test.ts @@ -0,0 +1,178 @@ +/* + * 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 { App, AppMount, AppNavLinkStatus, AppStatus, LegacyApp } from '../../application'; +import { toNavLink } from './to_nav_link'; + +import { httpServiceMock } from '../../mocks'; + +function mount() {} + +const app = (props: Partial = {}): App => ({ + mount: (mount as unknown) as AppMount, + id: 'some-id', + title: 'some-title', + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.default, + appRoute: `/app/some-id`, + legacy: false, + ...props, +}); + +const legacyApp = (props: Partial = {}): LegacyApp => ({ + appUrl: '/my-app-url', + id: 'some-id', + title: 'some-title', + status: AppStatus.accessible, + navLinkStatus: AppNavLinkStatus.default, + legacy: true, + ...props, +}); + +describe('toNavLink', () => { + const basePath = httpServiceMock.createSetupContract({ basePath: '/base-path' }).basePath; + + it('uses the application properties when creating the navLink', () => { + const link = toNavLink( + app({ + id: 'id', + title: 'title', + order: 12, + tooltip: 'tooltip', + euiIconType: 'my-icon', + }), + basePath + ); + expect(link.properties).toEqual( + expect.objectContaining({ + id: 'id', + title: 'title', + order: 12, + tooltip: 'tooltip', + euiIconType: 'my-icon', + }) + ); + }); + + it('flags legacy apps when converting to navLink', () => { + expect(toNavLink(app({}), basePath).properties.legacy).toEqual(false); + expect(toNavLink(legacyApp({}), basePath).properties.legacy).toEqual(true); + }); + + it('handles applications with custom app route', () => { + const link = toNavLink( + app({ + appRoute: '/my-route/my-path', + }), + basePath + ); + expect(link.properties.baseUrl).toEqual('http://localhost/base-path/my-route/my-path'); + }); + + it('uses appUrl when converting legacy applications', () => { + expect( + toNavLink( + legacyApp({ + appUrl: '/my-legacy-app/#foo', + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + baseUrl: 'http://localhost/base-path/my-legacy-app/#foo', + }) + ); + }); + + it('uses the application status when the navLinkStatus is set to default', () => { + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: false, + }) + ); + + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.inaccessible, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: true, + }) + ); + }); + + it('uses the navLinkStatus of the application to set the hidden and disabled properties', () => { + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.visible, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: false, + }) + ); + + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.hidden, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: false, + hidden: true, + }) + ); + + expect( + toNavLink( + app({ + navLinkStatus: AppNavLinkStatus.disabled, + }), + basePath + ).properties + ).toEqual( + expect.objectContaining({ + disabled: true, + hidden: false, + }) + ); + }); +}); diff --git a/src/core/public/chrome/nav_links/to_nav_link.ts b/src/core/public/chrome/nav_links/to_nav_link.ts new file mode 100644 index 0000000000000..18e4b7b26b6ba --- /dev/null +++ b/src/core/public/chrome/nav_links/to_nav_link.ts @@ -0,0 +1,48 @@ +/* + * 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 { App, AppNavLinkStatus, AppStatus, LegacyApp } from '../../application'; +import { IBasePath } from '../../http'; +import { NavLinkWrapper } from './nav_link'; + +export function toNavLink(app: App | LegacyApp, basePath: IBasePath): NavLinkWrapper { + const useAppStatus = app.navLinkStatus === AppNavLinkStatus.default; + return new NavLinkWrapper({ + ...app, + hidden: useAppStatus + ? app.status === AppStatus.inaccessible + : app.navLinkStatus === AppNavLinkStatus.hidden, + disabled: useAppStatus ? false : app.navLinkStatus === AppNavLinkStatus.disabled, + legacy: isLegacyApp(app), + baseUrl: isLegacyApp(app) + ? relativeToAbsolute(basePath.prepend(app.appUrl)) + : relativeToAbsolute(basePath.prepend(app.appRoute!)), + }); +} + +function relativeToAbsolute(url: string) { + // convert all link urls to absolute urls + const a = document.createElement('a'); + a.setAttribute('href', url); + return a.href; +} + +function isLegacyApp(app: App | LegacyApp): app is LegacyApp { + return app.legacy === true; +} diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 75f78ac8b2fa0..0447add491788 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -309,7 +309,7 @@ class HeaderUI extends Component { .filter(navLink => !navLink.hidden) .map(navLink => ({ key: navLink.id, - label: navLink.title, + label: navLink.tooltip ?? navLink.title, // Use href and onClick to support "open in new tab" and SPA navigation in the same link href: navLink.href, diff --git a/src/core/public/index.ts b/src/core/public/index.ts index ea704749c6131..5b17eccc37f8b 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -94,6 +94,10 @@ export { AppLeaveAction, AppLeaveDefaultAction, AppLeaveConfirmAction, + AppStatus, + AppNavLinkStatus, + AppUpdatableFields, + AppUpdater, } from './application'; export { diff --git a/src/core/public/legacy/legacy_service.ts b/src/core/public/legacy/legacy_service.ts index a4fdd86de5311..f906aff1759e2 100644 --- a/src/core/public/legacy/legacy_service.ts +++ b/src/core/public/legacy/legacy_service.ts @@ -81,6 +81,7 @@ export class LegacyPlatformService { ...core, getStartServices: () => this.startDependencies, application: { + ...core.application, register: notSupported(`core.application.register()`), registerMountContext: notSupported(`core.application.registerMountContext()`), }, diff --git a/src/core/public/notifications/toasts/global_toast_list.test.tsx b/src/core/public/notifications/toasts/global_toast_list.test.tsx index 61d73ac233188..dc2a9dabe791e 100644 --- a/src/core/public/notifications/toasts/global_toast_list.test.tsx +++ b/src/core/public/notifications/toasts/global_toast_list.test.tsx @@ -57,9 +57,9 @@ it('subscribes to toasts$ on mount and unsubscribes on unmount', () => { it('passes latest value from toasts$ to ', () => { const el = shallow( render({ - toasts$: Rx.from([[], [{ id: 1 }], [{ id: 1 }, { id: 2 }]]) as any, + toasts$: Rx.from([[], [{ id: '1' }], [{ id: '1' }, { id: '2' }]]) as any, }) ); - expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([{ id: 1 }, { id: 2 }]); + expect(el.find(EuiGlobalToastList).prop('toasts')).toEqual([{ id: '1' }, { id: '2' }]); }); diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 848f46605d4de..f146c2452868b 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -96,6 +96,7 @@ export function createPluginSetupContext< return { application: { register: app => deps.application.register(plugin.opaqueId, app), + registerAppUpdater: statusUpdater$ => deps.application.registerAppUpdater(statusUpdater$), registerMountContext: (contextName, provider) => deps.application.registerMountContext(plugin.opaqueId, contextName, provider), }, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index c76d6191de8a3..aef689162f45a 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -26,13 +26,18 @@ export interface App extends AppBase { // @public (undocumented) export interface AppBase { capabilities?: Partial; + chromeless?: boolean; euiIconType?: string; icon?: string; - // (undocumented) id: string; + // @internal + legacy?: boolean; + navLinkStatus?: AppNavLinkStatus; order?: number; + status?: AppStatus; title: string; - tooltip$?: Observable; + tooltip?: string; + updater$?: Observable; } // @public @@ -74,6 +79,7 @@ export type AppLeaveHandler = (factory: AppLeaveActionFactory) => AppLeaveAction // @public (undocumented) export interface ApplicationSetup { register(app: App): void; + registerAppUpdater(appUpdater$: Observable): void; // @deprecated registerMountContext(contextName: T, provider: IContextProvider): void; } @@ -123,9 +129,29 @@ export interface AppMountParameters { onAppLeave: (handler: AppLeaveHandler) => void; } +// @public +export enum AppNavLinkStatus { + default = 0, + disabled = 2, + hidden = 3, + visible = 1 +} + +// @public +export enum AppStatus { + accessible = 0, + inaccessible = 1 +} + // @public export type AppUnmount = () => void; +// @public +export type AppUpdatableFields = Pick; + +// @public +export type AppUpdater = (app: AppBase) => Partial | undefined; + // @public export interface Capabilities { [key: string]: Record>; diff --git a/src/core/server/config/deprecation/core_deprecations.test.ts b/src/core/server/config/deprecation/core_deprecations.test.ts index 7851522ec899f..b40dbdc1b6651 100644 --- a/src/core/server/config/deprecation/core_deprecations.test.ts +++ b/src/core/server/config/deprecation/core_deprecations.test.ts @@ -208,35 +208,4 @@ describe('core deprecations', () => { ).toEqual([`worker-src blob:`]); }); }); - - describe('elasticsearchUsernameDeprecation', () => { - it('logs a warning if elasticsearch.username is set to "elastic"', () => { - const { messages } = applyCoreDeprecations({ - elasticsearch: { - username: 'elastic', - }, - }); - expect(messages).toMatchInlineSnapshot(` - Array [ - "Setting elasticsearch.username to \\"elastic\\" is deprecated. You should use the \\"kibana\\" user instead.", - ] - `); - }); - - it('does not log a warning if elasticsearch.username is set to something besides "elastic"', () => { - const { messages } = applyCoreDeprecations({ - elasticsearch: { - username: 'otheruser', - }, - }); - expect(messages).toHaveLength(0); - }); - - it('does not log a warning if elasticsearch.username is unset', () => { - const { messages } = applyCoreDeprecations({ - elasticsearch: {}, - }); - expect(messages).toHaveLength(0); - }); - }); }); diff --git a/src/core/server/config/deprecation/core_deprecations.ts b/src/core/server/config/deprecation/core_deprecations.ts index e3b66414ee163..36fe95e05cb53 100644 --- a/src/core/server/config/deprecation/core_deprecations.ts +++ b/src/core/server/config/deprecation/core_deprecations.ts @@ -91,16 +91,6 @@ const cspRulesDeprecation: ConfigDeprecation = (settings, fromPath, log) => { return settings; }; -const elasticsearchUsernameDeprecation: ConfigDeprecation = (settings, _fromPath, log) => { - const username: string | undefined = get(settings, 'elasticsearch.username'); - if (username === 'elastic') { - log( - `Setting elasticsearch.username to "elastic" is deprecated. You should use the "kibana" user instead.` - ); - } - return settings; -}; - export const coreDeprecationProvider: ConfigDeprecationProvider = ({ unusedFromRoot, renameFromRoot, @@ -120,5 +110,4 @@ export const coreDeprecationProvider: ConfigDeprecationProvider = ({ dataPathDeprecation, rewriteBasePathDeprecation, cspRulesDeprecation, - elasticsearchUsernameDeprecation, ]; diff --git a/src/core/server/elasticsearch/elasticsearch_config.test.ts b/src/core/server/elasticsearch/elasticsearch_config.test.ts index c0db7369b4b99..1b4fc5eafec76 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.test.ts @@ -23,19 +23,32 @@ import { mockReadPkcs12Truststore, } from './elasticsearch_config.test.mocks'; -import { ElasticsearchConfig, config, ElasticsearchConfigType } from './elasticsearch_config'; -import { loggingServiceMock } from '../mocks'; -import { Logger } from '../logging'; - -const createElasticsearchConfig = (rawConfig: ElasticsearchConfigType, log?: Logger) => { - if (!log) { - log = loggingServiceMock.create().get('config'); - } - return new ElasticsearchConfig(rawConfig, log); +import { ElasticsearchConfig, config } from './elasticsearch_config'; +import { applyDeprecations, configDeprecationFactory } from '../config/deprecation'; + +const CONFIG_PATH = 'elasticsearch'; + +const applyElasticsearchDeprecations = (settings: Record = {}) => { + const deprecations = config.deprecations!(configDeprecationFactory); + const deprecationMessages: string[] = []; + const _config: any = {}; + _config[CONFIG_PATH] = settings; + const migrated = applyDeprecations( + _config, + deprecations.map(deprecation => ({ + deprecation, + path: CONFIG_PATH, + })), + msg => deprecationMessages.push(msg) + ); + return { + messages: deprecationMessages, + migrated, + }; }; test('set correct defaults', () => { - const configValue = createElasticsearchConfig(config.schema.validate({})); + const configValue = new ElasticsearchConfig(config.schema.validate({})); expect(configValue).toMatchInlineSnapshot(` ElasticsearchConfig { "apiVersion": "master", @@ -70,17 +83,17 @@ test('set correct defaults', () => { }); test('#hosts accepts both string and array of strings', () => { - let configValue = createElasticsearchConfig( + let configValue = new ElasticsearchConfig( config.schema.validate({ hosts: 'http://some.host:1234' }) ); expect(configValue.hosts).toEqual(['http://some.host:1234']); - configValue = createElasticsearchConfig( + configValue = new ElasticsearchConfig( config.schema.validate({ hosts: ['http://some.host:1234'] }) ); expect(configValue.hosts).toEqual(['http://some.host:1234']); - configValue = createElasticsearchConfig( + configValue = new ElasticsearchConfig( config.schema.validate({ hosts: ['http://some.host:1234', 'https://some.another.host'], }) @@ -89,17 +102,17 @@ test('#hosts accepts both string and array of strings', () => { }); test('#requestHeadersWhitelist accepts both string and array of strings', () => { - let configValue = createElasticsearchConfig( + let configValue = new ElasticsearchConfig( config.schema.validate({ requestHeadersWhitelist: 'token' }) ); expect(configValue.requestHeadersWhitelist).toEqual(['token']); - configValue = createElasticsearchConfig( + configValue = new ElasticsearchConfig( config.schema.validate({ requestHeadersWhitelist: ['token'] }) ); expect(configValue.requestHeadersWhitelist).toEqual(['token']); - configValue = createElasticsearchConfig( + configValue = new ElasticsearchConfig( config.schema.validate({ requestHeadersWhitelist: ['token', 'X-Forwarded-Proto'], }) @@ -122,7 +135,7 @@ describe('reads files', () => { }); it('reads certificate authorities when ssl.keystore.path is specified', () => { - const configValue = createElasticsearchConfig( + const configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { keystore: { path: 'some-path' } } }) ); expect(mockReadPkcs12Keystore).toHaveBeenCalledTimes(1); @@ -130,7 +143,7 @@ describe('reads files', () => { }); it('reads certificate authorities when ssl.truststore.path is specified', () => { - const configValue = createElasticsearchConfig( + const configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { truststore: { path: 'some-path' } } }) ); expect(mockReadPkcs12Truststore).toHaveBeenCalledTimes(1); @@ -138,21 +151,21 @@ describe('reads files', () => { }); it('reads certificate authorities when ssl.certificateAuthorities is specified', () => { - let configValue = createElasticsearchConfig( + let configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { certificateAuthorities: 'some-path' } }) ); expect(mockReadFileSync).toHaveBeenCalledTimes(1); expect(configValue.ssl.certificateAuthorities).toEqual(['content-of-some-path']); mockReadFileSync.mockClear(); - configValue = createElasticsearchConfig( + configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { certificateAuthorities: ['some-path'] } }) ); expect(mockReadFileSync).toHaveBeenCalledTimes(1); expect(configValue.ssl.certificateAuthorities).toEqual(['content-of-some-path']); mockReadFileSync.mockClear(); - configValue = createElasticsearchConfig( + configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { certificateAuthorities: ['some-path', 'another-path'] }, }) @@ -165,7 +178,7 @@ describe('reads files', () => { }); it('reads certificate authorities when ssl.keystore.path, ssl.truststore.path, and ssl.certificateAuthorities are specified', () => { - const configValue = createElasticsearchConfig( + const configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { keystore: { path: 'some-path' }, @@ -185,7 +198,7 @@ describe('reads files', () => { }); it('reads a private key and certificate when ssl.keystore.path is specified', () => { - const configValue = createElasticsearchConfig( + const configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { keystore: { path: 'some-path' } } }) ); expect(mockReadPkcs12Keystore).toHaveBeenCalledTimes(1); @@ -194,7 +207,7 @@ describe('reads files', () => { }); it('reads a private key when ssl.key is specified', () => { - const configValue = createElasticsearchConfig( + const configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { key: 'some-path' } }) ); expect(mockReadFileSync).toHaveBeenCalledTimes(1); @@ -202,7 +215,7 @@ describe('reads files', () => { }); it('reads a certificate when ssl.certificate is specified', () => { - const configValue = createElasticsearchConfig( + const configValue = new ElasticsearchConfig( config.schema.validate({ ssl: { certificate: 'some-path' } }) ); expect(mockReadFileSync).toHaveBeenCalledTimes(1); @@ -225,8 +238,8 @@ describe('throws when config is invalid', () => { it('throws if key is invalid', () => { const value = { ssl: { key: '/invalid/key' } }; - expect(() => - createElasticsearchConfig(config.schema.validate(value)) + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) ).toThrowErrorMatchingInlineSnapshot( `"ENOENT: no such file or directory, open '/invalid/key'"` ); @@ -234,8 +247,8 @@ describe('throws when config is invalid', () => { it('throws if certificate is invalid', () => { const value = { ssl: { certificate: '/invalid/cert' } }; - expect(() => - createElasticsearchConfig(config.schema.validate(value)) + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) ).toThrowErrorMatchingInlineSnapshot( `"ENOENT: no such file or directory, open '/invalid/cert'"` ); @@ -243,34 +256,40 @@ describe('throws when config is invalid', () => { it('throws if certificateAuthorities is invalid', () => { const value = { ssl: { certificateAuthorities: '/invalid/ca' } }; - expect(() => - createElasticsearchConfig(config.schema.validate(value)) + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) ).toThrowErrorMatchingInlineSnapshot(`"ENOENT: no such file or directory, open '/invalid/ca'"`); }); it('throws if keystore path is invalid', () => { const value = { ssl: { keystore: { path: '/invalid/keystore' } } }; - expect(() => - createElasticsearchConfig(config.schema.validate(value)) + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) ).toThrowErrorMatchingInlineSnapshot( `"ENOENT: no such file or directory, open '/invalid/keystore'"` ); }); - it('throws if keystore does not contain a key or certificate', () => { + it('throws if keystore does not contain a key', () => { mockReadPkcs12Keystore.mockReturnValueOnce({}); const value = { ssl: { keystore: { path: 'some-path' } } }; - expect(() => - createElasticsearchConfig(config.schema.validate(value)) - ).toThrowErrorMatchingInlineSnapshot( - `"Did not find key or certificate in Elasticsearch keystore."` - ); + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot(`"Did not find key in Elasticsearch keystore."`); + }); + + it('throws if keystore does not contain a certificate', () => { + mockReadPkcs12Keystore.mockReturnValueOnce({ key: 'foo' }); + const value = { ssl: { keystore: { path: 'some-path' } } }; + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) + ).toThrowErrorMatchingInlineSnapshot(`"Did not find certificate in Elasticsearch keystore."`); }); it('throws if truststore path is invalid', () => { const value = { ssl: { keystore: { path: '/invalid/truststore' } } }; - expect(() => - createElasticsearchConfig(config.schema.validate(value)) + expect( + () => new ElasticsearchConfig(config.schema.validate(value)) ).toThrowErrorMatchingInlineSnapshot( `"ENOENT: no such file or directory, open '/invalid/truststore'"` ); @@ -291,31 +310,47 @@ describe('throws when config is invalid', () => { }); }); -describe('logs warnings', () => { - let logger: ReturnType; - let log: Logger; +describe('deprecations', () => { + it('logs a warning if elasticsearch.username is set to "elastic"', () => { + const { messages } = applyElasticsearchDeprecations({ username: 'elastic' }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Setting [${CONFIG_PATH}.username] to \\"elastic\\" is deprecated. You should use the \\"kibana\\" user instead.", + ] + `); + }); - beforeAll(() => { - mockReadFileSync.mockResolvedValue('foo'); + it('does not log a warning if elasticsearch.username is set to something besides "elastic"', () => { + const { messages } = applyElasticsearchDeprecations({ username: 'otheruser' }); + expect(messages).toHaveLength(0); }); - beforeEach(() => { - logger = loggingServiceMock.create(); - log = logger.get('config'); + it('does not log a warning if elasticsearch.username is unset', () => { + const { messages } = applyElasticsearchDeprecations({}); + expect(messages).toHaveLength(0); }); - it('warns if ssl.key is set and ssl.certificate is not', () => { - createElasticsearchConfig(config.schema.validate({ ssl: { key: 'some-path' } }), log); - expect(loggingServiceMock.collect(logger).warn[0][0]).toMatchInlineSnapshot( - `"Detected a key without a certificate; mutual TLS authentication is disabled."` - ); + it('logs a warning if ssl.key is set and ssl.certificate is not', () => { + const { messages } = applyElasticsearchDeprecations({ ssl: { key: '' } }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Setting [${CONFIG_PATH}.ssl.key] without [${CONFIG_PATH}.ssl.certificate] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.", + ] + `); }); - it('warns if ssl.certificate is set and ssl.key is not', () => { - createElasticsearchConfig(config.schema.validate({ ssl: { certificate: 'some-path' } }), log); - expect(loggingServiceMock.collect(logger).warn[0][0]).toMatchInlineSnapshot( - `"Detected a certificate without a key; mutual TLS authentication is disabled."` - ); + it('logs a warning if ssl.certificate is set and ssl.key is not', () => { + const { messages } = applyElasticsearchDeprecations({ ssl: { certificate: '' } }); + expect(messages).toMatchInlineSnapshot(` + Array [ + "Setting [${CONFIG_PATH}.ssl.certificate] without [${CONFIG_PATH}.ssl.key] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.", + ] + `); + }); + + it('does not log a warning if both ssl.key and ssl.certificate are set', () => { + const { messages } = applyElasticsearchDeprecations({ ssl: { key: '', certificate: '' } }); + expect(messages).toEqual([]); }); }); diff --git a/src/core/server/elasticsearch/elasticsearch_config.ts b/src/core/server/elasticsearch/elasticsearch_config.ts index 815005f65c6e7..5f06c51a53d53 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.ts @@ -20,92 +20,120 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { Duration } from 'moment'; import { readFileSync } from 'fs'; +import { ConfigDeprecationProvider } from 'src/core/server'; import { readPkcs12Keystore, readPkcs12Truststore } from '../../utils'; -import { Logger } from '../logging'; +import { ServiceConfigDescriptor } from '../internal_types'; const hostURISchema = schema.uri({ scheme: ['http', 'https'] }); export const DEFAULT_API_VERSION = 'master'; -export type ElasticsearchConfigType = TypeOf; +export type ElasticsearchConfigType = TypeOf; type SslConfigSchema = ElasticsearchConfigType['ssl']; -export const config = { - path: 'elasticsearch', - schema: schema.object({ - sniffOnStart: schema.boolean({ defaultValue: false }), - sniffInterval: schema.oneOf([schema.duration(), schema.literal(false)], { - defaultValue: false, - }), - sniffOnConnectionFault: schema.boolean({ defaultValue: false }), - hosts: schema.oneOf([hostURISchema, schema.arrayOf(hostURISchema, { minSize: 1 })], { - defaultValue: 'http://localhost:9200', - }), - preserveHost: schema.boolean({ defaultValue: true }), - username: schema.maybe( - schema.conditional( - schema.contextRef('dist'), - false, - schema.string({ - validate: rawConfig => { - if (rawConfig === 'elastic') { - return ( - 'value of "elastic" is forbidden. This is a superuser account that can obfuscate ' + - 'privilege-related issues. You should use the "kibana" user instead.' - ); - } - }, - }), - schema.string() - ) - ), - password: schema.maybe(schema.string()), - requestHeadersWhitelist: schema.oneOf([schema.string(), schema.arrayOf(schema.string())], { - defaultValue: ['authorization'], - }), - customHeaders: schema.recordOf(schema.string(), schema.string(), { defaultValue: {} }), - shardTimeout: schema.duration({ defaultValue: '30s' }), - requestTimeout: schema.duration({ defaultValue: '30s' }), - pingTimeout: schema.duration({ defaultValue: schema.siblingRef('requestTimeout') }), - startupTimeout: schema.duration({ defaultValue: '5s' }), - logQueries: schema.boolean({ defaultValue: false }), - ssl: schema.object( - { - verificationMode: schema.oneOf( - [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], - { defaultValue: 'full' } - ), - certificateAuthorities: schema.maybe( - schema.oneOf([schema.string(), schema.arrayOf(schema.string(), { minSize: 1 })]) - ), - certificate: schema.maybe(schema.string()), - key: schema.maybe(schema.string()), - keyPassphrase: schema.maybe(schema.string()), - keystore: schema.object({ - path: schema.maybe(schema.string()), - password: schema.maybe(schema.string()), - }), - truststore: schema.object({ - path: schema.maybe(schema.string()), - password: schema.maybe(schema.string()), - }), - alwaysPresentCertificate: schema.boolean({ defaultValue: false }), - }, - { +const configSchema = schema.object({ + sniffOnStart: schema.boolean({ defaultValue: false }), + sniffInterval: schema.oneOf([schema.duration(), schema.literal(false)], { + defaultValue: false, + }), + sniffOnConnectionFault: schema.boolean({ defaultValue: false }), + hosts: schema.oneOf([hostURISchema, schema.arrayOf(hostURISchema, { minSize: 1 })], { + defaultValue: 'http://localhost:9200', + }), + preserveHost: schema.boolean({ defaultValue: true }), + username: schema.maybe( + schema.conditional( + schema.contextRef('dist'), + false, + schema.string({ validate: rawConfig => { - if (rawConfig.key && rawConfig.keystore.path) { - return 'cannot use [key] when [keystore.path] is specified'; - } - if (rawConfig.certificate && rawConfig.keystore.path) { - return 'cannot use [certificate] when [keystore.path] is specified'; + if (rawConfig === 'elastic') { + return ( + 'value of "elastic" is forbidden. This is a superuser account that can obfuscate ' + + 'privilege-related issues. You should use the "kibana" user instead.' + ); } }, - } - ), - apiVersion: schema.string({ defaultValue: DEFAULT_API_VERSION }), - healthCheck: schema.object({ delay: schema.duration({ defaultValue: 2500 }) }), - ignoreVersionMismatch: schema.boolean({ defaultValue: false }), + }), + schema.string() + ) + ), + password: schema.maybe(schema.string()), + requestHeadersWhitelist: schema.oneOf([schema.string(), schema.arrayOf(schema.string())], { + defaultValue: ['authorization'], }), + customHeaders: schema.recordOf(schema.string(), schema.string(), { defaultValue: {} }), + shardTimeout: schema.duration({ defaultValue: '30s' }), + requestTimeout: schema.duration({ defaultValue: '30s' }), + pingTimeout: schema.duration({ defaultValue: schema.siblingRef('requestTimeout') }), + startupTimeout: schema.duration({ defaultValue: '5s' }), + logQueries: schema.boolean({ defaultValue: false }), + ssl: schema.object( + { + verificationMode: schema.oneOf( + [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], + { defaultValue: 'full' } + ), + certificateAuthorities: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string(), { minSize: 1 })]) + ), + certificate: schema.maybe(schema.string()), + key: schema.maybe(schema.string()), + keyPassphrase: schema.maybe(schema.string()), + keystore: schema.object({ + path: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + }), + truststore: schema.object({ + path: schema.maybe(schema.string()), + password: schema.maybe(schema.string()), + }), + alwaysPresentCertificate: schema.boolean({ defaultValue: false }), + }, + { + validate: rawConfig => { + if (rawConfig.key && rawConfig.keystore.path) { + return 'cannot use [key] when [keystore.path] is specified'; + } + if (rawConfig.certificate && rawConfig.keystore.path) { + return 'cannot use [certificate] when [keystore.path] is specified'; + } + }, + } + ), + apiVersion: schema.string({ defaultValue: DEFAULT_API_VERSION }), + healthCheck: schema.object({ delay: schema.duration({ defaultValue: 2500 }) }), + ignoreVersionMismatch: schema.boolean({ defaultValue: false }), +}); + +const deprecations: ConfigDeprecationProvider = () => [ + (settings, fromPath, log) => { + const es = settings[fromPath]; + if (!es) { + return settings; + } + if (es.username === 'elastic') { + log( + `Setting [${fromPath}.username] to "elastic" is deprecated. You should use the "kibana" user instead.` + ); + } + if (es.ssl?.key !== undefined && es.ssl?.certificate === undefined) { + log( + `Setting [${fromPath}.ssl.key] without [${fromPath}.ssl.certificate] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.` + ); + } else if (es.ssl?.certificate !== undefined && es.ssl?.key === undefined) { + log( + `Setting [${fromPath}.ssl.certificate] without [${fromPath}.ssl.key] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.` + ); + } + return settings; + }, +]; + +export const config: ServiceConfigDescriptor = { + path: 'elasticsearch', + schema: configSchema, + deprecations, }; export class ElasticsearchConfig { @@ -205,7 +233,7 @@ export class ElasticsearchConfig { */ public readonly customHeaders: ElasticsearchConfigType['customHeaders']; - constructor(rawConfig: ElasticsearchConfigType, log: Logger) { + constructor(rawConfig: ElasticsearchConfigType) { this.ignoreVersionMismatch = rawConfig.ignoreVersionMismatch; this.apiVersion = rawConfig.apiVersion; this.logQueries = rawConfig.logQueries; @@ -227,12 +255,6 @@ export class ElasticsearchConfig { const { alwaysPresentCertificate, verificationMode } = rawConfig.ssl; const { key, keyPassphrase, certificate, certificateAuthorities } = readKeyAndCerts(rawConfig); - if (key && !certificate) { - log.warn(`Detected a key without a certificate; mutual TLS authentication is disabled.`); - } else if (certificate && !key) { - log.warn(`Detected a certificate without a key; mutual TLS authentication is disabled.`); - } - this.ssl = { alwaysPresentCertificate, key, @@ -261,8 +283,10 @@ const readKeyAndCerts = (rawConfig: ElasticsearchConfigType) => { rawConfig.ssl.keystore.path, rawConfig.ssl.keystore.password ); - if (!keystore.key && !keystore.cert) { - throw new Error(`Did not find key or certificate in Elasticsearch keystore.`); + if (!keystore.key) { + throw new Error(`Did not find key in Elasticsearch keystore.`); + } else if (!keystore.cert) { + throw new Error(`Did not find certificate in Elasticsearch keystore.`); } key = keystore.key; certificate = keystore.cert; diff --git a/src/core/server/elasticsearch/elasticsearch_service.mock.ts b/src/core/server/elasticsearch/elasticsearch_service.mock.ts index 1b52f22c4da09..a4e51ca55b3e7 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.mock.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.mock.ts @@ -74,8 +74,6 @@ const createInternalSetupContractMock = () => { legacy: { config$: new BehaviorSubject({} as ElasticsearchConfig), }, - adminClient$: new BehaviorSubject(createClusterClientMock()), - dataClient$: new BehaviorSubject(createClusterClientMock()), }; setupContract.adminClient.asScoped.mockReturnValue(createScopedClusterClientMock()); setupContract.dataClient.asScoped.mockReturnValue(createScopedClusterClientMock()); diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index 9f694ac1c46da..5a7d223fec7ad 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -21,7 +21,7 @@ import { first } from 'rxjs/operators'; import { MockClusterClient } from './elasticsearch_service.test.mocks'; -import { BehaviorSubject, combineLatest } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { Env } from '../config'; import { getEnvOptions } from '../config/__mocks__/env'; import { CoreContext } from '../core_context'; @@ -91,44 +91,6 @@ describe('#setup', () => { expect(mockDataClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); }); - it('returns data and admin client observables as a part of the contract', async () => { - const mockAdminClusterClientInstance = { close: jest.fn() }; - const mockDataClusterClientInstance = { close: jest.fn() }; - MockClusterClient.mockImplementationOnce( - () => mockAdminClusterClientInstance - ).mockImplementationOnce(() => mockDataClusterClientInstance); - - const setupContract = await elasticsearchService.setup(deps); - - const [esConfig, adminClient, dataClient] = await combineLatest( - setupContract.legacy.config$, - setupContract.adminClient$, - setupContract.dataClient$ - ) - .pipe(first()) - .toPromise(); - - expect(adminClient).toBe(mockAdminClusterClientInstance); - expect(dataClient).toBe(mockDataClusterClientInstance); - - expect(MockClusterClient).toHaveBeenCalledTimes(2); - expect(MockClusterClient).toHaveBeenNthCalledWith( - 1, - esConfig, - expect.objectContaining({ context: ['elasticsearch', 'admin'] }), - undefined - ); - expect(MockClusterClient).toHaveBeenNthCalledWith( - 2, - esConfig, - expect.objectContaining({ context: ['elasticsearch', 'data'] }), - expect.any(Function) - ); - - expect(mockAdminClusterClientInstance.close).not.toHaveBeenCalled(); - expect(mockDataClusterClientInstance.close).not.toHaveBeenCalled(); - }); - describe('#createClient', () => { it('allows to specify config properties', async () => { const setupContract = await elasticsearchService.setup(deps); diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index de32e7f6cf225..aba246ce66fb5 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -52,7 +52,7 @@ export class ElasticsearchService implements CoreService('elasticsearch') - .pipe(map(rawConfig => new ElasticsearchConfig(rawConfig, coreContext.logger.get('config')))); + .pipe(map(rawConfig => new ElasticsearchConfig(rawConfig))); } public async setup(deps: SetupDeps): Promise { @@ -152,8 +152,6 @@ export class ElasticsearchService implements CoreService clients.config)) }, - adminClient$, - dataClient$, adminClient, dataClient, diff --git a/src/core/server/elasticsearch/types.ts b/src/core/server/elasticsearch/types.ts index 22340bf3f2fc6..899b273c5c60a 100644 --- a/src/core/server/elasticsearch/types.ts +++ b/src/core/server/elasticsearch/types.ts @@ -77,7 +77,4 @@ export interface InternalElasticsearchServiceSetup extends ElasticsearchServiceS readonly legacy: { readonly config$: Observable; }; - - readonly adminClient$: Observable; - readonly dataClient$: Observable; } diff --git a/src/core/server/http/integration_tests/core_service.test.mocks.ts b/src/core/server/http/integration_tests/core_service.test.mocks.ts index 3982df567ed7c..6fa3357168027 100644 --- a/src/core/server/http/integration_tests/core_service.test.mocks.ts +++ b/src/core/server/http/integration_tests/core_service.test.mocks.ts @@ -16,8 +16,11 @@ * specific language governing permissions and limitations * under the License. */ +import { elasticsearchServiceMock } from '../../elasticsearch/elasticsearch_service.mock'; export const clusterClientMock = jest.fn(); jest.doMock('../../elasticsearch/scoped_cluster_client', () => ({ - ScopedClusterClient: clusterClientMock, + ScopedClusterClient: clusterClientMock.mockImplementation(function() { + return elasticsearchServiceMock.createScopedClusterClient(); + }), })); diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index f3867faa2ae75..65c4f1432721d 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -133,7 +133,7 @@ describe('http service', () => { const { http } = await root.setup(); const { registerAuth } = http; - await registerAuth((req, res, toolkit) => { + registerAuth((req, res, toolkit) => { return toolkit.authenticated({ responseHeaders: authResponseHeader }); }); @@ -157,7 +157,7 @@ describe('http service', () => { const { http } = await root.setup(); const { registerAuth } = http; - await registerAuth((req, res, toolkit) => { + registerAuth((req, res, toolkit) => { return toolkit.authenticated({ responseHeaders: authResponseHeader }); }); @@ -222,12 +222,15 @@ describe('http service', () => { const { http } = await root.setup(); const { registerAuth, createRouter } = http; - await registerAuth((req, res, toolkit) => - toolkit.authenticated({ requestHeaders: authHeaders }) - ); + registerAuth((req, res, toolkit) => toolkit.authenticated({ requestHeaders: authHeaders })); const router = createRouter('/new-platform'); - router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); + router.get({ path: '/', validate: false }, async (context, req, res) => { + // it forces client initialization since the core creates them lazily. + await context.core.elasticsearch.adminClient.callAsCurrentUser('ping'); + await context.core.elasticsearch.dataClient.callAsCurrentUser('ping'); + return res.ok(); + }); await root.start(); @@ -247,7 +250,12 @@ describe('http service', () => { const { createRouter } = http; const router = createRouter('/new-platform'); - router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); + router.get({ path: '/', validate: false }, async (context, req, res) => { + // it forces client initialization since the core creates them lazily. + await context.core.elasticsearch.adminClient.callAsCurrentUser('ping'); + await context.core.elasticsearch.dataClient.callAsCurrentUser('ping'); + return res.ok(); + }); await root.start(); diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index c4f3bf6caf5bd..bf7dc14c73265 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -450,11 +450,11 @@ export interface AuthToolkit { export class BasePath { // @internal constructor(serverBasePath?: string); - get: (request: KibanaRequest | LegacyRequest) => string; + get: (request: LegacyRequest | KibanaRequest) => string; prepend: (path: string) => string; remove: (path: string) => string; readonly serverBasePath: string; - set: (request: KibanaRequest | LegacyRequest, requestSpecificBasePath: string) => void; + set: (request: LegacyRequest | KibanaRequest, requestSpecificBasePath: string) => void; } // Warning: (ae-forgotten-export) The symbol "BootstrapArgs" needs to be exported by the entry point index.d.ts diff --git a/src/core/server/server.ts b/src/core/server/server.ts index eced24b84908c..7c3f9f249db13 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -17,7 +17,6 @@ * under the License. */ -import { take } from 'rxjs/operators'; import { Type } from '@kbn/config-schema'; import { @@ -216,9 +215,6 @@ export class Server { coreId, 'core', async (context, req, res): Promise => { - // it consumes elasticsearch observables to provide the same client throughout the context lifetime. - const adminClient = await coreSetup.elasticsearch.adminClient$.pipe(take(1)).toPromise(); - const dataClient = await coreSetup.elasticsearch.dataClient$.pipe(take(1)).toPromise(); const savedObjectsClient = coreSetup.savedObjects.getScopedClient(req); const uiSettingsClient = coreSetup.uiSettings.asScopedToClient(savedObjectsClient); @@ -230,8 +226,8 @@ export class Server { client: savedObjectsClient, }, elasticsearch: { - adminClient: adminClient.asScoped(req), - dataClient: dataClient.asScoped(req), + adminClient: coreSetup.elasticsearch.adminClient.asScoped(req), + dataClient: coreSetup.elasticsearch.dataClient.asScoped(req), }, uiSettings: { client: uiSettingsClient, @@ -256,6 +252,10 @@ export class Server { ]; this.configService.addDeprecationProvider(rootConfigPath, coreDeprecationProvider); + this.configService.addDeprecationProvider( + elasticsearchConfig.path, + elasticsearchConfig.deprecations! + ); this.configService.addDeprecationProvider( uiSettingsConfig.path, uiSettingsConfig.deprecations! diff --git a/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/console_editor/editor.tsx b/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/console_editor/editor.tsx index 40b9cc4640eef..761a252b56a87 100644 --- a/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/console_editor/editor.tsx +++ b/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/console_editor/editor.tsx @@ -164,7 +164,7 @@ function EditorUI() { mappings.retrieveAutoCompleteInfo(); - const unsubscribeResizer = subscribeResizeChecker(editorRef.current!, editor.getCoreEditor()); + const unsubscribeResizer = subscribeResizeChecker(editorRef.current!, editor); setupAutosave(); return () => { diff --git a/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/subscribe_console_resize_checker.ts b/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/subscribe_console_resize_checker.ts index 4ecd5d415833c..1adc56d47927b 100644 --- a/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/subscribe_console_resize_checker.ts +++ b/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/subscribe_console_resize_checker.ts @@ -22,8 +22,15 @@ export function subscribeResizeChecker(el: HTMLElement, ...editors: any[]) { const checker = new ResizeChecker(el); checker.on('resize', () => editors.forEach(e => { - e.resize(); - if (e.updateActionsBar) e.updateActionsBar(); + if (e.getCoreEditor) { + e.getCoreEditor().resize(); + } else { + e.resize(); + } + + if (e.updateActionsBar) { + e.updateActionsBar(); + } }) ); return () => checker.destroy(); diff --git a/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts b/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts index 608c73335b3e5..6262c304e307b 100644 --- a/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts +++ b/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts @@ -297,30 +297,30 @@ export class LegacyCoreEditor implements CoreEditor { // pageY is relative to page, so subtract the offset // from pageY to get the new top value const offsetFromPage = $(this.editor.container).offset()!.top; - const startRow = range.start.lineNumber - 1; + const startLine = range.start.lineNumber; const startColumn = range.start.column; - const firstLine = this.getLineValue(startRow); + const firstLine = this.getLineValue(startLine); const maxLineLength = this.getWrapLimit() - 5; const isWrapping = firstLine.length > maxLineLength; - const getScreenCoords = (row: number) => - this.editor.renderer.textToScreenCoordinates(row, startColumn).pageY - offsetFromPage; - const topOfReq = getScreenCoords(startRow); + const getScreenCoords = (line: number) => + this.editor.renderer.textToScreenCoordinates(line - 1, startColumn).pageY - offsetFromPage; + const topOfReq = getScreenCoords(startLine); if (topOfReq >= 0) { let offset = 0; if (isWrapping) { // Try get the line height of the text area in pixels. const textArea = $(this.editor.container.querySelector('textArea')!); - const hasRoomOnNextLine = this.getLineValue(startRow + 1).length < maxLineLength; + const hasRoomOnNextLine = this.getLineValue(startLine).length < maxLineLength; if (textArea && hasRoomOnNextLine) { // Line height + the number of wraps we have on a line. - offset += this.getLineValue(startRow).length * textArea.height()!; + offset += this.getLineValue(startLine).length * textArea.height()!; } else { - if (startRow > 0) { - this.setActionsBar(getScreenCoords(startRow - 1)); + if (startLine > 1) { + this.setActionsBar(getScreenCoords(startLine - 1)); return; } - this.setActionsBar(getScreenCoords(startRow + 1)); + this.setActionsBar(getScreenCoords(startLine + 1)); return; } } diff --git a/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/smart_resize.ts b/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/smart_resize.ts index b88e0e44591d8..7c4d871c4d73e 100644 --- a/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/smart_resize.ts +++ b/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/smart_resize.ts @@ -24,7 +24,7 @@ export default function(editor: any) { const resize = editor.resize; const throttledResize = throttle(() => { - resize.call(editor); + resize.call(editor, false); // Keep current top line in view when resizing to avoid losing user context const userRow = get(throttledResize, 'topRow', 0); diff --git a/src/legacy/core_plugins/console/public/np_ready/lib/utils/__tests__/utils_string_expanding.txt b/src/legacy/core_plugins/console/public/np_ready/lib/utils/__tests__/utils_string_expanding.txt index 88467ab3672cd..7de874c244e74 100644 --- a/src/legacy/core_plugins/console/public/np_ready/lib/utils/__tests__/utils_string_expanding.txt +++ b/src/legacy/core_plugins/console/public/np_ready/lib/utils/__tests__/utils_string_expanding.txt @@ -52,3 +52,33 @@ Correctly handle new lines in triple quotes SELECT * FROM "TABLE" """ } +========== +Single quotes escaped special case, start and end +------------------------------------- +{ + "query": "\"test\"" +} +------------------------------------- +{ + "query": "\"test\"" +} +========== +Single quotes escaped special case, start +------------------------------------- +{ + "query": "\"test" +} +------------------------------------- +{ + "query": "\"test" +} +========== +Single quotes escaped special case, end +------------------------------------- +{ + "query": "test\"" +} +------------------------------------- +{ + "query": "test\"" +} diff --git a/src/legacy/core_plugins/console/public/np_ready/lib/utils/utils.ts b/src/legacy/core_plugins/console/public/np_ready/lib/utils/utils.ts index a7f59acf1d77b..0b10938abe704 100644 --- a/src/legacy/core_plugins/console/public/np_ready/lib/utils/utils.ts +++ b/src/legacy/core_plugins/console/public/np_ready/lib/utils/utils.ts @@ -84,6 +84,20 @@ export function expandLiteralStrings(data: string) { // Expand to triple quotes if there are _any_ slashes if (string.match(/\\./)) { const firstDoubleQuoteIdx = string.indexOf('"'); + const lastDoubleQuoteIdx = string.lastIndexOf('"'); + + // Handle a special case where we may have a value like "\"test\"". We don't + // want to expand this to """"test"""" - so we terminate before processing the string + // further if we detect this either at the start or end of the double quote section. + + if (string[firstDoubleQuoteIdx + 1] === '\\' && string[firstDoubleQuoteIdx + 2] === '"') { + return string; + } + + if (string[lastDoubleQuoteIdx - 1] === '"' && string[lastDoubleQuoteIdx - 2] === '\\') { + return string; + } + const colonAndAnySpacing = string.slice(0, firstDoubleQuoteIdx); const rawStringifiedValue = string.slice(firstDoubleQuoteIdx, string.length); // Remove one level of JSON stringification diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/__snapshots__/dashboard_listing.test.js.snap b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/__snapshots__/dashboard_listing.test.js.snap index b2f004568841a..2a9a793ba43c4 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/__snapshots__/dashboard_listing.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/__snapshots__/dashboard_listing.test.js.snap @@ -9,6 +9,7 @@ exports[`after fetch hideWriteControls 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="" listingLimit={1} noItemsFragment={ @@ -16,13 +17,15 @@ exports[`after fetch hideWriteControls 1`] = ` +

-

+ } /> @@ -63,6 +66,7 @@ exports[`after fetch initialFilter 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="my dashboard" listingLimit={1000} noItemsFragment={ @@ -114,13 +118,15 @@ exports[`after fetch initialFilter 1`] = ` } iconType="dashboardApp" title={ -

+

-

+ } /> @@ -161,6 +167,7 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="" listingLimit={1} noItemsFragment={ @@ -212,13 +219,15 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = ` } iconType="dashboardApp" title={ -

+

-

+ } /> @@ -259,6 +268,7 @@ exports[`after fetch renders table rows 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="" listingLimit={1000} noItemsFragment={ @@ -310,13 +320,15 @@ exports[`after fetch renders table rows 1`] = ` } iconType="dashboardApp" title={ -

+

-

+ } /> @@ -357,6 +369,7 @@ exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="" listingLimit={1} noItemsFragment={ @@ -408,13 +421,15 @@ exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` } iconType="dashboardApp" title={ -

+

-

+ } /> @@ -455,6 +470,7 @@ exports[`renders empty page in before initial fetch to avoid flickering 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="" listingLimit={1000} noItemsFragment={ @@ -506,13 +522,15 @@ exports[`renders empty page in before initial fetch to avoid flickering 1`] = ` } iconType="dashboardApp" title={ -

+

-

+ } /> diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/dashboard_listing.js b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/dashboard_listing.js index 827fe6eabe784..30bf940069fb7 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/dashboard_listing.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/dashboard_listing.js @@ -42,6 +42,7 @@ export class DashboardListing extends React.Component { return ( +

-

+ } /> @@ -90,12 +91,12 @@ export class DashboardListing extends React.Component { +

-

+ } body={ diff --git a/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/tutorial.js b/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/tutorial.js index 314ddf2196f06..c7aa5b0f5b2f9 100644 --- a/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/tutorial.js +++ b/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/tutorial.js @@ -363,6 +363,11 @@ class TutorialUi extends React.Component { ); } + let icon = this.state.tutorial.euiIconType; + if (icon && icon.includes('/')) { + icon = this.props.addBasePath(icon); + } + const instructions = this.getInstructions(); content = (
@@ -371,7 +376,7 @@ class TutorialUi extends React.Component { description={this.props.replaceTemplateStrings(this.state.tutorial.longDescription)} previewUrl={previewUrl} exportedFieldsUrl={exportedFieldsUrl} - iconType={this.state.tutorial.euiIconType} + iconType={icon} isBeta={this.state.tutorial.isBeta} /> diff --git a/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial_directory.js b/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial_directory.js index 06da6f35ee42e..697c1b0468cd1 100644 --- a/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial_directory.js +++ b/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial_directory.js @@ -129,7 +129,7 @@ class TutorialDirectoryUi extends React.Component { let tutorialCards = tutorialConfigs.map(tutorialConfig => { // add base path to SVG based icons let icon = tutorialConfig.euiIconType; - if (icon != null && icon.includes('/')) { + if (icon && icon.includes('/')) { icon = this.props.addBasePath(icon); } diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/ibmmq_logs/screenshot.png b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/ibmmq_logs/screenshot.png new file mode 100644 index 0000000000000..100a8b6ae367c Binary files /dev/null and b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/ibmmq_logs/screenshot.png differ diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/ibmmq.svg b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/ibmmq.svg new file mode 100644 index 0000000000000..ad0cb64b161dd --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/ibmmq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing_table.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing_table.js index 840e647edcc86..b770625cd3d70 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing_table.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing_table.js @@ -36,6 +36,7 @@ class VisualizeListingTable extends Component { const { visualizeCapabilities, uiSettings, toastNotifications } = getServices(); return ( +

-

+ } />
@@ -130,12 +131,12 @@ class VisualizeListingTable extends Component { +

-

+ } body={ diff --git a/src/legacy/core_plugins/kibana/server/tutorials/ibmmq_logs/index.js b/src/legacy/core_plugins/kibana/server/tutorials/ibmmq_logs/index.js new file mode 100644 index 0000000000000..4ffda2d7a523c --- /dev/null +++ b/src/legacy/core_plugins/kibana/server/tutorials/ibmmq_logs/index.js @@ -0,0 +1,71 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; +import { + onPremInstructions, + cloudInstructions, + onPremCloudInstructions, +} from '../../../common/tutorials/filebeat_instructions'; + +export function ibmmqLogsSpecProvider(server, context) { + const moduleName = 'ibmmq'; + const platforms = ['OSX', 'DEB', 'RPM', 'WINDOWS']; + return { + id: 'ibmmqLogs', + name: i18n.translate('kbn.server.tutorials.ibmmqLogs.nameTitle', { + defaultMessage: 'IBM MQ logs', + }), + category: TUTORIAL_CATEGORY.LOGGING, + shortDescription: i18n.translate('kbn.server.tutorials.ibmmqLogs.shortDescription', { + defaultMessage: 'Collect IBM MQ logs with Filebeat.', + }), + longDescription: i18n.translate('kbn.server.tutorials.ibmmqLogs.longDescription', { + defaultMessage: 'Collect IBM MQ logs with Filebeat. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-ibmmq.html', + }, + }), + euiIconType: '/plugins/kibana/home/tutorial_resources/logos/ibmmq.svg', + artifacts: { + dashboards: [ + { + id: 'ba1d8830-7c7b-11e9-9645-e37efaf5baff', + linkLabel: i18n.translate( + 'kbn.server.tutorials.ibmmqLogs.artifacts.dashboards.linkLabel', + { + defaultMessage: 'IBM MQ Events', + } + ), + isOverview: true, + }, + ], + exportedFields: { + documentationUrl: '{config.docs.beats.filebeat}/exported-fields-ibmmq.html', + }, + }, + completionTimeMinutes: 10, + previewImagePath: '/plugins/kibana/home/tutorial_resources/ibmmq_logs/screenshot.png', + onPrem: onPremInstructions(moduleName, platforms, context), + elasticCloud: cloudInstructions(moduleName, platforms), + onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), + }; +} diff --git a/src/legacy/core_plugins/kibana/server/tutorials/register.js b/src/legacy/core_plugins/kibana/server/tutorials/register.js index 53ec16c1ca593..69a6ac76e4a8f 100644 --- a/src/legacy/core_plugins/kibana/server/tutorials/register.js +++ b/src/legacy/core_plugins/kibana/server/tutorials/register.js @@ -83,6 +83,7 @@ import { awsLogsSpecProvider } from './aws_logs'; import { activemqLogsSpecProvider } from './activemq_logs'; import { activemqMetricsSpecProvider } from './activemq_metrics'; import { azureMetricsSpecProvider } from './azure_metrics'; +import { ibmmqLogsSpecProvider } from './ibmmq_logs'; import { stanMetricsSpecProvider } from './stan_metrics'; import { envoyproxyMetricsSpecProvider } from './envoyproxy_metrics'; @@ -156,6 +157,7 @@ export function registerTutorials(server) { server.newPlatform.setup.plugins.home.tutorials.registerTutorial(activemqLogsSpecProvider); server.newPlatform.setup.plugins.home.tutorials.registerTutorial(activemqMetricsSpecProvider); server.newPlatform.setup.plugins.home.tutorials.registerTutorial(azureMetricsSpecProvider); + server.newPlatform.setup.plugins.home.tutorials.registerTutorial(ibmmqLogsSpecProvider); server.newPlatform.setup.plugins.home.tutorials.registerTutorial(stanMetricsSpecProvider); server.newPlatform.setup.plugins.home.tutorials.registerTutorial(envoyproxyMetricsSpecProvider); } diff --git a/src/legacy/core_plugins/telemetry/common/constants.ts b/src/legacy/core_plugins/telemetry/common/constants.ts index 7e366676a8565..cb4ff79969a32 100644 --- a/src/legacy/core_plugins/telemetry/common/constants.ts +++ b/src/legacy/core_plugins/telemetry/common/constants.ts @@ -75,3 +75,9 @@ export const UI_METRIC_USAGE_TYPE = 'ui_metric'; * Link to Advanced Settings. */ export const PATH_TO_ADVANCED_SETTINGS = 'kibana#/management/kibana/settings'; + +/** + * The type name used within the Monitoring index to publish management stats. + * @type {string} + */ +export const KIBANA_MANAGEMENT_STATS_TYPE = 'management'; diff --git a/src/legacy/core_plugins/telemetry/server/collectors/index.ts b/src/legacy/core_plugins/telemetry/server/collectors/index.ts index 2f2a53278117b..04ee4773cd60d 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/index.ts +++ b/src/legacy/core_plugins/telemetry/server/collectors/index.ts @@ -22,3 +22,4 @@ export { registerTelemetryUsageCollector } from './usage'; export { registerUiMetricUsageCollector } from './ui_metric'; export { registerLocalizationUsageCollector } from './localization'; export { registerTelemetryPluginUsageCollector } from './telemetry_plugin'; +export { registerManagementUsageCollector } from './management'; diff --git a/webpackShims/moment.js b/src/legacy/core_plugins/telemetry/server/collectors/management/index.ts similarity index 90% rename from webpackShims/moment.js rename to src/legacy/core_plugins/telemetry/server/collectors/management/index.ts index 31476d18c9562..979bbed3765e2 100644 --- a/webpackShims/moment.js +++ b/src/legacy/core_plugins/telemetry/server/collectors/management/index.ts @@ -17,4 +17,4 @@ * under the License. */ -module.exports = require('../node_modules/moment/min/moment-with-locales.min.js'); +export { registerManagementUsageCollector } from './telemetry_management_collector'; diff --git a/src/legacy/core_plugins/telemetry/server/collectors/management/telemetry_management_collector.ts b/src/legacy/core_plugins/telemetry/server/collectors/management/telemetry_management_collector.ts new file mode 100644 index 0000000000000..f45cf7fc6bb33 --- /dev/null +++ b/src/legacy/core_plugins/telemetry/server/collectors/management/telemetry_management_collector.ts @@ -0,0 +1,63 @@ +/* + * 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 { Server } from 'hapi'; +import { size } from 'lodash'; +import { KIBANA_MANAGEMENT_STATS_TYPE } from '../../../common/constants'; +import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; +import { SavedObjectsClient } from '../../../../../../core/server'; + +export type UsageStats = Record; + +export async function getTranslationCount(loader: any, locale: string): Promise { + const translations = await loader.getTranslationsByLocale(locale); + return size(translations.messages); +} + +export function createCollectorFetch(server: Server) { + return async function fetchUsageStats(): Promise { + const internalRepo = server.newPlatform.setup.core.savedObjects.createInternalRepository(); + const uiSettingsClient = server.newPlatform.start.core.uiSettings.asScopedToClient( + new SavedObjectsClient(internalRepo) + ); + + const user = await uiSettingsClient.getUserProvided(); + const modifiedEntries = Object.keys(user) + .filter((key: string) => key !== 'buildNum') + .reduce((obj: any, key: string) => { + obj[key] = user[key].userValue; + return obj; + }, {}); + + return modifiedEntries; + }; +} + +export function registerManagementUsageCollector( + usageCollection: UsageCollectionSetup, + server: any +) { + const collector = usageCollection.makeUsageCollector({ + type: KIBANA_MANAGEMENT_STATS_TYPE, + isReady: () => true, + fetch: createCollectorFetch(server), + }); + + usageCollection.registerCollector(collector); +} diff --git a/src/legacy/core_plugins/telemetry/server/plugin.ts b/src/legacy/core_plugins/telemetry/server/plugin.ts index 06a974f473498..b5b53b1daba55 100644 --- a/src/legacy/core_plugins/telemetry/server/plugin.ts +++ b/src/legacy/core_plugins/telemetry/server/plugin.ts @@ -27,6 +27,7 @@ import { registerTelemetryUsageCollector, registerLocalizationUsageCollector, registerTelemetryPluginUsageCollector, + registerManagementUsageCollector, } from './collectors'; export interface PluginsSetup { @@ -50,5 +51,6 @@ export class TelemetryPlugin { registerLocalizationUsageCollector(usageCollection, server); registerTelemetryUsageCollector(usageCollection, server); registerUiMetricUsageCollector(usageCollection, server); + registerManagementUsageCollector(usageCollection, server); } } diff --git a/src/legacy/ui/ui_render/bootstrap/template.js.hbs b/src/legacy/ui/ui_render/bootstrap/template.js.hbs index d8a55935b705a..85b6de26b9516 100644 --- a/src/legacy/ui/ui_render/bootstrap/template.js.hbs +++ b/src/legacy/ui/ui_render/bootstrap/template.js.hbs @@ -14,6 +14,7 @@ if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) { window.onload = function () { var files = [ '{{dllBundlePath}}/vendors.bundle.dll.js', + '{{regularBundlePath}}/kbn-ui-shared-deps/{{sharedDepsFilename}}', '{{regularBundlePath}}/commons.bundle.js', '{{regularBundlePath}}/{{appId}}.bundle.js' ]; diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index 0b266b8b62726..a935270d23fce 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -21,6 +21,7 @@ import { createHash } from 'crypto'; import Boom from 'boom'; import { resolve } from 'path'; import { i18n } from '@kbn/i18n'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; import { AppBootstrap } from './bootstrap'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { fromRoot } from '../../../core/server/utils'; @@ -41,18 +42,10 @@ export function uiRenderMixin(kbnServer, server, config) { // render all views from ./views server.setupViews(resolve(__dirname, 'views')); - server.exposeStaticDir( - '/node_modules/@elastic/eui/dist/{path*}', - fromRoot('node_modules/@elastic/eui/dist') - ); server.exposeStaticDir( '/node_modules/@kbn/ui-framework/dist/{path*}', fromRoot('node_modules/@kbn/ui-framework/dist') ); - server.exposeStaticDir( - '/node_modules/@elastic/charts/dist/{path*}', - fromRoot('node_modules/@elastic/charts/dist') - ); const translationsCache = { translations: null, hash: null }; server.route({ @@ -114,14 +107,12 @@ export function uiRenderMixin(kbnServer, server, config) { `${dllBundlePath}/vendors.style.dll.css`, ...(darkMode ? [ - `${basePath}/node_modules/@elastic/eui/dist/eui_theme_dark.css`, + `${basePath}/bundles/kbn-ui-shared-deps/${UiSharedDeps.darkCssDistFilename}`, `${basePath}/node_modules/@kbn/ui-framework/dist/kui_dark.css`, - `${basePath}/node_modules/@elastic/charts/dist/theme_only_dark.css`, ] : [ - `${basePath}/node_modules/@elastic/eui/dist/eui_theme_light.css`, + `${basePath}/bundles/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, `${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`, - `${basePath}/node_modules/@elastic/charts/dist/theme_only_light.css`, ]), `${regularBundlePath}/${darkMode ? 'dark' : 'light'}_theme.style.css`, `${regularBundlePath}/commons.style.css`, @@ -142,6 +133,7 @@ export function uiRenderMixin(kbnServer, server, config) { regularBundlePath, dllBundlePath, styleSheetPaths, + sharedDepsFilename: UiSharedDeps.distFilename, }, }); diff --git a/src/optimize/base_optimizer.js b/src/optimize/base_optimizer.js index 9a21a4b1d5439..efff7f0aa2b46 100644 --- a/src/optimize/base_optimizer.js +++ b/src/optimize/base_optimizer.js @@ -19,6 +19,7 @@ import { writeFile } from 'fs'; import os from 'os'; + import Boom from 'boom'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import TerserPlugin from 'terser-webpack-plugin'; @@ -26,10 +27,10 @@ import webpack from 'webpack'; import Stats from 'webpack/lib/Stats'; import * as threadLoader from 'thread-loader'; import webpackMerge from 'webpack-merge'; -import { DynamicDllPlugin } from './dynamic_dll_plugin'; import WrapperPlugin from 'wrapper-webpack-plugin'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; -import { defaults } from 'lodash'; +import { DynamicDllPlugin } from './dynamic_dll_plugin'; import { IS_KIBANA_DISTRIBUTABLE } from '../legacy/utils'; import { fromRoot } from '../core/server/utils'; @@ -403,6 +404,10 @@ export default class BaseOptimizer { // and not for the webpack compilations performance itself hints: false, }, + + externals: { + ...UiSharedDeps.externals, + }, }; // when running from the distributable define an environment variable we can use @@ -417,17 +422,6 @@ export default class BaseOptimizer { ], }; - // We need to add react-addons (and a few other bits) for enzyme to work. - // https://github.com/airbnb/enzyme/blob/master/docs/guides/webpack.md - const supportEnzymeConfig = { - externals: { - mocha: 'mocha', - 'react/lib/ExecutionEnvironment': true, - 'react/addons': true, - 'react/lib/ReactContext': true, - }, - }; - const watchingConfig = { plugins: [ new webpack.WatchIgnorePlugin([ @@ -482,9 +476,7 @@ export default class BaseOptimizer { IS_CODE_COVERAGE ? coverageConfig : {}, commonConfig, IS_KIBANA_DISTRIBUTABLE ? isDistributableConfig : {}, - this.uiBundles.isDevMode() - ? webpackMerge(watchingConfig, supportEnzymeConfig) - : productionConfig + this.uiBundles.isDevMode() ? watchingConfig : productionConfig ) ); } @@ -515,22 +507,19 @@ export default class BaseOptimizer { } failedStatsToError(stats) { - const details = stats.toString( - defaults( - { colors: true, warningsFilter: STATS_WARNINGS_FILTER }, - Stats.presetToOptions('minimal') - ) - ); + const details = stats.toString({ + ...Stats.presetToOptions('minimal'), + colors: true, + warningsFilter: STATS_WARNINGS_FILTER, + }); return Boom.internal( `Optimizations failure.\n${details.split('\n').join('\n ')}\n`, - stats.toJson( - defaults({ - warningsFilter: STATS_WARNINGS_FILTER, - ...Stats.presetToOptions('detailed'), - maxModules: 1000, - }) - ) + stats.toJson({ + warningsFilter: STATS_WARNINGS_FILTER, + ...Stats.presetToOptions('detailed'), + maxModules: 1000, + }) ); } diff --git a/src/optimize/bundles_route/bundles_route.js b/src/optimize/bundles_route/bundles_route.js index d3c08fae92264..f0261d44e0347 100644 --- a/src/optimize/bundles_route/bundles_route.js +++ b/src/optimize/bundles_route/bundles_route.js @@ -19,6 +19,7 @@ import { isAbsolute, extname } from 'path'; import LruCache from 'lru-cache'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; import { createDynamicAssetResponse } from './dynamic_asset_response'; /** @@ -66,6 +67,12 @@ export function createBundlesRoute({ } return [ + buildRouteForBundles( + `${basePublicPath}/bundles/kbn-ui-shared-deps/`, + '/bundles/kbn-ui-shared-deps/', + UiSharedDeps.distDir, + fileHashCache + ), buildRouteForBundles( `${basePublicPath}/bundles/`, '/bundles/', diff --git a/src/optimize/dynamic_dll_plugin/dll_config_model.js b/src/optimize/dynamic_dll_plugin/dll_config_model.js index 2a3d3dd659c67..ecf5def5aa6ca 100644 --- a/src/optimize/dynamic_dll_plugin/dll_config_model.js +++ b/src/optimize/dynamic_dll_plugin/dll_config_model.js @@ -23,6 +23,7 @@ import webpack from 'webpack'; import webpackMerge from 'webpack-merge'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import TerserPlugin from 'terser-webpack-plugin'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; function generateDLL(config) { const { @@ -145,6 +146,9 @@ function generateDLL(config) { // and not for the webpack compilations performance itself hints: false, }, + externals: { + ...UiSharedDeps.externals, + }, }; } diff --git a/src/optimize/watch/watch_cache.ts b/src/optimize/watch/watch_cache.ts index ab11a8c5d2f11..15957210b3d43 100644 --- a/src/optimize/watch/watch_cache.ts +++ b/src/optimize/watch/watch_cache.ts @@ -18,17 +18,18 @@ */ import { createHash } from 'crypto'; -import { readFile, writeFile } from 'fs'; +import { readFile, writeFile, readdir, unlink, rmdir } from 'fs'; import { resolve } from 'path'; import { promisify } from 'util'; - +import path from 'path'; import del from 'del'; -import deleteEmpty from 'delete-empty'; -import globby from 'globby'; import normalizePosixPath from 'normalize-path'; const readAsync = promisify(readFile); const writeAsync = promisify(writeFile); +const readdirAsync = promisify(readdir); +const unlinkAsync = promisify(unlink); +const rmdirAsync = promisify(rmdir); interface Params { logWithMetadata: (tags: string[], message: string, metadata?: { [key: string]: any }) => void; @@ -95,11 +96,7 @@ export class WatchCache { await del(this.statePath, { force: true }); // delete everything in optimize/.cache directory - await del(await globby([normalizePosixPath(this.cachePath)], { dot: true })); - - // delete some empty folder that could be left - // from the previous cache path reset action - await deleteEmpty(this.cachePath); + await recursiveDelete(normalizePosixPath(this.cachePath)); // delete dlls await del(this.dllsPath); @@ -167,3 +164,28 @@ export class WatchCache { } } } + +/** + * Recursively deletes a folder. This is a workaround for a bug in `del` where + * very large folders (with 84K+ files) cause a stack overflow. + */ +async function recursiveDelete(directory: string) { + const entries = await readdirAsync(directory, { withFileTypes: true }); + await Promise.all( + entries.map(entry => { + const absolutePath = path.join(directory, entry.name); + const result = entry.isDirectory() + ? recursiveDelete(absolutePath) + : unlinkAsync(absolutePath); + + // Ignore errors, if the file or directory doesn't exist. + return result.catch(e => { + if (e.code !== 'ENOENT') { + throw e; + } + }); + }) + ); + + return rmdirAsync(directory); +} diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx index 2e7b22a14fb0e..4c2dac4f39134 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx @@ -67,6 +67,11 @@ export interface TableListViewProps { tableListTitle: string; toastNotifications: ToastsStart; uiSettings: IUiSettingsClient; + /** + * Id of the heading element describing the table. This id will be used as `aria-labelledby` of the wrapper element. + * If the table is not empty, this component renders its own h1 element using the same id. + */ + headingId?: string; } export interface TableListViewState { @@ -463,7 +468,7 @@ class TableListView extends React.Component -

{this.props.tableListTitle}

+

{this.props.tableListTitle}

@@ -498,7 +503,11 @@ class TableListView extends React.Component - {this.renderPageContent()} + + {this.renderPageContent()} + ); } diff --git a/src/plugins/kibana_utils/demos/demos.test.ts b/src/plugins/kibana_utils/demos/demos.test.ts index 5c50e152ad46c..b905aeff41f1f 100644 --- a/src/plugins/kibana_utils/demos/demos.test.ts +++ b/src/plugins/kibana_utils/demos/demos.test.ts @@ -38,7 +38,7 @@ describe('demos', () => { describe('state sync', () => { test('url sync demo works', async () => { expect(await urlSyncResult).toMatchInlineSnapshot( - `"http://localhost/#?_s=!((completed:!f,id:0,text:'Learning%20state%20containers'),(completed:!f,id:2,text:test))"` + `"http://localhost/#?_s=(todos:!((completed:!f,id:0,text:'Learning%20state%20containers'),(completed:!f,id:2,text:test)))"` ); }); }); diff --git a/src/plugins/kibana_utils/demos/state_containers/counter.ts b/src/plugins/kibana_utils/demos/state_containers/counter.ts index 643763cc4cee9..4ddf532c1506d 100644 --- a/src/plugins/kibana_utils/demos/state_containers/counter.ts +++ b/src/plugins/kibana_utils/demos/state_containers/counter.ts @@ -19,14 +19,24 @@ import { createStateContainer } from '../../public/state_containers'; -const container = createStateContainer(0, { - increment: (cnt: number) => (by: number) => cnt + by, - double: (cnt: number) => () => cnt * 2, -}); +interface State { + count: number; +} + +const container = createStateContainer( + { count: 0 }, + { + increment: (state: State) => (by: number) => ({ count: state.count + by }), + double: (state: State) => () => ({ count: state.count * 2 }), + }, + { + count: (state: State) => () => state.count, + } +); container.transitions.increment(5); container.transitions.double(); -console.log(container.get()); // eslint-disable-line +console.log(container.selectors.count()); // eslint-disable-line -export const result = container.get(); +export const result = container.selectors.count(); diff --git a/src/plugins/kibana_utils/demos/state_containers/todomvc.ts b/src/plugins/kibana_utils/demos/state_containers/todomvc.ts index 6d0c960e2a5b2..e807783a56f31 100644 --- a/src/plugins/kibana_utils/demos/state_containers/todomvc.ts +++ b/src/plugins/kibana_utils/demos/state_containers/todomvc.ts @@ -25,15 +25,19 @@ export interface TodoItem { id: number; } -export type TodoState = TodoItem[]; +export interface TodoState { + todos: TodoItem[]; +} -export const defaultState: TodoState = [ - { - id: 0, - text: 'Learning state containers', - completed: false, - }, -]; +export const defaultState: TodoState = { + todos: [ + { + id: 0, + text: 'Learning state containers', + completed: false, + }, + ], +}; export interface TodoActions { add: PureTransition; @@ -44,17 +48,34 @@ export interface TodoActions { clearCompleted: PureTransition; } +export interface TodosSelectors { + todos: (state: TodoState) => () => TodoItem[]; + todo: (state: TodoState) => (id: number) => TodoItem | null; +} + export const pureTransitions: TodoActions = { - add: state => todo => [...state, todo], - edit: state => todo => state.map(item => (item.id === todo.id ? { ...item, ...todo } : item)), - delete: state => id => state.filter(item => item.id !== id), - complete: state => id => - state.map(item => (item.id === id ? { ...item, completed: true } : item)), - completeAll: state => () => state.map(item => ({ ...item, completed: true })), - clearCompleted: state => () => state.filter(({ completed }) => !completed), + add: state => todo => ({ todos: [...state.todos, todo] }), + edit: state => todo => ({ + todos: state.todos.map(item => (item.id === todo.id ? { ...item, ...todo } : item)), + }), + delete: state => id => ({ todos: state.todos.filter(item => item.id !== id) }), + complete: state => id => ({ + todos: state.todos.map(item => (item.id === id ? { ...item, completed: true } : item)), + }), + completeAll: state => () => ({ todos: state.todos.map(item => ({ ...item, completed: true })) }), + clearCompleted: state => () => ({ todos: state.todos.filter(({ completed }) => !completed) }), +}; + +export const pureSelectors: TodosSelectors = { + todos: state => () => state.todos, + todo: state => id => state.todos.find(todo => todo.id === id) ?? null, }; -const container = createStateContainer(defaultState, pureTransitions); +const container = createStateContainer( + defaultState, + pureTransitions, + pureSelectors +); container.transitions.add({ id: 1, @@ -64,6 +85,6 @@ container.transitions.add({ container.transitions.complete(0); container.transitions.complete(1); -console.log(container.get()); // eslint-disable-line +console.log(container.selectors.todos()); // eslint-disable-line -export const result = container.get(); +export const result = container.selectors.todos(); diff --git a/src/plugins/kibana_utils/demos/state_sync/url.ts b/src/plugins/kibana_utils/demos/state_sync/url.ts index 657b64f55a776..2c426cae6733a 100644 --- a/src/plugins/kibana_utils/demos/state_sync/url.ts +++ b/src/plugins/kibana_utils/demos/state_sync/url.ts @@ -18,7 +18,7 @@ */ import { defaultState, pureTransitions, TodoActions, TodoState } from '../state_containers/todomvc'; -import { BaseStateContainer, createStateContainer } from '../../public/state_containers'; +import { BaseState, BaseStateContainer, createStateContainer } from '../../public/state_containers'; import { createKbnUrlStateStorage, syncState, @@ -55,7 +55,7 @@ export const result = Promise.resolve() return window.location.href; }); -function withDefaultState( +function withDefaultState( // eslint-disable-next-line no-shadow stateContainer: BaseStateContainer, // eslint-disable-next-line no-shadow diff --git a/src/plugins/kibana_utils/docs/state_containers/README.md b/src/plugins/kibana_utils/docs/state_containers/README.md index 3b7a8b8bd4621..583f8f65ce6b6 100644 --- a/src/plugins/kibana_utils/docs/state_containers/README.md +++ b/src/plugins/kibana_utils/docs/state_containers/README.md @@ -18,14 +18,21 @@ your services or apps. ```ts import { createStateContainer } from 'src/plugins/kibana_utils'; -const container = createStateContainer(0, { - increment: (cnt: number) => (by: number) => cnt + by, - double: (cnt: number) => () => cnt * 2, -}); +const container = createStateContainer( + { count: 0 }, + { + increment: (state: {count: number}) => (by: number) => ({ count: state.count + by }), + double: (state: {count: number}) => () => ({ count: state.count * 2 }), + }, + { + count: (state: {count: number}) => () => state.count, + } +); container.transitions.increment(5); container.transitions.double(); -console.log(container.get()); // 10 + +console.log(container.selectors.count()); // 10 ``` diff --git a/src/plugins/kibana_utils/docs/state_containers/creation.md b/src/plugins/kibana_utils/docs/state_containers/creation.md index 66d28bbd8603f..f8ded75ed3f45 100644 --- a/src/plugins/kibana_utils/docs/state_containers/creation.md +++ b/src/plugins/kibana_utils/docs/state_containers/creation.md @@ -32,7 +32,7 @@ Create your a state container. ```ts import { createStateContainer } from 'src/plugins/kibana_utils'; -const container = createStateContainer(defaultState, {}); +const container = createStateContainer(defaultState); console.log(container.get()); ``` diff --git a/src/plugins/kibana_utils/docs/state_containers/no_react.md b/src/plugins/kibana_utils/docs/state_containers/no_react.md index 7a15483d83b44..a72995f4f1eae 100644 --- a/src/plugins/kibana_utils/docs/state_containers/no_react.md +++ b/src/plugins/kibana_utils/docs/state_containers/no_react.md @@ -1,13 +1,13 @@ # Consuming state in non-React setting -To read the current `state` of the store use `.get()` method. +To read the current `state` of the store use `.get()` method or `getState()` alias method. ```ts -store.get(); +stateContainer.get(); ``` To listen for latest state changes use `.state$` observable. ```ts -store.state$.subscribe(state => { ... }); +stateContainer.state$.subscribe(state => { ... }); ``` diff --git a/src/plugins/kibana_utils/docs/state_containers/react.md b/src/plugins/kibana_utils/docs/state_containers/react.md index 363fd9253d44f..1bab1af1d5f68 100644 --- a/src/plugins/kibana_utils/docs/state_containers/react.md +++ b/src/plugins/kibana_utils/docs/state_containers/react.md @@ -9,7 +9,7 @@ ```ts import { createStateContainer, createStateContainerReactHelpers } from 'src/plugins/kibana_utils'; -const container = createStateContainer({}, {}); +const container = createStateContainer({}); export const { Provider, Consumer, diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts index 95f4c35f2ce01..d4877acaa5ca0 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts @@ -19,18 +19,9 @@ import { createStateContainer } from './create_state_container'; -const create = (state: S, transitions: T = {} as T) => { - const pureTransitions = { - set: () => (newState: S) => newState, - ...transitions, - }; - const store = createStateContainer(state, pureTransitions); - return { store, mutators: store.transitions }; -}; - -test('can create store', () => { - const { store } = create({}); - expect(store).toMatchObject({ +test('can create state container', () => { + const stateContainer = createStateContainer({}); + expect(stateContainer).toMatchObject({ getState: expect.any(Function), state$: expect.any(Object), transitions: expect.any(Object), @@ -45,9 +36,9 @@ test('can set default state', () => { const defaultState = { foo: 'bar', }; - const { store } = create(defaultState); - expect(store.get()).toEqual(defaultState); - expect(store.getState()).toEqual(defaultState); + const stateContainer = createStateContainer(defaultState); + expect(stateContainer.get()).toEqual(defaultState); + expect(stateContainer.getState()).toEqual(defaultState); }); test('can set state', () => { @@ -57,12 +48,12 @@ test('can set state', () => { const newState = { foo: 'baz', }; - const { store, mutators } = create(defaultState); + const stateContainer = createStateContainer(defaultState); - mutators.set(newState); + stateContainer.set(newState); - expect(store.get()).toEqual(newState); - expect(store.getState()).toEqual(newState); + expect(stateContainer.get()).toEqual(newState); + expect(stateContainer.getState()).toEqual(newState); }); test('does not shallow merge states', () => { @@ -72,22 +63,22 @@ test('does not shallow merge states', () => { const newState = { foo2: 'baz', }; - const { store, mutators } = create(defaultState); + const stateContainer = createStateContainer(defaultState); - mutators.set(newState as any); + stateContainer.set(newState as any); - expect(store.get()).toEqual(newState); - expect(store.getState()).toEqual(newState); + expect(stateContainer.get()).toEqual(newState); + expect(stateContainer.getState()).toEqual(newState); }); test('can subscribe and unsubscribe to state changes', () => { - const { store, mutators } = create({}); + const stateContainer = createStateContainer({}); const spy = jest.fn(); - const subscription = store.state$.subscribe(spy); - mutators.set({ a: 1 }); - mutators.set({ a: 2 }); + const subscription = stateContainer.state$.subscribe(spy); + stateContainer.set({ a: 1 }); + stateContainer.set({ a: 2 }); subscription.unsubscribe(); - mutators.set({ a: 3 }); + stateContainer.set({ a: 3 }); expect(spy).toHaveBeenCalledTimes(2); expect(spy.mock.calls[0][0]).toEqual({ a: 1 }); @@ -95,16 +86,16 @@ test('can subscribe and unsubscribe to state changes', () => { }); test('multiple subscribers can subscribe', () => { - const { store, mutators } = create({}); + const stateContainer = createStateContainer({}); const spy1 = jest.fn(); const spy2 = jest.fn(); - const subscription1 = store.state$.subscribe(spy1); - const subscription2 = store.state$.subscribe(spy2); - mutators.set({ a: 1 }); + const subscription1 = stateContainer.state$.subscribe(spy1); + const subscription2 = stateContainer.state$.subscribe(spy2); + stateContainer.set({ a: 1 }); subscription1.unsubscribe(); - mutators.set({ a: 2 }); + stateContainer.set({ a: 2 }); subscription2.unsubscribe(); - mutators.set({ a: 3 }); + stateContainer.set({ a: 3 }); expect(spy1).toHaveBeenCalledTimes(1); expect(spy2).toHaveBeenCalledTimes(2); @@ -120,19 +111,19 @@ test('can create state container without transitions', () => { expect(stateContainer.get()).toEqual(state); }); -test('creates impure mutators from pure mutators', () => { - const { mutators } = create( +test('creates transitions', () => { + const stateContainer = createStateContainer( {}, { setFoo: () => (bar: any) => ({ foo: bar }), } ); - expect(typeof mutators.setFoo).toBe('function'); + expect(typeof stateContainer.transitions.setFoo).toBe('function'); }); -test('mutators can update state', () => { - const { store, mutators } = create( +test('transitions can update state', () => { + const stateContainer = createStateContainer( { value: 0, foo: 'bar', @@ -143,30 +134,30 @@ test('mutators can update state', () => { } ); - expect(store.get()).toEqual({ + expect(stateContainer.get()).toEqual({ value: 0, foo: 'bar', }); - mutators.add(11); - mutators.setFoo('baz'); + stateContainer.transitions.add(11); + stateContainer.transitions.setFoo('baz'); - expect(store.get()).toEqual({ + expect(stateContainer.get()).toEqual({ value: 11, foo: 'baz', }); - mutators.add(-20); - mutators.setFoo('bazooka'); + stateContainer.transitions.add(-20); + stateContainer.transitions.setFoo('bazooka'); - expect(store.get()).toEqual({ + expect(stateContainer.get()).toEqual({ value: -9, foo: 'bazooka', }); }); -test('mutators methods are not bound', () => { - const { store, mutators } = create( +test('transitions methods are not bound', () => { + const stateContainer = createStateContainer( { value: -3 }, { add: (state: { value: number }) => (increment: number) => ({ @@ -176,13 +167,13 @@ test('mutators methods are not bound', () => { } ); - expect(store.get()).toEqual({ value: -3 }); - mutators.add(4); - expect(store.get()).toEqual({ value: 1 }); + expect(stateContainer.get()).toEqual({ value: -3 }); + stateContainer.transitions.add(4); + expect(stateContainer.get()).toEqual({ value: 1 }); }); -test('created mutators are saved in store object', () => { - const { store, mutators } = create( +test('created transitions are saved in stateContainer object', () => { + const stateContainer = createStateContainer( { value: -3 }, { add: (state: { value: number }) => (increment: number) => ({ @@ -192,55 +183,57 @@ test('created mutators are saved in store object', () => { } ); - expect(typeof store.transitions.add).toBe('function'); - mutators.add(5); - expect(store.get()).toEqual({ value: 2 }); + expect(typeof stateContainer.transitions.add).toBe('function'); + stateContainer.transitions.add(5); + expect(stateContainer.get()).toEqual({ value: 2 }); }); -test('throws when state is modified inline - 1', () => { - const container = createStateContainer({ a: 'b' }, {}); +test('throws when state is modified inline', () => { + const container = createStateContainer({ a: 'b', array: [{ a: 'b' }] }); - let error: TypeError | null = null; - try { + expect(() => { (container.get().a as any) = 'c'; - } catch (err) { - error = err; - } + }).toThrowErrorMatchingInlineSnapshot( + `"Cannot assign to read only property 'a' of object '#'"` + ); - expect(error).toBeInstanceOf(TypeError); -}); + expect(() => { + (container.getState().a as any) = 'c'; + }).toThrowErrorMatchingInlineSnapshot( + `"Cannot assign to read only property 'a' of object '#'"` + ); -test('throws when state is modified inline - 2', () => { - const container = createStateContainer({ a: 'b' }, {}); + expect(() => { + (container.getState().array as any).push('c'); + }).toThrowErrorMatchingInlineSnapshot(`"Cannot add property 1, object is not extensible"`); - let error: TypeError | null = null; - try { - (container.getState().a as any) = 'c'; - } catch (err) { - error = err; - } + expect(() => { + (container.getState().array[0] as any).c = 'b'; + }).toThrowErrorMatchingInlineSnapshot(`"Cannot add property c, object is not extensible"`); - expect(error).toBeInstanceOf(TypeError); + expect(() => { + container.set(null as any); + expect(container.getState()).toBeNull(); + }).not.toThrow(); }); -test('throws when state is modified inline in subscription', done => { +test('throws when state is modified inline in subscription', () => { const container = createStateContainer({ a: 'b' }, { set: () => (newState: any) => newState }); container.subscribe(value => { - let error: TypeError | null = null; - try { + expect(() => { (value.a as any) = 'd'; - } catch (err) { - error = err; - } - expect(error).toBeInstanceOf(TypeError); - done(); + }).toThrowErrorMatchingInlineSnapshot( + `"Cannot assign to read only property 'a' of object '#'"` + ); }); + container.transitions.set({ a: 'c' }); }); describe('selectors', () => { test('can specify no selectors, or can skip them', () => { + createStateContainer({}); createStateContainer({}, {}); createStateContainer({}, {}, {}); }); diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container.ts index b949a9daed0ae..d420aec30f068 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container.ts @@ -20,34 +20,52 @@ import { BehaviorSubject } from 'rxjs'; import { skip } from 'rxjs/operators'; import { RecursiveReadonly } from '@kbn/utility-types'; +import deepFreeze from 'deep-freeze-strict'; import { PureTransitionsToTransitions, PureTransition, ReduxLikeStateContainer, PureSelectorsToSelectors, + BaseState, } from './types'; const $$observable = (typeof Symbol === 'function' && (Symbol as any).observable) || '@@observable'; +const $$setActionType = '@@SET'; const freeze: (value: T) => RecursiveReadonly = process.env.NODE_ENV !== 'production' ? (value: T): RecursiveReadonly => { - if (!value) return value as RecursiveReadonly; - if (value instanceof Array) return value as RecursiveReadonly; - if (typeof value === 'object') return Object.freeze({ ...value }) as RecursiveReadonly; - else return value as RecursiveReadonly; + const isFreezable = value !== null && typeof value === 'object'; + if (isFreezable) return deepFreeze(value) as RecursiveReadonly; + return value as RecursiveReadonly; } : (value: T) => value as RecursiveReadonly; -export const createStateContainer = < - State, - PureTransitions extends object = {}, - PureSelectors extends object = {} +export function createStateContainer( + defaultState: State +): ReduxLikeStateContainer; +export function createStateContainer( + defaultState: State, + pureTransitions: PureTransitions +): ReduxLikeStateContainer; +export function createStateContainer< + State extends BaseState, + PureTransitions extends object, + PureSelectors extends object +>( + defaultState: State, + pureTransitions: PureTransitions, + pureSelectors: PureSelectors +): ReduxLikeStateContainer; +export function createStateContainer< + State extends BaseState, + PureTransitions extends object, + PureSelectors extends object >( defaultState: State, pureTransitions: PureTransitions = {} as PureTransitions, pureSelectors: PureSelectors = {} as PureSelectors -): ReduxLikeStateContainer => { +): ReduxLikeStateContainer { const data$ = new BehaviorSubject>(freeze(defaultState)); const state$ = data$.pipe(skip(1)); const get = () => data$.getValue(); @@ -56,9 +74,13 @@ export const createStateContainer = < state$, getState: () => data$.getValue(), set: (state: State) => { - data$.next(freeze(state)); + container.dispatch({ type: $$setActionType, args: [state] }); }, reducer: (state, action) => { + if (action.type === $$setActionType) { + return freeze(action.args[0] as State); + } + const pureTransition = (pureTransitions as Record>)[ action.type ]; @@ -86,4 +108,4 @@ export const createStateContainer = < [$$observable]: state$, }; return container; -}; +} diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx index c1a35441b637b..0f25f65c30ade 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx @@ -23,15 +23,6 @@ import { act, Simulate } from 'react-dom/test-utils'; import { createStateContainer } from './create_state_container'; import { createStateContainerReactHelpers } from './create_state_container_react_helpers'; -const create = (state: S, transitions: T = {} as T) => { - const pureTransitions = { - set: () => (newState: S) => newState, - ...transitions, - }; - const store = createStateContainer(state, pureTransitions); - return { store, mutators: store.transitions }; -}; - let container: HTMLDivElement | null; beforeEach(() => { @@ -56,12 +47,12 @@ test('can create React context', () => { }); test(' passes state to ', () => { - const { store } = create({ hello: 'world' }); - const { Provider, Consumer } = createStateContainerReactHelpers(); + const stateContainer = createStateContainer({ hello: 'world' }); + const { Provider, Consumer } = createStateContainerReactHelpers(); ReactDOM.render( - - {(s: typeof store) => s.get().hello} + + {(s: typeof stateContainer) => s.get().hello} , container ); @@ -79,8 +70,8 @@ interface Props1 { } test(' passes state to connect()()', () => { - const { store } = create({ hello: 'Bob' }); - const { Provider, connect } = createStateContainerReactHelpers(); + const stateContainer = createStateContainer({ hello: 'Bob' }); + const { Provider, connect } = createStateContainerReactHelpers(); const Demo: React.FC = ({ message, stop }) => ( <> @@ -92,7 +83,7 @@ test(' passes state to connect()()', () => { const DemoConnected = connect(mergeProps)(Demo); ReactDOM.render( - + , container @@ -101,14 +92,14 @@ test(' passes state to connect()()', () => { expect(container!.innerHTML).toBe('Bob?'); }); -test('context receives Redux store', () => { - const { store } = create({ foo: 'bar' }); - const { Provider, context } = createStateContainerReactHelpers(); +test('context receives stateContainer', () => { + const stateContainer = createStateContainer({ foo: 'bar' }); + const { Provider, context } = createStateContainerReactHelpers(); ReactDOM.render( /* eslint-disable no-shadow */ - - {store => store.get().foo} + + {stateContainer => stateContainer.get().foo} , /* eslint-enable no-shadow */ container @@ -117,21 +108,21 @@ test('context receives Redux store', () => { expect(container!.innerHTML).toBe('bar'); }); -xtest('can use multiple stores in one React app', () => {}); +test.todo('can use multiple stores in one React app'); describe('hooks', () => { describe('useStore', () => { - test('can select store using useStore hook', () => { - const { store } = create({ foo: 'bar' }); - const { Provider, useContainer } = createStateContainerReactHelpers(); + test('can select store using useContainer hook', () => { + const stateContainer = createStateContainer({ foo: 'bar' }); + const { Provider, useContainer } = createStateContainerReactHelpers(); const Demo: React.FC<{}> = () => { // eslint-disable-next-line no-shadow - const store = useContainer(); - return <>{store.get().foo}; + const stateContainer = useContainer(); + return <>{stateContainer.get().foo}; }; ReactDOM.render( - + , container @@ -143,15 +134,15 @@ describe('hooks', () => { describe('useState', () => { test('can select state using useState hook', () => { - const { store } = create({ foo: 'qux' }); - const { Provider, useState } = createStateContainerReactHelpers(); + const stateContainer = createStateContainer({ foo: 'qux' }); + const { Provider, useState } = createStateContainerReactHelpers(); const Demo: React.FC<{}> = () => { const { foo } = useState(); return <>{foo}; }; ReactDOM.render( - + , container @@ -161,23 +152,20 @@ describe('hooks', () => { }); test('re-renders when state changes', () => { - const { - store, - mutators: { setFoo }, - } = create( + const stateContainer = createStateContainer( { foo: 'bar' }, { setFoo: (state: { foo: string }) => (foo: string) => ({ ...state, foo }), } ); - const { Provider, useState } = createStateContainerReactHelpers(); + const { Provider, useState } = createStateContainerReactHelpers(); const Demo: React.FC<{}> = () => { const { foo } = useState(); return <>{foo}; }; ReactDOM.render( - + , container @@ -185,7 +173,7 @@ describe('hooks', () => { expect(container!.innerHTML).toBe('bar'); act(() => { - setFoo('baz'); + stateContainer.transitions.setFoo('baz'); }); expect(container!.innerHTML).toBe('baz'); }); @@ -193,7 +181,7 @@ describe('hooks', () => { describe('useTransitions', () => { test('useTransitions hook returns mutations that can update state', () => { - const { store } = create( + const stateContainer = createStateContainer( { cnt: 0, }, @@ -206,7 +194,7 @@ describe('hooks', () => { ); const { Provider, useState, useTransitions } = createStateContainerReactHelpers< - typeof store + typeof stateContainer >(); const Demo: React.FC<{}> = () => { const { cnt } = useState(); @@ -220,7 +208,7 @@ describe('hooks', () => { }; ReactDOM.render( - + , container @@ -240,7 +228,7 @@ describe('hooks', () => { describe('useSelector', () => { test('can select deeply nested value', () => { - const { store } = create({ + const stateContainer = createStateContainer({ foo: { bar: { baz: 'qux', @@ -248,14 +236,14 @@ describe('hooks', () => { }, }); const selector = (state: { foo: { bar: { baz: string } } }) => state.foo.bar.baz; - const { Provider, useSelector } = createStateContainerReactHelpers(); + const { Provider, useSelector } = createStateContainerReactHelpers(); const Demo: React.FC<{}> = () => { const value = useSelector(selector); return <>{value}; }; ReactDOM.render( - + , container @@ -265,7 +253,7 @@ describe('hooks', () => { }); test('re-renders when state changes', () => { - const { store, mutators } = create({ + const stateContainer = createStateContainer({ foo: { bar: { baz: 'qux', @@ -280,7 +268,7 @@ describe('hooks', () => { }; ReactDOM.render( - + , container @@ -288,7 +276,7 @@ describe('hooks', () => { expect(container!.innerHTML).toBe('qux'); act(() => { - mutators.set({ + stateContainer.set({ foo: { bar: { baz: 'quux', @@ -300,9 +288,9 @@ describe('hooks', () => { }); test("re-renders only when selector's result changes", async () => { - const { store, mutators } = create({ a: 'b', foo: 'bar' }); + const stateContainer = createStateContainer({ a: 'b', foo: 'bar' }); const selector = (state: { foo: string }) => state.foo; - const { Provider, useSelector } = createStateContainerReactHelpers(); + const { Provider, useSelector } = createStateContainerReactHelpers(); let cnt = 0; const Demo: React.FC<{}> = () => { @@ -311,7 +299,7 @@ describe('hooks', () => { return <>{value}; }; ReactDOM.render( - + , container @@ -321,14 +309,14 @@ describe('hooks', () => { expect(cnt).toBe(1); act(() => { - mutators.set({ a: 'c', foo: 'bar' }); + stateContainer.set({ a: 'c', foo: 'bar' }); }); await new Promise(r => setTimeout(r, 1)); expect(cnt).toBe(1); act(() => { - mutators.set({ a: 'd', foo: 'bar 2' }); + stateContainer.set({ a: 'd', foo: 'bar 2' }); }); await new Promise(r => setTimeout(r, 1)); @@ -336,9 +324,9 @@ describe('hooks', () => { }); test('does not re-render on same shape object', async () => { - const { store, mutators } = create({ foo: { bar: 'baz' } }); + const stateContainer = createStateContainer({ foo: { bar: 'baz' } }); const selector = (state: { foo: any }) => state.foo; - const { Provider, useSelector } = createStateContainerReactHelpers(); + const { Provider, useSelector } = createStateContainerReactHelpers(); let cnt = 0; const Demo: React.FC<{}> = () => { @@ -347,7 +335,7 @@ describe('hooks', () => { return <>{JSON.stringify(value)}; }; ReactDOM.render( - + , container @@ -357,14 +345,14 @@ describe('hooks', () => { expect(cnt).toBe(1); act(() => { - mutators.set({ foo: { bar: 'baz' } }); + stateContainer.set({ foo: { bar: 'baz' } }); }); await new Promise(r => setTimeout(r, 1)); expect(cnt).toBe(1); act(() => { - mutators.set({ foo: { bar: 'qux' } }); + stateContainer.set({ foo: { bar: 'qux' } }); }); await new Promise(r => setTimeout(r, 1)); @@ -372,7 +360,7 @@ describe('hooks', () => { }); test('can set custom comparator function to prevent re-renders on deep equality', async () => { - const { store, mutators } = create( + const stateContainer = createStateContainer( { foo: { bar: 'baz' } }, { set: () => (newState: { foo: { bar: string } }) => newState, @@ -380,7 +368,7 @@ describe('hooks', () => { ); const selector = (state: { foo: any }) => state.foo; const comparator = (prev: any, curr: any) => JSON.stringify(prev) === JSON.stringify(curr); - const { Provider, useSelector } = createStateContainerReactHelpers(); + const { Provider, useSelector } = createStateContainerReactHelpers(); let cnt = 0; const Demo: React.FC<{}> = () => { @@ -389,7 +377,7 @@ describe('hooks', () => { return <>{JSON.stringify(value)}; }; ReactDOM.render( - + , container @@ -399,13 +387,13 @@ describe('hooks', () => { expect(cnt).toBe(1); act(() => { - mutators.set({ foo: { bar: 'baz' } }); + stateContainer.set({ foo: { bar: 'baz' } }); }); await new Promise(r => setTimeout(r, 1)); expect(cnt).toBe(1); }); - xtest('unsubscribes when React un-mounts', () => {}); + test.todo('unsubscribes when React un-mounts'); }); }); diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts index 45b34b13251f4..36903f2d7c90f 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts @@ -35,7 +35,7 @@ export const createStateContainerReactHelpers = useContainer().transitions; + const useTransitions: () => Container['transitions'] = () => useContainer().transitions; const useSelector = ( selector: (state: UnboxState) => Result, diff --git a/src/plugins/kibana_utils/public/state_containers/types.ts b/src/plugins/kibana_utils/public/state_containers/types.ts index e120f60e72b8f..5f27a3d2c1dca 100644 --- a/src/plugins/kibana_utils/public/state_containers/types.ts +++ b/src/plugins/kibana_utils/public/state_containers/types.ts @@ -20,12 +20,13 @@ import { Observable } from 'rxjs'; import { Ensure, RecursiveReadonly } from '@kbn/utility-types'; +export type BaseState = object; export interface TransitionDescription { type: Type; args: Args; } -export type Transition = (...args: Args) => State; -export type PureTransition = ( +export type Transition = (...args: Args) => State; +export type PureTransition = ( state: RecursiveReadonly ) => Transition; export type EnsurePureTransition = Ensure>; @@ -34,15 +35,15 @@ export type PureTransitionsToTransitions = { [K in keyof T]: PureTransitionToTransition>; }; -export interface BaseStateContainer { +export interface BaseStateContainer { get: () => RecursiveReadonly; set: (state: State) => void; state$: Observable>; } export interface StateContainer< - State, - PureTransitions extends object = {}, + State extends BaseState, + PureTransitions extends object, PureSelectors extends object = {} > extends BaseStateContainer { transitions: Readonly>; @@ -50,7 +51,7 @@ export interface StateContainer< } export interface ReduxLikeStateContainer< - State, + State extends BaseState, PureTransitions extends object = {}, PureSelectors extends object = {} > extends StateContainer { @@ -63,14 +64,16 @@ export interface ReduxLikeStateContainer< } export type Dispatch = (action: T) => void; - -export type Middleware = ( +export type Middleware = ( store: Pick, 'getState' | 'dispatch'> ) => ( next: (action: TransitionDescription) => TransitionDescription | any ) => Dispatch; -export type Reducer = (state: State, action: TransitionDescription) => State; +export type Reducer = ( + state: State, + action: TransitionDescription +) => State; export type UnboxState< Container extends StateContainer @@ -80,7 +83,7 @@ export type UnboxTransitions< > = Container extends StateContainer ? T : never; export type Selector = (...args: Args) => Result; -export type PureSelector = ( +export type PureSelector = ( state: State ) => Selector; export type EnsurePureSelector = Ensure>; @@ -93,7 +96,12 @@ export type PureSelectorsToSelectors = { export type Comparator = (previous: Result, current: Result) => boolean; -export type MapStateToProps = (state: State) => StateProps; -export type Connect = ( +export type MapStateToProps = ( + state: State +) => StateProps; +export type Connect = < + Props extends object, + StatePropKeys extends keyof Props +>( mapStateToProp: MapStateToProps> ) => (component: React.ComponentType) => React.FC>; diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts index cc513bc674d0f..08ad1551420d2 100644 --- a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { BaseStateContainer, createStateContainer } from '../state_containers'; +import { BaseState, BaseStateContainer, createStateContainer } from '../state_containers'; import { defaultState, pureTransitions, @@ -89,7 +89,7 @@ describe('state_sync', () => { // initial sync of storage to state is not happening expect(container.getState()).toEqual(defaultState); - const storageState2 = [{ id: 1, text: 'todo', completed: true }]; + const storageState2 = { todos: [{ id: 1, text: 'todo', completed: true }] }; (testStateStorage.get as jest.Mock).mockImplementation(() => storageState2); storageChange$.next(storageState2); @@ -124,7 +124,7 @@ describe('state_sync', () => { start(); const originalState = container.getState(); - const storageState = [...originalState]; + const storageState = { ...originalState }; (testStateStorage.get as jest.Mock).mockImplementation(() => storageState); storageChange$.next(storageState); @@ -134,7 +134,7 @@ describe('state_sync', () => { }); it('storage change to null should notify state', () => { - container.set([{ completed: false, id: 1, text: 'changed' }]); + container.set({ todos: [{ completed: false, id: 1, text: 'changed' }] }); const { stop, start } = syncStates([ { stateContainer: withDefaultState(container, defaultState), @@ -189,8 +189,8 @@ describe('state_sync', () => { ]); start(); - const newStateFromUrl = [{ completed: false, id: 1, text: 'changed' }]; - history.replace('/#?_s=!((completed:!f,id:1,text:changed))'); + const newStateFromUrl = { todos: [{ completed: false, id: 1, text: 'changed' }] }; + history.replace('/#?_s=(todos:!((completed:!f,id:1,text:changed)))'); expect(container.getState()).toEqual(newStateFromUrl); expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(newStateFromUrl); @@ -220,7 +220,7 @@ describe('state_sync', () => { expect(history.length).toBe(startHistoryLength + 1); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + `"/#?_s=(todos:!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3')))"` ); stop(); @@ -248,14 +248,14 @@ describe('state_sync', () => { expect(history.length).toBe(startHistoryLength + 1); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + `"/#?_s=(todos:!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3')))"` ); await tick(); expect(history.length).toBe(startHistoryLength + 1); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + `"/#?_s=(todos:!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3')))"` ); stop(); @@ -294,7 +294,7 @@ describe('state_sync', () => { }); }); -function withDefaultState( +function withDefaultState( stateContainer: BaseStateContainer, // eslint-disable-next-line no-shadow defaultState: State @@ -302,7 +302,10 @@ function withDefaultState( return { ...stateContainer, set: (state: State | null) => { - stateContainer.set(state || defaultState); + stateContainer.set({ + ...defaultState, + ...state, + }); }, }; } diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.ts index f0ef1423dec71..9c1116e5da531 100644 --- a/src/plugins/kibana_utils/public/state_sync/state_sync.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync.ts @@ -23,6 +23,7 @@ import defaultComparator from 'fast-deep-equal'; import { IStateSyncConfig } from './types'; import { IStateStorage } from './state_sync_state_storage'; import { distinctUntilChangedWithInitialValue } from '../../common'; +import { BaseState } from '../state_containers'; /** * Utility for syncing application state wrapped in state container @@ -86,7 +87,10 @@ export interface ISyncStateRef({ +export function syncState< + State extends BaseState, + StateStorage extends IStateStorage = IStateStorage +>({ storageKey, stateStorage, stateContainer, diff --git a/src/plugins/kibana_utils/public/state_sync/types.ts b/src/plugins/kibana_utils/public/state_sync/types.ts index 0f7395ad0f0e5..3009c1d161a53 100644 --- a/src/plugins/kibana_utils/public/state_sync/types.ts +++ b/src/plugins/kibana_utils/public/state_sync/types.ts @@ -17,10 +17,11 @@ * under the License. */ -import { BaseStateContainer } from '../state_containers/types'; +import { BaseState, BaseStateContainer } from '../state_containers/types'; import { IStateStorage } from './state_sync_state_storage'; -export interface INullableBaseStateContainer extends BaseStateContainer { +export interface INullableBaseStateContainer + extends BaseStateContainer { // State container for stateSync() have to accept "null" // for example, set() implementation could handle null and fallback to some default state // this is required to handle edge case, when state in storage becomes empty and syncing is in progress. @@ -29,7 +30,7 @@ export interface INullableBaseStateContainer extends BaseStateContainer { /** diff --git a/tasks/config/karma.js b/tasks/config/karma.js index c0d6074da61c5..0acd452530b30 100644 --- a/tasks/config/karma.js +++ b/tasks/config/karma.js @@ -20,6 +20,7 @@ import { dirname } from 'path'; import { times } from 'lodash'; import { makeJunitReportPath } from '@kbn/test'; +import * as UiSharedDeps from '@kbn/ui-shared-deps'; const TOTAL_CI_SHARDS = 4; const ROOT = dirname(require.resolve('../../package.json')); @@ -48,6 +49,25 @@ module.exports = function(grunt) { return ['progress']; } + function getKarmaFiles(shardNum) { + return [ + 'http://localhost:5610/test_bundle/built_css.css', + + `http://localhost:5610/bundles/kbn-ui-shared-deps/${UiSharedDeps.distFilename}`, + 'http://localhost:5610/built_assets/dlls/vendors.bundle.dll.js', + + shardNum === undefined + ? `http://localhost:5610/bundles/tests.bundle.js` + : `http://localhost:5610/bundles/tests.bundle.js?shards=${TOTAL_CI_SHARDS}&shard_num=${shardNum}`, + + // this causes tilemap tests to fail, probably because the eui styles haven't been + // included in the karma harness a long some time, if ever + // `http://localhost:5610/bundles/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`, + 'http://localhost:5610/built_assets/dlls/vendors.style.dll.css', + 'http://localhost:5610/bundles/tests.style.css', + ]; + } + const config = { options: { // base path that will be used to resolve all patterns (eg. files, exclude) @@ -90,15 +110,7 @@ module.exports = function(grunt) { }, // list of files / patterns to load in the browser - files: [ - 'http://localhost:5610/test_bundle/built_css.css', - - 'http://localhost:5610/built_assets/dlls/vendors.bundle.dll.js', - 'http://localhost:5610/bundles/tests.bundle.js', - - 'http://localhost:5610/built_assets/dlls/vendors.style.dll.css', - 'http://localhost:5610/bundles/tests.style.css', - ], + files: getKarmaFiles(), proxies: { '/tests/': 'http://localhost:5610/tests/', @@ -181,15 +193,7 @@ module.exports = function(grunt) { config[`ciShard-${n}`] = { singleRun: true, options: { - files: [ - 'http://localhost:5610/test_bundle/built_css.css', - - 'http://localhost:5610/built_assets/dlls/vendors.bundle.dll.js', - `http://localhost:5610/bundles/tests.bundle.js?shards=${TOTAL_CI_SHARDS}&shard_num=${n}`, - - 'http://localhost:5610/built_assets/dlls/vendors.style.dll.css', - 'http://localhost:5610/bundles/tests.style.css', - ], + files: getKarmaFiles(n), }, }; }); diff --git a/test/common/services/security/role_mappings.ts b/test/common/services/security/role_mappings.ts new file mode 100644 index 0000000000000..cc2fa23825498 --- /dev/null +++ b/test/common/services/security/role_mappings.ts @@ -0,0 +1,66 @@ +/* + * 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 axios, { AxiosInstance } from 'axios'; +import util from 'util'; +import { ToolingLog } from '@kbn/dev-utils'; + +export class RoleMappings { + private log: ToolingLog; + private axios: AxiosInstance; + + constructor(url: string, log: ToolingLog) { + this.log = log; + this.axios = axios.create({ + headers: { 'kbn-xsrf': 'x-pack/ftr/services/security/role_mappings' }, + baseURL: url, + maxRedirects: 0, + validateStatus: () => true, // we do our own validation below and throw better error messages + }); + } + + public async create(name: string, roleMapping: Record) { + this.log.debug(`creating role mapping ${name}`); + const { data, status, statusText } = await this.axios.post( + `/internal/security/role_mapping/${name}`, + roleMapping + ); + if (status !== 200) { + throw new Error( + `Expected status code of 200, received ${status} ${statusText}: ${util.inspect(data)}` + ); + } + this.log.debug(`created role mapping ${name}`); + } + + public async delete(name: string) { + this.log.debug(`deleting role mapping ${name}`); + const { data, status, statusText } = await this.axios.delete( + `/internal/security/role_mapping/${name}` + ); + if (status !== 200 && status !== 404) { + throw new Error( + `Expected status code of 200 or 404, received ${status} ${statusText}: ${util.inspect( + data + )}` + ); + } + this.log.debug(`deleted role mapping ${name}`); + } +} diff --git a/test/common/services/security/security.ts b/test/common/services/security/security.ts index 6649a765a9e50..4eebb7b6697e0 100644 --- a/test/common/services/security/security.ts +++ b/test/common/services/security/security.ts @@ -21,6 +21,7 @@ import { format as formatUrl } from 'url'; import { Role } from './role'; import { User } from './user'; +import { RoleMappings } from './role_mappings'; import { FtrProviderContext } from '../../ftr_provider_context'; export function SecurityServiceProvider({ getService }: FtrProviderContext) { @@ -30,6 +31,7 @@ export function SecurityServiceProvider({ getService }: FtrProviderContext) { return new (class SecurityService { role = new Role(url, log); + roleMappings = new RoleMappings(url, log); user = new User(url, log); })(); } diff --git a/test/functional/services/apps_menu.ts b/test/functional/services/apps_menu.ts index a4cd98b2a06ec..fe17532f6a41a 100644 --- a/test/functional/services/apps_menu.ts +++ b/test/functional/services/apps_menu.ts @@ -25,7 +25,7 @@ export function AppsMenuProvider({ getService }: FtrProviderContext) { return new (class AppsMenu { /** - * Get the text and href from each of the links in the apps menu + * Get the attributes from each of the links in the apps menu */ public async readLinks() { const appMenu = await testSubjects.find('navDrawer'); @@ -37,12 +37,21 @@ export function AppsMenuProvider({ getService }: FtrProviderContext) { return { text: $(link).text(), href: $(link).attr('href'), + disabled: $(link).attr('disabled') != null, }; }); return links; } + /** + * Get the attributes from the link with the given name. + * @param name + */ + public async getLink(name: string) { + return (await this.readLinks()).find(nl => nl.text === name); + } + /** * Determine if an app link with the given name exists * @param name diff --git a/test/plugin_functional/plugins/core_app_status/kibana.json b/test/plugin_functional/plugins/core_app_status/kibana.json new file mode 100644 index 0000000000000..91d8e6fd8f9e1 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "core_app_status", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["core_app_status"], + "server": false, + "ui": true +} diff --git a/test/plugin_functional/plugins/core_app_status/package.json b/test/plugin_functional/plugins/core_app_status/package.json new file mode 100644 index 0000000000000..61655487c6acb --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/package.json @@ -0,0 +1,17 @@ +{ + "name": "core_app_status", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/core_app_status", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.5.3" + } +} diff --git a/test/plugin_functional/plugins/core_app_status/public/application.tsx b/test/plugin_functional/plugins/core_app_status/public/application.tsx new file mode 100644 index 0000000000000..323774392a6d7 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/public/application.tsx @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageHeader, + EuiPageHeaderSection, + EuiTitle, +} from '@elastic/eui'; + +import { AppMountContext, AppMountParameters } from 'kibana/public'; + +const AppStatusApp = () => ( + + + + + +

Welcome to App Status Test App!

+
+
+
+ + + + +

App Status Test App home page section title

+
+
+
+ App Status Test App content +
+
+
+); + +export const renderApp = (context: AppMountContext, { element }: AppMountParameters) => { + render(, element); + + return () => unmountComponentAtNode(element); +}; diff --git a/webpackShims/angular.js b/test/plugin_functional/plugins/core_app_status/public/index.ts similarity index 74% rename from webpackShims/angular.js rename to test/plugin_functional/plugins/core_app_status/public/index.ts index 4857f0f8975bc..e0ad7c25a54b8 100644 --- a/webpackShims/angular.js +++ b/test/plugin_functional/plugins/core_app_status/public/index.ts @@ -17,6 +17,8 @@ * under the License. */ -require('jquery'); -require('../node_modules/angular/angular'); -module.exports = window.angular; +import { PluginInitializer } from 'kibana/public'; +import { CoreAppStatusPlugin, CoreAppStatusPluginSetup, CoreAppStatusPluginStart } from './plugin'; + +export const plugin: PluginInitializer = () => + new CoreAppStatusPlugin(); diff --git a/test/plugin_functional/plugins/core_app_status/public/plugin.tsx b/test/plugin_functional/plugins/core_app_status/public/plugin.tsx new file mode 100644 index 0000000000000..85caaaf5f9090 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/public/plugin.tsx @@ -0,0 +1,56 @@ +/* + * 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 { Plugin, CoreSetup, AppUpdater, AppUpdatableFields, CoreStart } from 'kibana/public'; +import { BehaviorSubject } from 'rxjs'; + +export class CoreAppStatusPlugin + implements Plugin { + private appUpdater = new BehaviorSubject(() => ({})); + + public setup(core: CoreSetup, deps: {}) { + core.application.register({ + id: 'app_status', + title: 'App Status', + euiIconType: 'snowflake', + updater$: this.appUpdater, + async mount(context, params) { + const { renderApp } = await import('./application'); + return renderApp(context, params); + }, + }); + + return {}; + } + + public start(core: CoreStart) { + return { + setAppStatus: (status: Partial) => { + this.appUpdater.next(() => status); + }, + navigateToApp: async (appId: string) => { + return core.application.navigateToApp(appId); + }, + }; + } + public stop() {} +} + +export type CoreAppStatusPluginSetup = ReturnType; +export type CoreAppStatusPluginStart = ReturnType; diff --git a/test/plugin_functional/plugins/core_app_status/tsconfig.json b/test/plugin_functional/plugins/core_app_status/tsconfig.json new file mode 100644 index 0000000000000..5fcaeafbb0d85 --- /dev/null +++ b/test/plugin_functional/plugins/core_app_status/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../typings/**/*", + ], + "exclude": [] +} diff --git a/test/plugin_functional/test_suites/core_plugins/application_status.ts b/test/plugin_functional/test_suites/core_plugins/application_status.ts new file mode 100644 index 0000000000000..703ae30533bae --- /dev/null +++ b/test/plugin_functional/test_suites/core_plugins/application_status.ts @@ -0,0 +1,116 @@ +/* + * 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 expect from '@kbn/expect'; +import { + AppNavLinkStatus, + AppStatus, + AppUpdatableFields, +} from '../../../../src/core/public/application/types'; +import { PluginFunctionalProviderContext } from '../../services'; +import { CoreAppStatusPluginStart } from '../../plugins/core_app_status/public/plugin'; +import '../../plugins/core_provider_plugin/types'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const PageObjects = getPageObjects(['common']); + const browser = getService('browser'); + const appsMenu = getService('appsMenu'); + + const setAppStatus = async (s: Partial) => { + await browser.executeAsync(async (status: Partial, cb: Function) => { + const plugin = window.__coreProvider.start.plugins + .core_app_status as CoreAppStatusPluginStart; + plugin.setAppStatus(status); + cb(); + }, s); + }; + + const navigateToApp = async (i: string): Promise<{ error?: string }> => { + return (await browser.executeAsync(async (appId, cb: Function) => { + // navigating in legacy mode performs a page refresh + // and webdriver seems to re-execute the script after the reload + // as it considers it didn't end on the previous session. + // however when testing navigation to NP app, __coreProvider is not accessible + // so we need to check for existence. + if (!window.__coreProvider) { + cb({}); + } + const plugin = window.__coreProvider.start.plugins + .core_app_status as CoreAppStatusPluginStart; + try { + await plugin.navigateToApp(appId); + cb({}); + } catch (e) { + cb({ + error: e.message, + }); + } + }, i)) as any; + }; + + describe('application status management', () => { + beforeEach(async () => { + await PageObjects.common.navigateToApp('settings'); + }); + + it('can change the navLink status at runtime', async () => { + await setAppStatus({ + navLinkStatus: AppNavLinkStatus.disabled, + }); + let link = await appsMenu.getLink('App Status'); + expect(link).not.to.eql(undefined); + expect(link!.disabled).to.eql(true); + + await setAppStatus({ + navLinkStatus: AppNavLinkStatus.hidden, + }); + link = await appsMenu.getLink('App Status'); + expect(link).to.eql(undefined); + + await setAppStatus({ + navLinkStatus: AppNavLinkStatus.visible, + tooltip: 'Some tooltip', + }); + link = await appsMenu.getLink('Some tooltip'); // the tooltip replaces the name in the selector we use. + expect(link).not.to.eql(undefined); + expect(link!.disabled).to.eql(false); + }); + + it('shows an error when navigating to an inaccessible app', async () => { + await setAppStatus({ + status: AppStatus.inaccessible, + }); + + const result = await navigateToApp('app_status'); + expect(result.error).to.contain( + 'Trying to navigate to an inaccessible application: app_status' + ); + }); + + it('allows to navigate to an accessible app', async () => { + await setAppStatus({ + status: AppStatus.accessible, + }); + + const result = await navigateToApp('app_status'); + expect(result.error).to.eql(undefined); + }); + }); +} diff --git a/test/plugin_functional/test_suites/core_plugins/index.ts b/test/plugin_functional/test_suites/core_plugins/index.ts index 6c55245d10f03..d66e2e7dc5da7 100644 --- a/test/plugin_functional/test_suites/core_plugins/index.ts +++ b/test/plugin_functional/test_suites/core_plugins/index.ts @@ -28,5 +28,6 @@ export default function({ loadTestFile }: PluginFunctionalProviderContext) { loadTestFile(require.resolve('./ui_settings')); loadTestFile(require.resolve('./top_nav')); loadTestFile(require.resolve('./application_leave_confirm')); + loadTestFile(require.resolve('./application_status')); }); } diff --git a/webpackShims/moment-timezone.js b/webpackShims/moment-timezone.js deleted file mode 100644 index d5e032ff21eef..0000000000000 --- a/webpackShims/moment-timezone.js +++ /dev/null @@ -1,21 +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. - */ - -var moment = (module.exports = require('../node_modules/moment-timezone/moment-timezone')); -moment.tz.load(require('../node_modules/moment-timezone/data/packed/latest.json')); diff --git a/x-pack/dev-tools/jest/create_jest_config.js b/x-pack/dev-tools/jest/create_jest_config.js index 2127c956e6ae4..b746f0ae258cd 100644 --- a/x-pack/dev-tools/jest/create_jest_config.js +++ b/x-pack/dev-tools/jest/create_jest_config.js @@ -29,6 +29,7 @@ export function createJestConfig({ kibanaDirectory, xPackKibanaDirectory }) { '\\.(css|less|scss)$': `${kibanaDirectory}/src/dev/jest/mocks/style_mock.js`, '^test_utils/enzyme_helpers': `${xPackKibanaDirectory}/test_utils/enzyme_helpers.tsx`, '^test_utils/find_test_subject': `${xPackKibanaDirectory}/test_utils/find_test_subject.ts`, + '^test_utils/stub_web_worker': `${xPackKibanaDirectory}/test_utils/stub_web_worker.ts`, }, coverageDirectory: '/../target/kibana-coverage/jest', coverageReporters: !!process.env.CODE_COVERAGE ? ['json'] : ['html'], diff --git a/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx b/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx index 8cdb7f050027d..0bd3896782603 100644 --- a/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx +++ b/x-pack/legacy/plugins/apm/public/context/LicenseContext/index.tsx @@ -16,8 +16,8 @@ export const LicenseContext = React.createContext( export function LicenseProvider({ children }: { children: React.ReactChild }) { const { license$ } = useApmPluginContext().plugins.licensing; - const license = useObservable(license$); - const hasInvalidLicense = !license?.isActive; + const license = useObservable(license$, { isActive: true } as ILicense); + const hasInvalidLicense = !license.isActive; // if license is invalid show an error message if (hasInvalidLicense) { diff --git a/x-pack/legacy/plugins/canvas/webpackShims/moment.js b/x-pack/legacy/plugins/canvas/webpackShims/moment.js deleted file mode 100644 index 1261aa7f7bd0f..0000000000000 --- a/x-pack/legacy/plugins/canvas/webpackShims/moment.js +++ /dev/null @@ -1,2 +0,0 @@ -/* eslint-disable */ -module.exports = require('../../../node_modules/moment/min/moment.min.js'); diff --git a/x-pack/legacy/plugins/graph/public/angular/templates/_sidebar.scss b/x-pack/legacy/plugins/graph/public/angular/templates/_sidebar.scss index 6fa51c1ba1ec8..e54158e2ad8ce 100644 --- a/x-pack/legacy/plugins/graph/public/angular/templates/_sidebar.scss +++ b/x-pack/legacy/plugins/graph/public/angular/templates/_sidebar.scss @@ -13,6 +13,7 @@ .help-block { font-size: $euiFontSizeXS; + color: $euiTextColor; } } diff --git a/x-pack/legacy/plugins/graph/public/angular/templates/index.html b/x-pack/legacy/plugins/graph/public/angular/templates/index.html index 20b1059ae45ec..4493d794cb8d1 100644 --- a/x-pack/legacy/plugins/graph/public/angular/templates/index.html +++ b/x-pack/legacy/plugins/graph/public/angular/templates/index.html @@ -1,4 +1,4 @@ -
+
@@ -81,6 +81,7 @@ @@ -386,4 +396,4 @@
- + diff --git a/x-pack/legacy/plugins/graph/public/components/app.tsx b/x-pack/legacy/plugins/graph/public/components/app.tsx index 5ff7fc2e5da93..957a8f66907a1 100644 --- a/x-pack/legacy/plugins/graph/public/components/app.tsx +++ b/x-pack/legacy/plugins/graph/public/components/app.tsx @@ -16,6 +16,7 @@ import { FieldManager } from './field_manager'; import { SearchBarProps, SearchBar } from './search_bar'; import { GraphStore } from '../state_management'; import { GuidancePanel } from './guidance_panel'; +import { GraphTitle } from './graph_title'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; @@ -52,6 +53,7 @@ export function GraphApp(props: GraphAppProps) { > <> + {props.isInitialized && }
diff --git a/x-pack/legacy/plugins/graph/public/components/graph_title.tsx b/x-pack/legacy/plugins/graph/public/components/graph_title.tsx new file mode 100644 index 0000000000000..8151900da0c07 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/components/graph_title.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { EuiScreenReaderOnly } from '@elastic/eui'; +import React from 'react'; + +import { GraphState, metaDataSelector } from '../state_management'; + +interface GraphTitleProps { + title: string; +} + +/** + * Component showing the title of the current workspace as a heading visible for screen readers + */ +export const GraphTitle = connect((state: GraphState) => ({ + title: metaDataSelector(state).title, +}))(({ title }: GraphTitleProps) => ( + +

{title}

+
+)); diff --git a/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss b/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss index f1c332eba1aa8..e1423b794dcd3 100644 --- a/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss +++ b/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss @@ -15,16 +15,10 @@ position: relative; padding-left: $euiSizeXL; margin-bottom: $euiSizeL; - - button { - // make buttons wrap lines like regular text - display: contents; - } } .gphGuidancePanel__item--disabled { color: $euiColorDarkShade; - pointer-events: none; button { color: $euiColorDarkShade !important; diff --git a/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx b/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx index 5fae9720db39a..f34b82d6bb1a3 100644 --- a/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx +++ b/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx @@ -13,6 +13,7 @@ import { EuiText, EuiLink, EuiCallOut, + EuiScreenReaderOnly, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import classNames from 'classnames'; @@ -53,6 +54,7 @@ function ListItem({ 'gphGuidancePanel__item--disabled': state === 'disabled', })} aria-disabled={state === 'disabled'} + aria-current={state === 'active' ? 'step' : undefined} > {state !== 'disabled' && ( -

+

{i18n.translate('xpack.graph.guidancePanel.title', { defaultMessage: 'Three steps to your graph', })} @@ -104,7 +106,7 @@ function GuidancePanelComponent(props: GuidancePanelProps) { -
    +
      {i18n.translate( @@ -116,7 +118,7 @@ function GuidancePanelComponent(props: GuidancePanelProps) { - + {i18n.translate('xpack.graph.guidancePanel.fieldsItem.fieldsButtonLabel', { defaultMessage: 'Add fields.', })} @@ -128,7 +130,7 @@ function GuidancePanelComponent(props: GuidancePanelProps) { defaultMessage="Enter a query in the search bar to start exploring. Don't know where to start? {topTerms}." values={{ topTerms: ( - + {i18n.translate('xpack.graph.guidancePanel.nodesItem.topTermsButtonLabel', { defaultMessage: 'Graph the top terms', })} @@ -137,7 +139,7 @@ function GuidancePanelComponent(props: GuidancePanelProps) { }} /> -
+
@@ -157,7 +159,15 @@ function GuidancePanelComponent(props: GuidancePanelProps) { title={i18n.translate('xpack.graph.noDataSourceNotificationMessageTitle', { defaultMessage: 'No data source', })} + heading="h1" > + +

+ {i18n.translate('xpack.graph.noDataSourceNotificationMessageTitle', { + defaultMessage: 'No data source', + })} +

+

+

-

+ } />
@@ -88,12 +89,12 @@ function getNoItemsMessage( +

-

+ } body={ diff --git a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js b/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js index 9463eccb93a02..2c0ea7fe699b8 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.js @@ -20,8 +20,8 @@ import sinon from 'sinon'; import { findTestSubject } from '@elastic/eui/lib/test'; import { positiveNumbersAboveZeroErrorMessage, - numberRequiredMessage, positiveNumberRequiredMessage, + numberRequiredMessage, maximumAgeRequiredMessage, maximumSizeRequiredMessage, policyNameRequiredMessage, @@ -243,17 +243,18 @@ describe('edit policy', () => { noRollover(rendered); setPolicyName(rendered, 'mypolicy'); activatePhase(rendered, 'warm'); + setPhaseAfter(rendered, 'warm', ''); save(rendered); expectedErrorMessages(rendered, [numberRequiredMessage]); }); - test('should show positive number required above zero error when trying to save warm phase with 0 for after', () => { + test('should allow 0 for phase timing', () => { const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); activatePhase(rendered, 'warm'); setPhaseAfter(rendered, 'warm', 0); save(rendered); - expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + expectedErrorMessages(rendered, []); }); test('should show positive number required error when trying to save warm phase with -1 for after', () => { const rendered = mountWithIntl(component); @@ -383,14 +384,14 @@ describe('edit policy', () => { }); }); describe('cold phase', () => { - test('should show positive number required error when trying to save cold phase with 0 for after', () => { + test('should allow 0 for phase timing', () => { const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); activatePhase(rendered, 'cold'); setPhaseAfter(rendered, 'cold', 0); save(rendered); - expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + expectedErrorMessages(rendered, []); }); test('should show positive number required error when trying to save cold phase with -1 for after', () => { const rendered = mountWithIntl(component); @@ -464,14 +465,14 @@ describe('edit policy', () => { }); }); describe('delete phase', () => { - test('should show positive number required error when trying to save delete phase with 0 for after', () => { + test('should allow 0 for phase timing', () => { const rendered = mountWithIntl(component); noRollover(rendered); setPolicyName(rendered, 'mypolicy'); activatePhase(rendered, 'delete'); setPhaseAfter(rendered, 'delete', 0); save(rendered); - expectedErrorMessages(rendered, [positiveNumbersAboveZeroErrorMessage]); + expectedErrorMessages(rendered, []); }); test('should show positive number required error when trying to save delete phase with -1 for after', () => { const rendered = mountWithIntl(component); diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js b/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js index 0ed28bbaa905f..b4c9f4e958cd2 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/sections/edit_policy/components/min_age_input.js @@ -131,7 +131,7 @@ export const MinAgeInput = props => { onChange={async e => { setPhaseData(PHASE_ROLLOVER_MINIMUM_AGE, e.target.value); }} - min={1} + min={0} /> diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js index b0af0e6547803..a8f7fd3f4bdfa 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/cold_phase.js @@ -17,7 +17,7 @@ import { export const defaultColdPhase = { [PHASE_ENABLED]: false, [PHASE_ROLLOVER_ALIAS]: '', - [PHASE_ROLLOVER_MINIMUM_AGE]: '', + [PHASE_ROLLOVER_MINIMUM_AGE]: 0, [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', [PHASE_NODE_ATTRS]: '', [PHASE_REPLICA_COUNT]: '', diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js index 5a44539ff90f8..b5296cd83fabd 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/delete_phase.js @@ -15,7 +15,7 @@ export const defaultDeletePhase = { [PHASE_ENABLED]: false, [PHASE_ROLLOVER_ENABLED]: false, [PHASE_ROLLOVER_ALIAS]: '', - [PHASE_ROLLOVER_MINIMUM_AGE]: '', + [PHASE_ROLLOVER_MINIMUM_AGE]: 0, [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', }; export const defaultEmptyDeletePhase = defaultDeletePhase; diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js index d3dc55178b253..f02ac2096675f 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/defaults/warm_phase.js @@ -23,7 +23,7 @@ export const defaultWarmPhase = { [PHASE_ROLLOVER_ALIAS]: '', [PHASE_FORCE_MERGE_SEGMENTS]: '', [PHASE_FORCE_MERGE_ENABLED]: false, - [PHASE_ROLLOVER_MINIMUM_AGE]: '', + [PHASE_ROLLOVER_MINIMUM_AGE]: 0, [PHASE_ROLLOVER_MINIMUM_AGE_UNITS]: 'd', [PHASE_NODE_ATTRS]: '', [PHASE_SHRINK_ENABLED]: false, diff --git a/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js b/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js index 026845c78ee66..750a7feb19c3d 100644 --- a/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js +++ b/x-pack/legacy/plugins/index_lifecycle_management/public/store/selectors/lifecycle.js @@ -120,12 +120,6 @@ export const validatePhase = (type, phase, errors) => { phaseErrors[numberedAttribute] = [numberRequiredMessage]; } else if (phase[numberedAttribute] < 0) { phaseErrors[numberedAttribute] = [positiveNumberRequiredMessage]; - } else if ( - (numberedAttribute === PHASE_ROLLOVER_MINIMUM_AGE || - numberedAttribute === PHASE_PRIMARY_SHARD_COUNT) && - phase[numberedAttribute] < 1 - ) { - phaseErrors[numberedAttribute] = [positiveNumbersAboveZeroErrorMessage]; } } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/__snapshots__/vector_icon.test.js.snap b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/__snapshots__/vector_icon.test.js.snap index 57368b52a2bce..5837a80ec3083 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/__snapshots__/vector_icon.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/__snapshots__/vector_icon.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Renders CircleIcon with correct styles when isPointOnly 1`] = ` +exports[`Renders CircleIcon 1`] = ` `; -exports[`Renders LineIcon with correct styles when isLineOnly 1`] = ` +exports[`Renders LineIcon 1`] = ` `; -exports[`Renders PolygonIcon with correct styles when not line only or not point only 1`] = ` +exports[`Renders PolygonIcon 1`] = ` `; -exports[`Renders SymbolIcon with correct styles when isPointOnly and symbolId provided 1`] = ` +exports[`Renders SymbolIcon 1`] = ` ; - } +export function VectorIcon({ fillColor, isPointsOnly, isLinesOnly, strokeColor, symbolId }) { + if (isLinesOnly) { const style = { - stroke: this.props.getColorForProperty(VECTOR_STYLES.LINE_COLOR, false), - strokeWidth: '1px', - fill: this.props.getColorForProperty(VECTOR_STYLES.FILL_COLOR, false), + stroke: strokeColor, + strokeWidth: '4px', }; + return ; + } - if (!this.state.isPointsOnly) { - return ; - } + const style = { + stroke: strokeColor, + strokeWidth: '1px', + fill: fillColor, + }; - if (!this.props.symbolId) { - return ; - } + if (!isPointsOnly) { + return ; + } - return ( - - ); + if (!symbolId) { + return ; } + + return ( + + ); } VectorIcon.propTypes = { - getColorForProperty: PropTypes.func.isRequired, + fillColor: PropTypes.string, + isPointsOnly: PropTypes.bool.isRequired, + isLinesOnly: PropTypes.bool.isRequired, + strokeColor: PropTypes.string.isRequired, symbolId: PropTypes.string, - loadIsPointsOnly: PropTypes.func.isRequired, - loadIsLinesOnly: PropTypes.func.isRequired, }; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.test.js index ee0058a6ef1aa..9d1a4d75beba2 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.test.js @@ -8,113 +8,51 @@ import React from 'react'; import { shallow } from 'enzyme'; import { VectorIcon } from './vector_icon'; -import { VectorStyle } from '../../vector_style'; -import { extractColorFromStyleProperty } from './extract_color_from_style_property'; -import { VECTOR_STYLES } from '../../vector_style_defaults'; -let isPointsOnly = false; -let isLinesOnly = false; -const styles = { - fillColor: { - type: VectorStyle.STYLE_TYPE.STATIC, - options: { - color: '#ff0000', - }, - }, - lineColor: { - type: VectorStyle.STYLE_TYPE.DYNAMIC, - options: { - color: 'Blues', - field: { - name: 'prop1', - }, - }, - }, -}; - -const defaultProps = { - getColorForProperty: (styleProperty, isLinesOnly) => { - if (isLinesOnly) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.LINE_COLOR], 'grey'); - } - - if (styleProperty === VECTOR_STYLES.LINE_COLOR) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.LINE_COLOR], 'none'); - } else if (styleProperty === VECTOR_STYLES.FILL_COLOR) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.FILL_COLOR], 'grey'); - } else { - //unexpected - console.error('Cannot return color for properties other then line or fill color'); - } - }, - - loadIsPointsOnly: () => { - return isPointsOnly; - }, - loadIsLinesOnly: () => { - return isLinesOnly; - }, -}; - -function configureIsLinesOnly() { - isLinesOnly = true; - isPointsOnly = false; -} - -function configureIsPointsOnly() { - isLinesOnly = false; - isPointsOnly = true; -} - -function configureNotLineOrPointOnly() { - isLinesOnly = false; - isPointsOnly = false; -} - -test('Renders PolygonIcon with correct styles when not line only or not point only', async () => { - configureNotLineOrPointOnly(); - const component = shallow(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); +test('Renders PolygonIcon', () => { + const component = shallow( + + ); expect(component).toMatchSnapshot(); }); -test('Renders LineIcon with correct styles when isLineOnly', async () => { - configureIsLinesOnly(); - const component = shallow(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); +test('Renders LineIcon', () => { + const component = shallow( + + ); expect(component).toMatchSnapshot(); }); -test('Renders CircleIcon with correct styles when isPointOnly', async () => { - configureIsPointsOnly(); - const component = shallow(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); +test('Renders CircleIcon', () => { + const component = shallow( + + ); expect(component).toMatchSnapshot(); }); -test('Renders SymbolIcon with correct styles when isPointOnly and symbolId provided', async () => { - configureIsPointsOnly(); - const component = shallow(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); +test('Renders SymbolIcon', () => { + const component = shallow( + + ); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_style_legend.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_style_legend.js index df302c42d48ed..a7e98c83468ae 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_style_legend.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_style_legend.js @@ -4,57 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; -import React, { Component, Fragment } from 'react'; - -export class VectorStyleLegend extends Component { - state = { - styles: [], - }; - - componentDidMount() { - this._isMounted = true; - this._prevStyleDescriptors = undefined; - this._loadRows(); - } - - componentDidUpdate() { - this._loadRows(); - } - - componentWillUnmount() { - this._isMounted = false; - } - - _loadRows = _.debounce(async () => { - const styles = await this.props.getLegendDetailStyleProperties(); - const styleDescriptorPromises = styles.map(async style => { - return { - type: style.getStyleName(), - options: style.getOptions(), - fieldMeta: style.getFieldMeta(), - label: await style.getField().getLabel(), - }; - }); - - const styleDescriptors = await Promise.all(styleDescriptorPromises); - if (this._isMounted && !_.isEqual(styleDescriptors, this._prevStyleDescriptors)) { - this._prevStyleDescriptors = styleDescriptors; - this.setState({ styles: styles }); - } - }, 100); - - render() { - return this.state.styles.map(style => { - return ( - - {style.renderLegendDetailRow({ - loadIsLinesOnly: this.props.loadIsLinesOnly, - loadIsPointsOnly: this.props.loadIsPointsOnly, - symbolId: this.props.symbolId, - })} - - ); - }); - } +import React, { Fragment } from 'react'; + +export function VectorStyleLegend({ isLinesOnly, isPointsOnly, styles, symbolId }) { + return styles.map(style => { + return ( + + {style.renderLegendDetailRow({ + isLinesOnly, + isPointsOnly, + symbolId, + })} + + ); + }); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js index 8e80e036dbb8b..dffe513644db8 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js @@ -86,25 +86,14 @@ export class VectorStyleEditor extends Component { async _loadSupportedFeatures() { const supportedFeatures = await this.props.layer.getSource().getSupportedShapeTypes(); - const isPointsOnly = await this.props.loadIsPointsOnly(); - const isLinesOnly = await this.props.loadIsLinesOnly(); - if (!this._isMounted) { return; } - if ( - _.isEqual(supportedFeatures, this.state.supportedFeatures) && - isPointsOnly === this.state.isPointsOnly && - isLinesOnly === this.state.isLinesOnly - ) { - return; - } - let selectedFeature = VECTOR_SHAPE_TYPES.POLYGON; - if (isPointsOnly) { + if (this.props.isPointsOnly) { selectedFeature = VECTOR_SHAPE_TYPES.POINT; - } else if (isLinesOnly) { + } else if (this.props.isLinesOnly) { selectedFeature = VECTOR_SHAPE_TYPES.LINE; } @@ -112,12 +101,7 @@ export class VectorStyleEditor extends Component { !_.isEqual(supportedFeatures, this.state.supportedFeatures) || selectedFeature !== this.state.selectedFeature ) { - this.setState({ - supportedFeatures, - selectedFeature, - isPointsOnly, - isLinesOnly, - }); + this.setState({ supportedFeatures, selectedFeature }); } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap index 26e36cb97a791..8da8cfaa71e2c 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap @@ -1,98 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Should render categorical legend 1`] = ` -
- - - - - - - 0_format - - - - - - - - - - - - 10_format - - - - - - - - - - - - - - - foobar_label - - - - - - -
-`; +exports[`Should render categorical legend 1`] = `""`; exports[`Should render ranged legend 1`] = ` { - return isLinesOnly; - }; - - const loadIsPointsOnly = () => { - return isPointsOnly; - }; - - const getColorForProperty = (styleProperty, isLinesOnly) => { - if (isLinesOnly) { - return color; - } - - return this.getStyleName() === styleProperty ? color : 'none'; - }; - + const fillColor = this.getStyleName() === VECTOR_STYLES.FILL_COLOR ? color : 'none'; return ( ); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js index dbf704c9cbe4c..0affeefde1313 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js @@ -24,7 +24,7 @@ const mockField = { }, }; -test('Should render ranged legend', async () => { +test('Should render ranged legend', () => { const colorStyle = new DynamicColorProperty( { color: 'Blues', @@ -40,25 +40,15 @@ test('Should render ranged legend', async () => { ); const legendRow = colorStyle.renderLegendDetailRow({ - loadIsPointsOnly: () => { - return true; - }, - loadIsLinesOnly: () => { - return false; - }, + isPointsOnly: true, + isLinesOnly: false, }); - const component = shallow(legendRow); - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - expect(component).toMatchSnapshot(); }); -test('Should render categorical legend', async () => { +test('Should render categorical legend', () => { const colorStyle = new DynamicColorProperty( { useCustomColorRamp: true, @@ -84,20 +74,10 @@ test('Should render categorical legend', async () => { ); const legendRow = colorStyle.renderLegendDetailRow({ - loadIsPointsOnly: () => { - return true; - }, - loadIsLinesOnly: () => { - return false; - }, + isPointsOnly: true, + isLinesOnly: false, }); - const component = shallow(legendRow); - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - expect(component).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js index bac3c96581967..cb5858fa47b3e 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js @@ -165,12 +165,12 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return null; } - _renderCategoricalLegend({ loadIsPointsOnly, loadIsLinesOnly, symbolId }) { + _renderCategoricalLegend({ isPointsOnly, isLinesOnly, symbolId }) { return ( ); @@ -180,11 +180,11 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return ; } - renderLegendDetailRow({ loadIsPointsOnly, loadIsLinesOnly, symbolId }) { + renderLegendDetailRow({ isPointsOnly, isLinesOnly, symbolId }) { if (this.isRanged()) { return this._renderRangeLegend(); } else if (this.hasBreaks()) { - return this._renderCategoricalLegend({ loadIsPointsOnly, loadIsLinesOnly, symbolId }); + return this._renderCategoricalLegend({ isPointsOnly, isLinesOnly, symbolId }); } else { return null; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js index b8fc428a62a52..7bd60ea6502bc 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js @@ -17,10 +17,6 @@ export function isOnlySingleFeatureType(featureType, supportedFeatures, hasFeatu return supportedFeatures[0] === featureType; } - if (!hasFeatureType) { - return false; - } - const featureTypes = Object.keys(hasFeatureType); return featureTypes.reduce((isOnlyTargetFeatureType, featureTypeKey) => { const hasFeature = hasFeatureType[featureTypeKey]; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js index ea80b188e1646..d1efcbb72d1a7 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js @@ -143,8 +143,8 @@ export class VectorStyle extends AbstractStyle { styleProperties={styleProperties} symbolDescriptor={this._descriptor.properties[VECTOR_STYLES.SYMBOL]} layer={layer} - loadIsPointsOnly={this._getIsPointsOnly} - loadIsLinesOnly={this._getIsLinesOnly} + isPointsOnly={this._getIsPointsOnly()} + isLinesOnly={this._getIsLinesOnly()} onIsTimeAwareChange={onIsTimeAwareChange} isTimeAware={this.isTimeAware()} showIsTimeAware={propertiesWithFieldMeta.length > 0} @@ -218,43 +218,57 @@ export class VectorStyle extends AbstractStyle { async pluckStyleMetaFromSourceDataRequest(sourceDataRequest) { const features = _.get(sourceDataRequest.getData(), 'features', []); - if (features.length === 0) { - return {}; - } - - const dynamicProperties = this.getDynamicPropertiesArray(); const supportedFeatures = await this._source.getSupportedShapeTypes(); - const isSingleFeatureType = supportedFeatures.length === 1; - if (dynamicProperties.length === 0 && isSingleFeatureType) { - // no meta data to pull from source data request. - return {}; - } - - let hasPoints = false; - let hasLines = false; - let hasPolygons = false; - for (let i = 0; i < features.length; i++) { - const feature = features[i]; - if (!hasPoints && POINTS.includes(feature.geometry.type)) { - hasPoints = true; - } - if (!hasLines && LINES.includes(feature.geometry.type)) { - hasLines = true; - } - if (!hasPolygons && POLYGONS.includes(feature.geometry.type)) { - hasPolygons = true; + const hasFeatureType = { + [VECTOR_SHAPE_TYPES.POINT]: false, + [VECTOR_SHAPE_TYPES.LINE]: false, + [VECTOR_SHAPE_TYPES.POLYGON]: false, + }; + if (supportedFeatures.length > 1) { + for (let i = 0; i < features.length; i++) { + const feature = features[i]; + if (!hasFeatureType[VECTOR_SHAPE_TYPES.POINT] && POINTS.includes(feature.geometry.type)) { + hasFeatureType[VECTOR_SHAPE_TYPES.POINT] = true; + } + if (!hasFeatureType[VECTOR_SHAPE_TYPES.LINE] && LINES.includes(feature.geometry.type)) { + hasFeatureType[VECTOR_SHAPE_TYPES.LINE] = true; + } + if ( + !hasFeatureType[VECTOR_SHAPE_TYPES.POLYGON] && + POLYGONS.includes(feature.geometry.type) + ) { + hasFeatureType[VECTOR_SHAPE_TYPES.POLYGON] = true; + } } } const featuresMeta = { - hasFeatureType: { - [VECTOR_SHAPE_TYPES.POINT]: hasPoints, - [VECTOR_SHAPE_TYPES.LINE]: hasLines, - [VECTOR_SHAPE_TYPES.POLYGON]: hasPolygons, + geometryTypes: { + isPointsOnly: isOnlySingleFeatureType( + VECTOR_SHAPE_TYPES.POINT, + supportedFeatures, + hasFeatureType + ), + isLinesOnly: isOnlySingleFeatureType( + VECTOR_SHAPE_TYPES.LINE, + supportedFeatures, + hasFeatureType + ), + isPolygonsOnly: isOnlySingleFeatureType( + VECTOR_SHAPE_TYPES.POLYGON, + supportedFeatures, + hasFeatureType + ), }, }; + const dynamicProperties = this.getDynamicPropertiesArray(); + if (dynamicProperties.length === 0 || features.length === 0) { + // no additional meta data to pull from source data request. + return featuresMeta; + } + dynamicProperties.forEach(dynamicProperty => { const styleMeta = dynamicProperty.pluckStyleMetaFromFeatures(features); if (styleMeta) { @@ -291,24 +305,16 @@ export class VectorStyle extends AbstractStyle { ); } - _isOnlySingleFeatureType = async featureType => { - return isOnlySingleFeatureType( - featureType, - await this._source.getSupportedShapeTypes(), - this._getStyleMeta().hasFeatureType - ); - }; - - _getIsPointsOnly = async () => { - return this._isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.POINT); + _getIsPointsOnly = () => { + return _.get(this._getStyleMeta(), 'geometryTypes.isPointsOnly', false); }; - _getIsLinesOnly = async () => { - return this._isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.LINE); + _getIsLinesOnly = () => { + return _.get(this._getStyleMeta(), 'geometryTypes.isLinesOnly', false); }; - _getIsPolygonsOnly = async () => { - return this._isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.POLYGON); + _getIsPolygonsOnly = () => { + return _.get(this._getStyleMeta(), 'geometryTypes.isPolygonsOnly', false); }; _getDynamicPropertyByFieldName(fieldName) { @@ -393,50 +399,44 @@ export class VectorStyle extends AbstractStyle { : this._descriptor.properties.symbol.options.symbolId; } - _getColorForProperty = (styleProperty, isLinesOnly) => { - const styles = this.getRawProperties(); - if (isLinesOnly) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.LINE_COLOR], 'grey'); - } - - if (styleProperty === VECTOR_STYLES.LINE_COLOR) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.LINE_COLOR], 'none'); - } else if (styleProperty === VECTOR_STYLES.FILL_COLOR) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.FILL_COLOR], 'grey'); - } else { - //unexpected - console.error('Cannot return color for properties other then line or fill color'); - } - }; - getIcon = () => { - const symbolId = this._getSymbolId(); + const isLinesOnly = this._getIsLinesOnly(); + const strokeColor = isLinesOnly + ? extractColorFromStyleProperty(this._descriptor.properties[VECTOR_STYLES.LINE_COLOR], 'grey') + : extractColorFromStyleProperty( + this._descriptor.properties[VECTOR_STYLES.LINE_COLOR], + 'none' + ); + const fillColor = isLinesOnly + ? null + : extractColorFromStyleProperty( + this._descriptor.properties[VECTOR_STYLES.FILL_COLOR], + 'grey' + ); return ( ); }; - _getLegendDetailStyleProperties = async () => { - const isLinesOnly = await this._getIsLinesOnly(); - const isPolygonsOnly = await this._getIsPolygonsOnly(); - + _getLegendDetailStyleProperties = () => { return this.getDynamicPropertiesArray().filter(styleProperty => { const styleName = styleProperty.getStyleName(); if ([VECTOR_STYLES.ICON_ORIENTATION, VECTOR_STYLES.LABEL_TEXT].includes(styleName)) { return false; } - if (isLinesOnly) { + if (this._getIsLinesOnly()) { return LINE_STYLES.includes(styleName); } - if (isPolygonsOnly) { + if (this._getIsPolygonsOnly()) { return POLYGON_STYLES.includes(styleName); } @@ -445,16 +445,15 @@ export class VectorStyle extends AbstractStyle { }; async hasLegendDetails() { - const styles = await this._getLegendDetailStyleProperties(); - return styles.length > 0; + return this._getLegendDetailStyleProperties().length > 0; } renderLegendDetails() { return ( ); 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 aa0badd5583d5..3d2911720c312 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 @@ -159,11 +159,9 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { const vectorStyle = new VectorStyle({}, new MockSource()); const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); - expect(featuresMeta.hasFeatureType).toEqual({ - LINE: false, - POINT: true, - POLYGON: false, - }); + expect(featuresMeta.geometryTypes.isPointsOnly).toBe(true); + expect(featuresMeta.geometryTypes.isLinesOnly).toBe(false); + expect(featuresMeta.geometryTypes.isPolygonsOnly).toBe(false); }); it('Should identify when feature collection only contains lines', async () => { @@ -189,11 +187,9 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { const vectorStyle = new VectorStyle({}, new MockSource()); const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); - expect(featuresMeta.hasFeatureType).toEqual({ - LINE: true, - POINT: false, - POLYGON: false, - }); + expect(featuresMeta.geometryTypes.isPointsOnly).toBe(false); + expect(featuresMeta.geometryTypes.isLinesOnly).toBe(true); + expect(featuresMeta.geometryTypes.isPolygonsOnly).toBe(false); }); }); @@ -241,11 +237,9 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { ); const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); - expect(featuresMeta.hasFeatureType).toEqual({ - LINE: false, - POINT: true, - POLYGON: false, - }); + expect(featuresMeta.geometryTypes.isPointsOnly).toBe(true); + expect(featuresMeta.geometryTypes.isLinesOnly).toBe(false); + expect(featuresMeta.geometryTypes.isPolygonsOnly).toBe(false); }); it('Should extract scaled field range', async () => { @@ -275,88 +269,3 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { }); }); }); - -describe('checkIfOnlyFeatureType', () => { - describe('source supports single feature type', () => { - it('isPointsOnly should be true when source feature type only supports points', async () => { - const vectorStyle = new VectorStyle( - {}, - new MockSource({ - supportedShapeTypes: [VECTOR_SHAPE_TYPES.POINT], - }) - ); - const isPointsOnly = await vectorStyle._getIsPointsOnly(); - expect(isPointsOnly).toBe(true); - }); - - it('isLineOnly should be false when source feature type only supports points', async () => { - const vectorStyle = new VectorStyle( - {}, - new MockSource({ - supportedShapeTypes: [VECTOR_SHAPE_TYPES.POINT], - }) - ); - const isLineOnly = await vectorStyle._getIsLinesOnly(); - expect(isLineOnly).toBe(false); - }); - }); - - describe('source supports multiple feature types', () => { - it('isPointsOnly should be true when data contains just points', async () => { - const vectorStyle = new VectorStyle( - { - __styleMeta: { - hasFeatureType: { - POINT: true, - LINE: false, - POLYGON: false, - }, - }, - }, - new MockSource({ - supportedShapeTypes: Object.values(VECTOR_SHAPE_TYPES), - }) - ); - const isPointsOnly = await vectorStyle._getIsPointsOnly(); - expect(isPointsOnly).toBe(true); - }); - - it('isPointsOnly should be false when data contains just lines', async () => { - const vectorStyle = new VectorStyle( - { - __styleMeta: { - hasFeatureType: { - POINT: false, - LINE: true, - POLYGON: false, - }, - }, - }, - new MockSource({ - supportedShapeTypes: Object.values(VECTOR_SHAPE_TYPES), - }) - ); - const isPointsOnly = await vectorStyle._getIsPointsOnly(); - expect(isPointsOnly).toBe(false); - }); - - it('isPointsOnly should be false when data contains points, lines, and polygons', async () => { - const vectorStyle = new VectorStyle( - { - __styleMeta: { - hasFeatureType: { - POINT: true, - LINE: true, - POLYGON: true, - }, - }, - }, - new MockSource({ - supportedShapeTypes: Object.values(VECTOR_SHAPE_TYPES), - }) - ); - const isPointsOnly = await vectorStyle._getIsPointsOnly(); - expect(isPointsOnly).toBe(false); - }); - }); -}); diff --git a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts index a7afee237dba9..8cdaa192fcbc9 100644 --- a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts +++ b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.test.ts @@ -345,6 +345,98 @@ describe('ML - custom URL utils', () => { ); }); + test('returns expected URL for APM', () => { + const urlConfig = { + url_name: 'APM', + time_range: '2h', + url_value: + 'apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:"$trace.id$" and transaction.name:"$transaction.name$"&_g=()', + }; + + const testRecords = { + job_id: 'abnormal_trace_durations_nodejs', + result_type: 'record', + probability: 0.025597710862701226, + multi_bucket_impact: 5, + record_score: 13.124152090331723, + initial_record_score: 13.124152090331723, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1573339500000, + by_field_name: 'transaction.name', + by_field_value: 'GET /test-data', + function: 'high_mean', + function_description: 'mean', + typical: [802.0600710562369], + actual: [761.1531339031332], + field_name: 'transaction.duration.us', + influencers: [ + { + influencer_field_name: 'transaction.name', + influencer_field_values: ['GET /test-data'], + }, + { + influencer_field_name: 'trace.id', + influencer_field_values: [ + '000a09d58a428f38550e7e87637733c1', + '0039c771d8bbadf6137767d3aeb89f96', + '01279ed5bb9f4249e3822d16dec7f2f2', + ], + }, + { + influencer_field_name: 'service.name', + influencer_field_values: ['example-service'], + }, + ], + 'trace.id': [ + '000a09d58a428f38550e7e87637733c1', + '0039c771d8bbadf6137767d3aeb89f96', + '01279ed5bb9f4249e3822d16dec7f2f2', + ], + 'service.name': ['example-service'], + 'transaction.name': ['GET /test-data'], + earliest: '2019-11-09T20:45:00.000Z', + latest: '2019-11-10T01:00:00.000Z', + }; + + expect(getUrlForRecord(urlConfig, testRecords)).toBe( + 'apm#/traces?rangeFrom=2019-11-09T20:45:00.000Z&rangeTo=2019-11-10T01:00:00.000Z&kuery=(trace.id:"000a09d58a428f38550e7e87637733c1" OR trace.id:"0039c771d8bbadf6137767d3aeb89f96" OR trace.id:"01279ed5bb9f4249e3822d16dec7f2f2") AND transaction.name:"GET%20%2Ftest-data"&_g=()' + ); + }); + + test('removes an empty path component with a trailing slash', () => { + const urlConfig = { + url_name: 'APM', + time_range: '2h', + url_value: + 'apm#/services/$service.name$/transactions?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request', + }; + + const testRecords = { + job_id: 'decreased_throughput_jsbase', + result_type: 'record', + probability: 8.91350850732573e-9, + multi_bucket_impact: 5, + record_score: 93.63625728951217, + initial_record_score: 93.63625728951217, + bucket_span: 900, + detector_index: 0, + is_interim: false, + timestamp: 1573266600000, + function: 'low_count', + function_description: 'count', + typical: [100615.66506877479], + actual: [25251], + earliest: '2019-11-09T00:30:00.000Z', + latest: '2019-11-09T04:45:00.000Z', + }; + + expect(getUrlForRecord(urlConfig, testRecords)).toBe( + 'apm#/services/transactions?rangeFrom=2019-11-09T00:30:00.000Z&rangeTo=2019-11-09T04:45:00.000Z&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request' + ); + }); + test('returns expected URL for other type URL', () => { expect(getUrlForRecord(TEST_OTHER_URL, TEST_RECORD)).toBe( 'http://airlinecodes.info/airline-code-AAL' diff --git a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts index e2f2dc0ad0fe8..7774f6dec0c95 100644 --- a/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts +++ b/x-pack/legacy/plugins/ml/public/application/util/custom_url_utils.ts @@ -97,7 +97,11 @@ export function openCustomUrlWindow(fullUrl: string, urlConfig: UrlConfig) { // a Kibana Discover or Dashboard page running on the same server as this ML plugin. function isKibanaUrl(urlConfig: UrlConfig) { const urlValue = urlConfig.url_value; - return urlValue.startsWith('kibana#/discover') || urlValue.startsWith('kibana#/dashboard'); + return ( + urlValue.startsWith('kibana#/discover') || + urlValue.startsWith('kibana#/dashboard') || + urlValue.startsWith('apm#/') + ); } /** @@ -136,13 +140,14 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc) commonEscapeCallback ); - return str.replace(/\$([^?&$\'"]+)\$/g, (match, name: string) => { + // Looking for a $token$ with an optional trailing slash + return str.replace(/\$([^?&$\'"]+)\$(\/)?/g, (match, name: string, slash: string = '') => { // Use lodash get to allow nested JSON fields to be retrieved. let tokenValue: string | string[] | undefined = get(record, name); tokenValue = Array.isArray(tokenValue) ? tokenValue[0] : tokenValue; - // If property not found string is not replaced. - return tokenValue === undefined ? match : getResultTokenValue(tokenValue); + // If property not found token is replaced with an empty string. + return tokenValue === undefined ? '' : getResultTokenValue(tokenValue) + slash; }); }; @@ -155,7 +160,7 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc) commonEscapeCallback ); return str.replace( - /(.+query:')([^']*)('.+)/, + /(.+query:'|.+&kuery=)([^']*)(['&].+)/, (fullMatch, prefix: string, queryString: string, postfix: string) => { const [resultPrefix, resultPostfix] = [prefix, postfix].map(replaceSingleTokenValues); @@ -170,28 +175,39 @@ function buildKibanaUrl(urlConfig: UrlConfig, record: CustomUrlAnomalyRecordDoc) const queryParts: string[] = []; const joinOperator = ' AND '; - for (let i = 0; i < queryFields.length; i++) { + fieldsLoop: for (let i = 0; i < queryFields.length; i++) { const field = queryFields[i]; // Use lodash get to allow nested JSON fields to be retrieved. - const tokenValues: string[] | string | null = get(record, field) || null; + let tokenValues: string[] | string | null = get(record, field) || null; if (tokenValues === null) { continue; } + tokenValues = Array.isArray(tokenValues) ? tokenValues : [tokenValues]; + // Create a pair `influencerField:value`. // In cases where there are multiple influencer field values for an anomaly // combine values with OR operator e.g. `(influencerField:value or influencerField:another_value)`. - let result = (Array.isArray(tokenValues) ? tokenValues : [tokenValues]) - .map(value => `${field}:"${getResultTokenValue(value)}"`) - .join(' OR '); - result = tokenValues.length > 1 ? `(${result})` : result; - - // Build up a URL string which is not longer than the allowed length and isn't corrupted by invalid query. - availableCharactersLeft -= result.length - (i === 0 ? 0 : joinOperator.length); - - if (availableCharactersLeft <= 0) { - break; - } else { - queryParts.push(result); + let result = ''; + for (let j = 0; j < tokenValues.length; j++) { + const part = `${j > 0 ? ' OR ' : ''}${field}:"${getResultTokenValue( + tokenValues[j] + )}"`; + + // Build up a URL string which is not longer than the allowed length and isn't corrupted by invalid query. + if (availableCharactersLeft < part.length) { + if (result.length > 0) { + queryParts.push(j > 0 ? `(${result})` : result); + } + break fieldsLoop; + } + + result += part; + + availableCharactersLeft -= result.length; + } + + if (result.length > 0) { + queryParts.push(tokenValues.length > 1 ? `(${result})` : result); } } diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js b/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js index cb268ffede7fa..9c5048daeee3f 100644 --- a/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/__tests__/data_recognizer.js @@ -12,6 +12,8 @@ describe('ML - data recognizer', () => { const moduleIds = [ 'apache_ecs', + 'apm_jsbase', + 'apm_nodejs', 'apm_transaction', 'auditbeat_process_docker_ecs', 'auditbeat_process_hosts_ecs', diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/logo.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/logo.json new file mode 100644 index 0000000000000..3905c809fbd7a --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "apmApp" +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/manifest.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/manifest.json new file mode 100644 index 0000000000000..e463b34be0fc2 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/manifest.json @@ -0,0 +1,53 @@ +{ + "id": "apm_jsbase", + "title": "APM: RUM Javascript", + "description": "Detect problematic spans and identify user agents that are potentially causing issues.", + "type": "APM data", + "logoFile": "logo.json", + "defaultIndexPattern": "apm-*", + "query": { + "bool": { + "filter": [{ "term": { "agent.name": "js-base" } }] + } + }, + "jobs": [ + { + "id": "abnormal_span_durations_jsbase", + "file": "abnormal_span_durations_jsbase.json" + }, + { + "id": "anomalous_error_rate_for_user_agents_jsbase", + "file": "anomalous_error_rate_for_user_agents_jsbase.json" + }, + { + "id": "decreased_throughput_jsbase", + "file": "decreased_throughput_jsbase.json" + }, + { + "id": "high_count_by_user_agent_jsbase", + "file": "high_count_by_user_agent_jsbase.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-abnormal_span_durations_jsbase", + "file": "datafeed_abnormal_span_durations_jsbase.json", + "job_id": "abnormal_span_durations_jsbase" + }, + { + "id": "datafeed-anomalous_error_rate_for_user_agents_jsbase", + "file": "datafeed_anomalous_error_rate_for_user_agents_jsbase.json", + "job_id": "anomalous_error_rate_for_user_agents_jsbase" + }, + { + "id": "datafeed-decreased_throughput_jsbase", + "file": "datafeed_decreased_throughput_jsbase.json", + "job_id": "decreased_throughput_jsbase" + }, + { + "id": "datafeed-high_count_by_user_agent_jsbase", + "file": "datafeed_high_count_by_user_agent_jsbase.json", + "job_id": "high_count_by_user_agent_jsbase" + } + ] +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/abnormal_span_durations_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/abnormal_span_durations_jsbase.json new file mode 100644 index 0000000000000..e0b51a4dcd05e --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/abnormal_span_durations_jsbase.json @@ -0,0 +1,41 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM JSBase: Looks for spans that are taking longer than usual to process.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "increased span duration", + "function": "high_mean", + "field_name": "span.duration.us", + "partition_field_name": "span.type" + } + ], + "influencers": [ + "span.type", + "trace.id", + "span.name", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "128mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-jsbase", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:\"$trace.id$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/anomalous_error_rate_for_user_agents_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/anomalous_error_rate_for_user_agents_jsbase.json new file mode 100644 index 0000000000000..66fd9858c6885 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/anomalous_error_rate_for_user_agents_jsbase.json @@ -0,0 +1,40 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM JSBase: Detects user agents that are encountering errors at an above normal rate. This can help detect browser compatibility issues.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "high error rate for user agent", + "function": "high_non_zero_count", + "partition_field_name": "user_agent.name" + } + ], + "influencers": [ + "user_agent.name", + "error.exception.message.keyword", + "error.page.url", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-jsbase", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/services/$service.name$/errors?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=user_agent.name:\"$user_agent.name$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_abnormal_span_durations_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_abnormal_span_durations_jsbase.json new file mode 100644 index 0000000000000..7ecbe2890b826 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_abnormal_span_durations_jsbase.json @@ -0,0 +1,15 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must": [ + { "bool": { "filter": { "term": { "agent.name": "js-base" } } } }, + { "bool": { "filter": { "term": { "processor.event": "span" } } } } + ] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_anomalous_error_rate_for_user_agents_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_anomalous_error_rate_for_user_agents_jsbase.json new file mode 100644 index 0000000000000..fbfedcbf47561 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_anomalous_error_rate_for_user_agents_jsbase.json @@ -0,0 +1,15 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must": [ + { "bool": { "filter": { "term": { "agent.name": "js-base" } } } }, + { "exists": { "field": "user_agent.name" } } + ] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_decreased_throughput_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_decreased_throughput_jsbase.json new file mode 100644 index 0000000000000..48cba1f157815 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_decreased_throughput_jsbase.json @@ -0,0 +1,27 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": { "term": { "agent.name": "js-base" } } + } + }, + "aggregations": { + "buckets": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "900000ms" + }, + "aggregations": { + "@timestamp": { + "max": { + "field": "@timestamp" + } + } + } + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_high_count_by_user_agent_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_high_count_by_user_agent_jsbase.json new file mode 100644 index 0000000000000..18ca6b1389287 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/datafeed_high_count_by_user_agent_jsbase.json @@ -0,0 +1,16 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must": [ + { "bool": { "filter": { "term": { "agent.name": "js-base" } } } }, + { "bool": { "filter": [{ "exists": { "field": "user_agent.name" } }] } }, + { "bool": { "filter": { "term": { "processor.event": "transaction" } } } } + ] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/decreased_throughput_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/decreased_throughput_jsbase.json new file mode 100644 index 0000000000000..4bc8757f19dc9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/decreased_throughput_jsbase.json @@ -0,0 +1,37 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM JSBase: Identifies periods during which the application is processing fewer requests than normal.", + "analysis_config": { + "summary_count_field_name": "doc_count", + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "low throughput", + "function": "low_count" + } + ], + "influencers": [ + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "10mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-jsbase", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/services?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/high_count_by_user_agent_jsbase.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/high_count_by_user_agent_jsbase.json new file mode 100644 index 0000000000000..7e1316359eabb --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_jsbase/ml/high_count_by_user_agent_jsbase.json @@ -0,0 +1,38 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM JSBase: Detects user agents that are making requests at a suspiciously high rate. This is useful in identifying bots.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "high request rate for user agent", + "function": "high_non_zero_count", + "partition_field_name": "user_agent.name" + } + ], + "influencers": [ + "user_agent.name", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "32mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-jsbase", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/services/$service.name$/transactions?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=user_agent.name:\"$user_agent.name$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/logo.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/logo.json new file mode 100644 index 0000000000000..3905c809fbd7a --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "apmApp" +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/manifest.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/manifest.json new file mode 100644 index 0000000000000..1865a33a1d301 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/manifest.json @@ -0,0 +1,42 @@ +{ + "id": "apm_nodejs", + "title": "APM: NodeJS", + "description": "Detect abnormal traces, anomalous spans, and identify periods of decreased throughput.", + "type": "APM data", + "logoFile": "logo.json", + "defaultIndexPattern": "apm-*", + "query": { + "bool": { "filter": [{ "term": { "agent.name": "nodejs" } }] } + }, + "jobs": [ + { + "id": "abnormal_span_durations_nodejs", + "file": "abnormal_span_durations_nodejs.json" + }, + { + "id": "abnormal_trace_durations_nodejs", + "file": "abnormal_trace_durations_nodejs.json" + }, + { + "id": "decreased_throughput_nodejs", + "file": "decreased_throughput_nodejs.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-abnormal_span_durations_nodejs", + "file": "datafeed_abnormal_span_durations_nodejs.json", + "job_id": "abnormal_span_durations_nodejs" + }, + { + "id": "datafeed-abnormal_trace_durations_nodejs", + "file": "datafeed_abnormal_trace_durations_nodejs.json", + "job_id": "abnormal_trace_durations_nodejs" + }, + { + "id": "datafeed-decreased_throughput_nodejs", + "file": "datafeed_decreased_throughput_nodejs.json", + "job_id": "decreased_throughput_nodejs" + } + ] +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_span_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_span_durations_nodejs.json new file mode 100644 index 0000000000000..1a8318437790e --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_span_durations_nodejs.json @@ -0,0 +1,41 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM NodeJS: Looks for spans that are taking longer than usual to process.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "increased span duration", + "function": "high_mean", + "field_name": "span.duration.us", + "partition_field_name": "span.type" + } + ], + "influencers": [ + "span.type", + "trace.id", + "span.name", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "128mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-nodejs", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:\"$trace.id$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_trace_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_trace_durations_nodejs.json new file mode 100644 index 0000000000000..875b49e895a00 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/abnormal_trace_durations_nodejs.json @@ -0,0 +1,40 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM NodeJS: Identifies trace transactions that are processing more slowly than usual.", + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "increased trace duration", + "function": "high_mean", + "field_name": "transaction.duration.us", + "by_field_name": "transaction.name" + } + ], + "influencers": [ + "transaction.name", + "trace.id", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "256mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-nodejs", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/traces?rangeFrom=$earliest$&rangeTo=$latest$&kuery=trace.id:\"$trace.id$\" and transaction.name:\"$transaction.name$\"&_g=()" + } + ] + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_span_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_span_durations_nodejs.json new file mode 100644 index 0000000000000..3e4f4877bd042 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_span_durations_nodejs.json @@ -0,0 +1,15 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must": [ + { "bool": { "filter": { "term": { "agent.name": "nodejs" } } } }, + { "bool": { "filter": { "term": { "processor.event": "span" } } } } + ] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_trace_durations_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_trace_durations_nodejs.json new file mode 100644 index 0000000000000..d87f809a49940 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_abnormal_trace_durations_nodejs.json @@ -0,0 +1,13 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "must_not": [{ "exists": { "field": "parent.id" } }], + "must": [{ "bool": { "filter": { "term": { "agent.name": "nodejs" } } } }] + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_decreased_throughput_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_decreased_throughput_nodejs.json new file mode 100644 index 0000000000000..451957c327dd0 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/datafeed_decreased_throughput_nodejs.json @@ -0,0 +1,27 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": { "term": { "agent.name": "nodejs" } } + } + }, + "aggregations": { + "buckets": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "900000ms" + }, + "aggregations": { + "@timestamp": { + "max": { + "field": "@timestamp" + } + } + } + } + } +} diff --git a/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/decreased_throughput_nodejs.json b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/decreased_throughput_nodejs.json new file mode 100644 index 0000000000000..f63c6289a5cd9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/server/models/data_recognizer/modules/apm_nodejs/ml/decreased_throughput_nodejs.json @@ -0,0 +1,38 @@ +{ + "job_type": "anomaly_detector", + "groups": [ + "apm" + ], + "description": "APM NodeJS: Identifies periods during which the application is processing fewer requests than normal.", + "analysis_config": { + "summary_count_field_name": "doc_count", + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "low throughput", + "function": "low_count" + } + ], + "influencers": [ + "transaction.name", + "service.name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "10mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-apm-nodejs", + "custom_urls": [ + { + "url_name": "APM", + "time_range": "2h", + "url_value": "apm#/services?rangeFrom=$earliest$&rangeTo=$latest$&refreshPaused=true&refreshInterval=0&kuery=&transactionType=request" + } + ] + } +} diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/__snapshots__/helpers.test.js.snap b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/__snapshots__/helpers.test.js.snap new file mode 100644 index 0000000000000..ea9d312413168 --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/__snapshots__/helpers.test.js.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Bytes Usage should format correctly with only usedBytes 1`] = ` + +
+ 50.0 B +
+
+`; + +exports[`Bytes Usage should format correctly with used and max bytes 1`] = ` + +
+ 50.0 B / 100.0 B +
+
+`; + +exports[`BytesPercentageUsage should format correctly with used bytes and max bytes 1`] = ` + +
+ 50.00% +
+
+
+ 50.0 B / 100.0 B +
+
+
+`; + +exports[`BytesPercentageUsage should return zero bytes if both parameters are not present 1`] = ` +
+ 0 +
+`; diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/helpers.test.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/helpers.test.js new file mode 100644 index 0000000000000..fea8f0001540a --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/helpers.test.js @@ -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. + */ + +import React from 'react'; +import { renderWithIntl } from '../../../../../../../../test_utils/enzyme_helpers'; +import { BytesUsage, BytesPercentageUsage } from '../helpers'; + +describe('Bytes Usage', () => { + it('should format correctly with used and max bytes', () => { + const props = { + usedBytes: 50, + maxBytes: 100, + }; + expect(renderWithIntl()).toMatchSnapshot(); + }); + + it('should format correctly with only usedBytes', () => { + const props = { + usedBytes: 50, + }; + expect(renderWithIntl()).toMatchSnapshot(); + }); +}); + +describe('BytesPercentageUsage', () => { + it('should format correctly with used bytes and max bytes', () => { + const props = { + usedBytes: 50, + maxBytes: 100, + }; + expect(renderWithIntl()).toMatchSnapshot(); + }); + it('should return zero bytes if both parameters are not present', () => { + const props = { + usedBytes: 50, + }; + expect(renderWithIntl()).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/apm_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/apm_panel.js index 3ba04359c2672..84dc13e9da1de 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/apm_panel.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/apm_panel.js @@ -8,11 +8,7 @@ import React from 'react'; import moment from 'moment'; import { get } from 'lodash'; import { formatMetric } from 'plugins/monitoring/lib/format_number'; -import { - ClusterItemContainer, - BytesPercentageUsage, - DisabledIfNoDataAndInSetupModeLink, -} from './helpers'; +import { ClusterItemContainer, BytesUsage, DisabledIfNoDataAndInSetupModeLink } from './helpers'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { @@ -153,7 +149,7 @@ export function ApmPanel(props) { /> - + diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js index fc23110f940e8..7b08c89f53881 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js @@ -10,7 +10,6 @@ import { formatNumber } from 'plugins/monitoring/lib/format_number'; import { ClusterItemContainer, HealthStatusIndicator, - BytesUsage, BytesPercentageUsage, DisabledIfNoDataAndInSetupModeLink, } from './helpers'; @@ -291,7 +290,7 @@ export function ElasticsearchPanel(props) { /> - diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/helpers.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/helpers.js index ae7cc1b4e965c..0d9290225cd5f 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/helpers.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/helpers.js @@ -6,7 +6,7 @@ import React from 'react'; import { get } from 'lodash'; -import { formatBytesUsage, formatPercentageUsage } from 'plugins/monitoring/lib/format_number'; +import { formatBytesUsage, formatPercentageUsage, formatNumber } from '../../../lib/format_number'; import { EuiSpacer, EuiFlexItem, @@ -88,10 +88,13 @@ export function BytesUsage({ usedBytes, maxBytes }) { if (usedBytes && maxBytes) { return ( - {formatPercentageUsage(usedBytes, maxBytes)} - - {formatBytesUsage(usedBytes, maxBytes)} - + {formatBytesUsage(usedBytes, maxBytes)} + + ); + } else if (usedBytes) { + return ( + + {formatNumber(usedBytes, 'byte')} ); } diff --git a/x-pack/legacy/plugins/security/common/model.ts b/x-pack/legacy/plugins/security/common/model.ts index 90e6a5403dfe8..733e89f774db8 100644 --- a/x-pack/legacy/plugins/security/common/model.ts +++ b/x-pack/legacy/plugins/security/common/model.ts @@ -11,12 +11,17 @@ export { BuiltinESPrivileges, EditUser, FeaturesPrivileges, + InlineRoleTemplate, + InvalidRoleTemplate, KibanaPrivileges, RawKibanaFeaturePrivileges, RawKibanaPrivileges, Role, RoleIndexPrivilege, RoleKibanaPrivilege, + RoleMapping, + RoleTemplate, + StoredRoleTemplate, User, canUserChangePassword, getUserDisplayName, diff --git a/x-pack/legacy/plugins/security/public/lib/role_mappings_api.ts b/x-pack/legacy/plugins/security/public/lib/role_mappings_api.ts new file mode 100644 index 0000000000000..b8bcba91388b5 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/lib/role_mappings_api.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'src/core/public'; +import { RoleMapping } from '../../common/model'; + +interface CheckRoleMappingFeaturesResponse { + canManageRoleMappings: boolean; + canUseInlineScripts: boolean; + canUseStoredScripts: boolean; + hasCompatibleRealms: boolean; +} + +type DeleteRoleMappingsResponse = Array<{ + name: string; + success: boolean; + error?: Error; +}>; + +export class RoleMappingsAPI { + constructor(private readonly http: CoreSetup['http']) {} + + public async checkRoleMappingFeatures(): Promise { + return this.http.get(`/internal/security/_check_role_mapping_features`); + } + + public async getRoleMappings(): Promise { + return this.http.get(`/internal/security/role_mapping`); + } + + public async getRoleMapping(name: string): Promise { + return this.http.get(`/internal/security/role_mapping/${encodeURIComponent(name)}`); + } + + public async saveRoleMapping(roleMapping: RoleMapping) { + const payload = { ...roleMapping }; + delete payload.name; + + return this.http.post( + `/internal/security/role_mapping/${encodeURIComponent(roleMapping.name)}`, + { body: JSON.stringify(payload) } + ); + } + + public async deleteRoleMappings(names: string[]): Promise { + return Promise.all( + names.map(name => + this.http + .delete(`/internal/security/role_mapping/${encodeURIComponent(name)}`) + .then(() => ({ success: true, name })) + .catch(error => ({ success: false, name, error })) + ) + ); + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/_index.scss b/x-pack/legacy/plugins/security/public/views/management/_index.scss index 104fed5980543..78b53845071e4 100644 --- a/x-pack/legacy/plugins/security/public/views/management/_index.scss +++ b/x-pack/legacy/plugins/security/public/views/management/_index.scss @@ -1,3 +1,4 @@ @import './change_password_form/index'; @import './edit_role/index'; -@import './edit_user/index'; \ No newline at end of file +@import './edit_user/index'; +@import './role_mappings/edit_role_mapping/index'; \ No newline at end of file diff --git a/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts b/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts index 7d345ac13dc41..4ab7e45e84849 100644 --- a/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts +++ b/x-pack/legacy/plugins/security/public/views/management/breadcrumbs.ts @@ -86,3 +86,30 @@ export function getApiKeysBreadcrumbs() { }, ]; } + +export function getRoleMappingBreadcrumbs() { + return [ + MANAGEMENT_BREADCRUMB, + { + text: i18n.translate('xpack.security.roleMapping.breadcrumb', { + defaultMessage: 'Role Mappings', + }), + href: '#/management/security/role_mappings', + }, + ]; +} + +export function getEditRoleMappingBreadcrumbs($route: Record) { + const { name } = $route.current.params; + return [ + ...getRoleMappingBreadcrumbs(), + { + text: + name || + i18n.translate('xpack.security.roleMappings.createBreadcrumb', { + defaultMessage: 'Create', + }), + href: `#/management/security/role_mappings/edit/${name}`, + }, + ]; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/management.js b/x-pack/legacy/plugins/security/public/views/management/management.js index 59da63abbb83f..f0369f232aeba 100644 --- a/x-pack/legacy/plugins/security/public/views/management/management.js +++ b/x-pack/legacy/plugins/security/public/views/management/management.js @@ -11,9 +11,11 @@ import 'plugins/security/views/management/roles_grid/roles'; import 'plugins/security/views/management/api_keys_grid/api_keys'; import 'plugins/security/views/management/edit_user/edit_user'; import 'plugins/security/views/management/edit_role/index'; +import 'plugins/security/views/management/role_mappings/role_mappings_grid'; +import 'plugins/security/views/management/role_mappings/edit_role_mapping'; import routes from 'ui/routes'; import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; -import { ROLES_PATH, USERS_PATH, API_KEYS_PATH } from './management_urls'; +import { ROLES_PATH, USERS_PATH, API_KEYS_PATH, ROLE_MAPPINGS_PATH } from './management_urls'; import { management } from 'ui/management'; import { npSetup } from 'ui/new_platform'; @@ -38,11 +40,23 @@ routes resolve: { securityManagementSection: function() { const showSecurityLinks = xpackInfo.get('features.security.showLinks'); + const showRoleMappingsManagementLink = xpackInfo.get( + 'features.security.showRoleMappingsManagement' + ); function deregisterSecurity() { management.deregister('security'); } + function deregisterRoleMappingsManagement() { + if (management.hasItem('security')) { + const security = management.getSection('security'); + if (security.hasItem('roleMappings')) { + security.deregister('roleMappings'); + } + } + } + function ensureSecurityRegistered() { const registerSecurity = () => management.register('security', { @@ -88,11 +102,26 @@ routes url: `#${API_KEYS_PATH}`, }); } + + if (showRoleMappingsManagementLink && !security.hasItem('roleMappings')) { + security.register('roleMappings', { + name: 'securityRoleMappingLink', + order: 30, + display: i18n.translate('xpack.security.management.roleMappingsTitle', { + defaultMessage: 'Role Mappings', + }), + url: `#${ROLE_MAPPINGS_PATH}`, + }); + } } if (!showSecurityLinks) { deregisterSecurity(); } else { + if (!showRoleMappingsManagementLink) { + deregisterRoleMappingsManagement(); + } + // getCurrentUser will reject if there is no authenticated user, so we prevent them from // seeing the security management screens. return npSetup.plugins.security.authc diff --git a/x-pack/legacy/plugins/security/public/views/management/management_urls.ts b/x-pack/legacy/plugins/security/public/views/management/management_urls.ts index ea0cba9f7f3a7..881740c0b2895 100644 --- a/x-pack/legacy/plugins/security/public/views/management/management_urls.ts +++ b/x-pack/legacy/plugins/security/public/views/management/management_urls.ts @@ -12,3 +12,13 @@ export const CLONE_ROLES_PATH = `${ROLES_PATH}/clone`; export const USERS_PATH = `${SECURITY_PATH}/users`; export const EDIT_USERS_PATH = `${USERS_PATH}/edit`; export const API_KEYS_PATH = `${SECURITY_PATH}/api_keys`; +export const ROLE_MAPPINGS_PATH = `${SECURITY_PATH}/role_mappings`; +export const CREATE_ROLE_MAPPING_PATH = `${ROLE_MAPPINGS_PATH}/edit`; + +export const getEditRoleHref = (roleName: string) => + `#${EDIT_ROLES_PATH}/${encodeURIComponent(roleName)}`; + +export const getCreateRoleMappingHref = () => `#${CREATE_ROLE_MAPPING_PATH}`; + +export const getEditRoleMappingHref = (roleMappingName: string) => + `#${CREATE_ROLE_MAPPING_PATH}/${encodeURIComponent(roleMappingName)}`; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.test.tsx new file mode 100644 index 0000000000000..b826d68053e27 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.test.tsx @@ -0,0 +1,301 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { DeleteProvider } from '.'; +import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api'; +import { RoleMapping } from '../../../../../../common/model'; +import { EuiConfirmModal } from '@elastic/eui'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { act } from '@testing-library/react'; +import { toastNotifications } from 'ui/notify'; + +jest.mock('ui/notify', () => { + return { + toastNotifications: { + addError: jest.fn(), + addSuccess: jest.fn(), + addDanger: jest.fn(), + }, + }; +}); + +describe('DeleteProvider', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('allows a single role mapping to be deleted', async () => { + const props = { + roleMappingsAPI: ({ + deleteRoleMappings: jest.fn().mockReturnValue( + Promise.resolve([ + { + name: 'delete-me', + success: true, + }, + ]) + ), + } as unknown) as RoleMappingsAPI, + }; + + const roleMappingsToDelete = [ + { + name: 'delete-me', + }, + ] as RoleMapping[]; + + const onSuccess = jest.fn(); + + const wrapper = mountWithIntl( + + {onDelete => ( + + )} + + ); + + await act(async () => { + wrapper.find('#invoker').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { title, confirmButtonText } = wrapper.find(EuiConfirmModal).props(); + expect(title).toMatchInlineSnapshot(`"Delete role mapping 'delete-me'?"`); + expect(confirmButtonText).toMatchInlineSnapshot(`"Delete role mapping"`); + + await act(async () => { + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith(['delete-me']); + + const notifications = toastNotifications as jest.Mocked; + expect(notifications.addError).toHaveBeenCalledTimes(0); + expect(notifications.addDanger).toHaveBeenCalledTimes(0); + expect(notifications.addSuccess).toHaveBeenCalledTimes(1); + expect(notifications.addSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "deletedRoleMappingSuccessToast", + "title": "Deleted role mapping 'delete-me'", + }, + ] + `); + }); + + it('allows multiple role mappings to be deleted', async () => { + const props = { + roleMappingsAPI: ({ + deleteRoleMappings: jest.fn().mockReturnValue( + Promise.resolve([ + { + name: 'delete-me', + success: true, + }, + { + name: 'delete-me-too', + success: true, + }, + ]) + ), + } as unknown) as RoleMappingsAPI, + }; + + const roleMappingsToDelete = [ + { + name: 'delete-me', + }, + { + name: 'delete-me-too', + }, + ] as RoleMapping[]; + + const onSuccess = jest.fn(); + + const wrapper = mountWithIntl( + + {onDelete => ( + + )} + + ); + + await act(async () => { + wrapper.find('#invoker').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + const { title, confirmButtonText } = wrapper.find(EuiConfirmModal).props(); + expect(title).toMatchInlineSnapshot(`"Delete 2 role mappings?"`); + expect(confirmButtonText).toMatchInlineSnapshot(`"Delete role mappings"`); + + await act(async () => { + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith([ + 'delete-me', + 'delete-me-too', + ]); + const notifications = toastNotifications as jest.Mocked; + expect(notifications.addError).toHaveBeenCalledTimes(0); + expect(notifications.addDanger).toHaveBeenCalledTimes(0); + expect(notifications.addSuccess).toHaveBeenCalledTimes(1); + expect(notifications.addSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "deletedRoleMappingSuccessToast", + "title": "Deleted 2 role mappings", + }, + ] + `); + }); + + it('handles mixed success/failure conditions', async () => { + const props = { + roleMappingsAPI: ({ + deleteRoleMappings: jest.fn().mockReturnValue( + Promise.resolve([ + { + name: 'delete-me', + success: true, + }, + { + name: 'i-wont-work', + success: false, + error: new Error('something went wrong. sad.'), + }, + ]) + ), + } as unknown) as RoleMappingsAPI, + }; + + const roleMappingsToDelete = [ + { + name: 'delete-me', + }, + { + name: 'i-wont-work', + }, + ] as RoleMapping[]; + + const onSuccess = jest.fn(); + + const wrapper = mountWithIntl( + + {onDelete => ( + + )} + + ); + + await act(async () => { + wrapper.find('#invoker').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + await act(async () => { + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith([ + 'delete-me', + 'i-wont-work', + ]); + + const notifications = toastNotifications as jest.Mocked; + expect(notifications.addError).toHaveBeenCalledTimes(0); + expect(notifications.addSuccess).toHaveBeenCalledTimes(1); + expect(notifications.addSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "deletedRoleMappingSuccessToast", + "title": "Deleted role mapping 'delete-me'", + }, + ] + `); + + expect(notifications.addDanger).toHaveBeenCalledTimes(1); + expect(notifications.addDanger.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Error deleting role mapping 'i-wont-work'", + ] + `); + }); + + it('handles errors calling the API', async () => { + const props = { + roleMappingsAPI: ({ + deleteRoleMappings: jest.fn().mockImplementation(() => { + throw new Error('AHHHHH'); + }), + } as unknown) as RoleMappingsAPI, + }; + + const roleMappingsToDelete = [ + { + name: 'delete-me', + }, + ] as RoleMapping[]; + + const onSuccess = jest.fn(); + + const wrapper = mountWithIntl( + + {onDelete => ( + + )} + + ); + + await act(async () => { + wrapper.find('#invoker').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + await act(async () => { + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(props.roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith(['delete-me']); + + const notifications = toastNotifications as jest.Mocked; + expect(notifications.addDanger).toHaveBeenCalledTimes(0); + expect(notifications.addSuccess).toHaveBeenCalledTimes(0); + + expect(notifications.addError).toHaveBeenCalledTimes(1); + expect(notifications.addError.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + [Error: AHHHHH], + Object { + "title": "Error deleting role mappings", + }, + ] + `); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.tsx new file mode 100644 index 0000000000000..2072cedeab462 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/delete_provider.tsx @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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, useRef, useState, ReactElement } from 'react'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { toastNotifications } from 'ui/notify'; +import { i18n } from '@kbn/i18n'; +import { RoleMapping } from '../../../../../../common/model'; +import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api'; + +interface Props { + roleMappingsAPI: RoleMappingsAPI; + children: (deleteMappings: DeleteRoleMappings) => ReactElement; +} + +export type DeleteRoleMappings = ( + roleMappings: RoleMapping[], + onSuccess?: OnSuccessCallback +) => void; + +type OnSuccessCallback = (deletedRoleMappings: string[]) => void; + +export const DeleteProvider: React.FunctionComponent = ({ roleMappingsAPI, children }) => { + const [roleMappings, setRoleMappings] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isDeleteInProgress, setIsDeleteInProgress] = useState(false); + + const onSuccessCallback = useRef(null); + + const deleteRoleMappingsPrompt: DeleteRoleMappings = ( + roleMappingsToDelete, + onSuccess = () => undefined + ) => { + if (!roleMappingsToDelete || !roleMappingsToDelete.length) { + throw new Error('No Role Mappings specified for delete'); + } + setIsModalOpen(true); + setRoleMappings(roleMappingsToDelete); + onSuccessCallback.current = onSuccess; + }; + + const closeModal = () => { + setIsModalOpen(false); + setRoleMappings([]); + }; + + const deleteRoleMappings = async () => { + let result; + + setIsDeleteInProgress(true); + + try { + result = await roleMappingsAPI.deleteRoleMappings(roleMappings.map(rm => rm.name)); + } catch (e) { + toastNotifications.addError(e, { + title: i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.unknownError', + { + defaultMessage: 'Error deleting role mappings', + } + ), + }); + setIsDeleteInProgress(false); + return; + } + + setIsDeleteInProgress(false); + + closeModal(); + + const successfulDeletes = result.filter(res => res.success); + const erroredDeletes = result.filter(res => !res.success); + + // Surface success notifications + if (successfulDeletes.length > 0) { + const hasMultipleSuccesses = successfulDeletes.length > 1; + const successMessage = hasMultipleSuccesses + ? i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.successMultipleNotificationTitle', + { + defaultMessage: 'Deleted {count} role mappings', + values: { count: successfulDeletes.length }, + } + ) + : i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.successSingleNotificationTitle', + { + defaultMessage: "Deleted role mapping '{name}'", + values: { name: successfulDeletes[0].name }, + } + ); + toastNotifications.addSuccess({ + title: successMessage, + 'data-test-subj': 'deletedRoleMappingSuccessToast', + }); + if (onSuccessCallback.current) { + onSuccessCallback.current(successfulDeletes.map(({ name }) => name)); + } + } + + // Surface error notifications + if (erroredDeletes.length > 0) { + const hasMultipleErrors = erroredDeletes.length > 1; + const errorMessage = hasMultipleErrors + ? i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.errorMultipleNotificationTitle', + { + defaultMessage: 'Error deleting {count} role mappings', + values: { + count: erroredDeletes.length, + }, + } + ) + : i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.errorSingleNotificationTitle', + { + defaultMessage: "Error deleting role mapping '{name}'", + values: { name: erroredDeletes[0].name }, + } + ); + toastNotifications.addDanger(errorMessage); + } + }; + + const renderModal = () => { + if (!isModalOpen) { + return null; + } + + const isSingle = roleMappings.length === 1; + + return ( + + + {!isSingle ? ( + +

+ {i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.confirmModal.deleteMultipleListDescription', + { defaultMessage: 'You are about to delete these role mappings:' } + )} +

+
    + {roleMappings.map(({ name }) => ( +
  • {name}
  • + ))} +
+
+ ) : null} +
+
+ ); + }; + + return ( + + {children(deleteRoleMappingsPrompt)} + {renderModal()} + + ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/index.ts new file mode 100644 index 0000000000000..7e8b5a99c3bf5 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/delete_provider/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 { DeleteProvider } from './delete_provider'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/index.ts new file mode 100644 index 0000000000000..315c1f7ec2baf --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './delete_provider'; +export * from './no_compatible_realms'; +export * from './permission_denied'; +export * from './section_loading'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/index.ts new file mode 100644 index 0000000000000..fb2e5b40c1941 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/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 { NoCompatibleRealms } from './no_compatible_realms'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/no_compatible_realms.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/no_compatible_realms.tsx new file mode 100644 index 0000000000000..969832b3ecbae --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/no_compatible_realms/no_compatible_realms.tsx @@ -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 React from 'react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { documentationLinks } from '../../services/documentation_links'; + +export const NoCompatibleRealms: React.FunctionComponent = () => ( + + } + color="warning" + iconType="alert" + > + + + + ), + }} + /> + +); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/index.ts new file mode 100644 index 0000000000000..8b0bc67f3f777 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/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 { PermissionDenied } from './permission_denied'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/permission_denied.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/permission_denied.tsx new file mode 100644 index 0000000000000..1a32645eaedb9 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/permission_denied/permission_denied.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 { EuiEmptyPrompt, EuiFlexGroup, EuiPageContent } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; + +export const PermissionDenied = () => ( + + + + + + } + body={ +

+ +

+ } + /> +
+
+); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/index.ts new file mode 100644 index 0000000000000..f59aa7a22d7c2 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/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 { SectionLoading } from './section_loading'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.test.tsx new file mode 100644 index 0000000000000..300f6ca0e1f72 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.test.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { SectionLoading } from '.'; + +describe('SectionLoading', () => { + it('renders the default loading message', () => { + const wrapper = shallowWithIntl(); + expect(wrapper.props().body).toMatchInlineSnapshot(` + + + + `); + }); + + it('renders the custom message when provided', () => { + const custom =
hold your horses
; + const wrapper = shallowWithIntl({custom}); + expect(wrapper.props().body).toMatchInlineSnapshot(` + +
+ hold your horses +
+
+ `); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.tsx new file mode 100644 index 0000000000000..8ae87127ed3b2 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/components/section_loading/section_loading.tsx @@ -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 React from 'react'; +import { EuiEmptyPrompt, EuiLoadingSpinner, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface Props { + children?: React.ReactChild; +} +export const SectionLoading = (props: Props) => { + return ( + } + body={ + + {props.children || ( + + )} + + } + data-test-subj="sectionLoading" + /> + ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/_index.scss b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/_index.scss new file mode 100644 index 0000000000000..80e08ebcf1226 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/_index.scss @@ -0,0 +1 @@ +@import './components/rule_editor_panel/index'; \ No newline at end of file diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.test.tsx new file mode 100644 index 0000000000000..375a8d9f374a8 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.test.tsx @@ -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 React from 'react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { findTestSubject } from 'test_utils/find_test_subject'; + +// brace/ace uses the Worker class, which is not currently provided by JSDOM. +// This is not required for the tests to pass, but it rather suppresses lengthy +// warnings in the console which adds unnecessary noise to the test output. +import 'test_utils/stub_web_worker'; + +import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api'; +import { EditRoleMappingPage } from '.'; +import { NoCompatibleRealms, SectionLoading, PermissionDenied } from '../../components'; +import { VisualRuleEditor } from './rule_editor_panel/visual_rule_editor'; +import { JSONRuleEditor } from './rule_editor_panel/json_rule_editor'; +import { EuiComboBox } from '@elastic/eui'; + +jest.mock('../../../../../lib/roles_api', () => { + return { + RolesApi: { + getRoles: () => Promise.resolve([{ name: 'foo_role' }, { name: 'bar role' }]), + }, + }; +}); + +describe('EditRoleMappingPage', () => { + it('allows a role mapping to be created', async () => { + const roleMappingsAPI = ({ + saveRoleMapping: jest.fn().mockResolvedValue(null), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + + await nextTick(); + wrapper.update(); + + findTestSubject(wrapper, 'roleMappingFormNameInput').simulate('change', { + target: { value: 'my-role-mapping' }, + }); + + (wrapper + .find(EuiComboBox) + .filter('[data-test-subj="roleMappingFormRoleComboBox"]') + .props() as any).onChange([{ label: 'foo_role' }]); + + findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click'); + + findTestSubject(wrapper, 'saveRoleMappingButton').simulate('click'); + + expect(roleMappingsAPI.saveRoleMapping).toHaveBeenCalledWith({ + name: 'my-role-mapping', + enabled: true, + roles: ['foo_role'], + role_templates: [], + rules: { + all: [{ field: { username: '*' } }], + }, + metadata: {}, + }); + }); + + it('allows a role mapping to be updated', async () => { + const roleMappingsAPI = ({ + saveRoleMapping: jest.fn().mockResolvedValue(null), + getRoleMapping: jest.fn().mockResolvedValue({ + name: 'foo', + role_templates: [ + { + template: { id: 'foo' }, + }, + ], + enabled: true, + rules: { + any: [{ field: { 'metadata.someCustomOption': [false, true, 'asdf'] } }], + }, + metadata: { + foo: 'bar', + bar: 'baz', + }, + }), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl( + + ); + + await nextTick(); + wrapper.update(); + + findTestSubject(wrapper, 'switchToRolesButton').simulate('click'); + + (wrapper + .find(EuiComboBox) + .filter('[data-test-subj="roleMappingFormRoleComboBox"]') + .props() as any).onChange([{ label: 'foo_role' }]); + + findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click'); + wrapper.find('button[id="addRuleOption"]').simulate('click'); + + findTestSubject(wrapper, 'saveRoleMappingButton').simulate('click'); + + expect(roleMappingsAPI.saveRoleMapping).toHaveBeenCalledWith({ + name: 'foo', + enabled: true, + roles: ['foo_role'], + role_templates: [], + rules: { + any: [ + { field: { 'metadata.someCustomOption': [false, true, 'asdf'] } }, + { field: { username: '*' } }, + ], + }, + metadata: { + foo: 'bar', + bar: 'baz', + }, + }); + }); + + it('renders a permission denied message when unauthorized to manage role mappings', async () => { + const roleMappingsAPI = ({ + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: false, + hasCompatibleRealms: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + expect(wrapper.find(SectionLoading)).toHaveLength(1); + expect(wrapper.find(PermissionDenied)).toHaveLength(0); + + await nextTick(); + wrapper.update(); + + expect(wrapper.find(SectionLoading)).toHaveLength(0); + expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0); + expect(wrapper.find(PermissionDenied)).toHaveLength(1); + }); + + it('renders a warning when there are no compatible realms enabled', async () => { + const roleMappingsAPI = ({ + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: false, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + expect(wrapper.find(SectionLoading)).toHaveLength(1); + expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0); + + await nextTick(); + wrapper.update(); + + expect(wrapper.find(SectionLoading)).toHaveLength(0); + expect(wrapper.find(NoCompatibleRealms)).toHaveLength(1); + }); + + it('renders a warning when editing a mapping with a stored role template, when stored scripts are disabled', async () => { + const roleMappingsAPI = ({ + getRoleMapping: jest.fn().mockResolvedValue({ + name: 'foo', + role_templates: [ + { + template: { id: 'foo' }, + }, + ], + enabled: true, + rules: { + field: { username: '*' }, + }, + }), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + canUseInlineScripts: true, + canUseStoredScripts: false, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl( + + ); + + expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(0); + expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(0); + + await nextTick(); + wrapper.update(); + + expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(0); + expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(1); + }); + + it('renders a warning when editing a mapping with an inline role template, when inline scripts are disabled', async () => { + const roleMappingsAPI = ({ + getRoleMapping: jest.fn().mockResolvedValue({ + name: 'foo', + role_templates: [ + { + template: { source: 'foo' }, + }, + ], + enabled: true, + rules: { + field: { username: '*' }, + }, + }), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + canUseInlineScripts: false, + canUseStoredScripts: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl( + + ); + + expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(0); + expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(0); + + await nextTick(); + wrapper.update(); + + expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(1); + expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(0); + }); + + it('renders the visual editor by default for simple rule sets', async () => { + const roleMappingsAPI = ({ + getRoleMapping: jest.fn().mockResolvedValue({ + name: 'foo', + roles: ['superuser'], + enabled: true, + rules: { + all: [ + { + field: { + username: '*', + }, + }, + { + field: { + dn: null, + }, + }, + { + field: { + realm: ['ldap', 'pki', null, 12], + }, + }, + ], + }, + }), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl( + + ); + + await nextTick(); + wrapper.update(); + + expect(wrapper.find(VisualRuleEditor)).toHaveLength(1); + expect(wrapper.find(JSONRuleEditor)).toHaveLength(0); + }); + + it('renders the JSON editor by default for complex rule sets', async () => { + const createRule = (depth: number): Record => { + if (depth > 0) { + const rule = { + all: [ + { + field: { + username: '*', + }, + }, + ], + } as Record; + + const subRule = createRule(depth - 1); + if (subRule) { + rule.all.push(subRule); + } + + return rule; + } + return null as any; + }; + + const roleMappingsAPI = ({ + getRoleMapping: jest.fn().mockResolvedValue({ + name: 'foo', + roles: ['superuser'], + enabled: true, + rules: createRule(10), + }), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl( + + ); + + await nextTick(); + wrapper.update(); + + expect(wrapper.find(VisualRuleEditor)).toHaveLength(0); + expect(wrapper.find(JSONRuleEditor)).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.tsx new file mode 100644 index 0000000000000..b8a75a4ad9fdf --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/edit_role_mapping_page.tsx @@ -0,0 +1,332 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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, { Component, Fragment } from 'react'; +import { + EuiForm, + EuiPageContent, + EuiSpacer, + EuiText, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiLink, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { toastNotifications } from 'ui/notify'; +import { RoleMapping } from '../../../../../../common/model'; +import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api'; +import { RuleEditorPanel } from './rule_editor_panel'; +import { + NoCompatibleRealms, + PermissionDenied, + DeleteProvider, + SectionLoading, +} from '../../components'; +import { ROLE_MAPPINGS_PATH } from '../../../management_urls'; +import { validateRoleMappingForSave } from '../services/role_mapping_validation'; +import { MappingInfoPanel } from './mapping_info_panel'; +import { documentationLinks } from '../../services/documentation_links'; + +interface State { + loadState: 'loading' | 'permissionDenied' | 'ready' | 'saveInProgress'; + roleMapping: RoleMapping | null; + hasCompatibleRealms: boolean; + canUseStoredScripts: boolean; + canUseInlineScripts: boolean; + formError: { + isInvalid: boolean; + error?: string; + }; + validateForm: boolean; + rulesValid: boolean; +} + +interface Props { + name?: string; + roleMappingsAPI: RoleMappingsAPI; +} + +export class EditRoleMappingPage extends Component { + constructor(props: any) { + super(props); + this.state = { + loadState: 'loading', + roleMapping: null, + hasCompatibleRealms: true, + canUseStoredScripts: true, + canUseInlineScripts: true, + rulesValid: true, + validateForm: false, + formError: { + isInvalid: false, + }, + }; + } + + public componentDidMount() { + this.loadAppData(); + } + + public render() { + const { loadState } = this.state; + + if (loadState === 'permissionDenied') { + return ; + } + + if (loadState === 'loading') { + return ( + + + + ); + } + + return ( +
+ + {this.getFormTitle()} + + this.setState({ roleMapping })} + mode={this.editingExistingRoleMapping() ? 'edit' : 'create'} + validateForm={this.state.validateForm} + canUseInlineScripts={this.state.canUseInlineScripts} + canUseStoredScripts={this.state.canUseStoredScripts} + /> + + + this.setState({ + roleMapping: { + ...this.state.roleMapping!, + rules, + }, + }) + } + /> + + {this.getFormButtons()} + +
+ ); + } + + private getFormTitle = () => { + return ( + + +

+ {this.editingExistingRoleMapping() ? ( + + ) : ( + + )} +

+
+ +

+ + + + ), + }} + /> +

+
+ {!this.state.hasCompatibleRealms && ( + <> + + + + )} +
+ ); + }; + + private getFormButtons = () => { + return ( + + + + + + + + + + + + + {this.editingExistingRoleMapping() && ( + + + {deleteRoleMappingsPrompt => { + return ( + + deleteRoleMappingsPrompt([this.state.roleMapping!], () => + this.backToRoleMappingsList() + ) + } + color="danger" + > + + + ); + }} + + + )} + + ); + }; + + private onRuleValidityChange = (rulesValid: boolean) => { + this.setState({ + rulesValid, + }); + }; + + private saveRoleMapping = () => { + if (!this.state.roleMapping) { + return; + } + + const { isInvalid } = validateRoleMappingForSave(this.state.roleMapping); + if (isInvalid) { + this.setState({ validateForm: true }); + return; + } + + const roleMappingName = this.state.roleMapping.name; + + this.setState({ + loadState: 'saveInProgress', + }); + + this.props.roleMappingsAPI + .saveRoleMapping(this.state.roleMapping) + .then(() => { + toastNotifications.addSuccess({ + title: i18n.translate('xpack.security.management.editRoleMapping.saveSuccess', { + defaultMessage: `Saved role mapping '{roleMappingName}'`, + values: { + roleMappingName, + }, + }), + 'data-test-subj': 'savedRoleMappingSuccessToast', + }); + this.backToRoleMappingsList(); + }) + .catch(e => { + toastNotifications.addError(e, { + title: i18n.translate('xpack.security.management.editRoleMapping.saveError', { + defaultMessage: `Error saving role mapping`, + }), + toastMessage: e?.body?.message, + }); + + this.setState({ + loadState: 'saveInProgress', + }); + }); + }; + + private editingExistingRoleMapping = () => typeof this.props.name === 'string'; + + private async loadAppData() { + try { + const [features, roleMapping] = await Promise.all([ + this.props.roleMappingsAPI.checkRoleMappingFeatures(), + this.editingExistingRoleMapping() + ? this.props.roleMappingsAPI.getRoleMapping(this.props.name!) + : Promise.resolve({ + name: '', + enabled: true, + metadata: {}, + role_templates: [], + roles: [], + rules: {}, + }), + ]); + + const { + canManageRoleMappings, + canUseStoredScripts, + canUseInlineScripts, + hasCompatibleRealms, + } = features; + + const loadState: State['loadState'] = canManageRoleMappings ? 'ready' : 'permissionDenied'; + + this.setState({ + loadState, + hasCompatibleRealms, + canUseStoredScripts, + canUseInlineScripts, + roleMapping, + }); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.security.management.editRoleMapping.table.fetchingRoleMappingsErrorMessage', + { + defaultMessage: 'Error loading role mapping editor: {message}', + values: { message: e?.body?.message ?? '' }, + } + ), + 'data-test-subj': 'errorLoadingRoleMappingEditorToast', + }); + this.backToRoleMappingsList(); + } + } + + private backToRoleMappingsList = () => { + window.location.hash = ROLE_MAPPINGS_PATH; + }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/index.ts new file mode 100644 index 0000000000000..6758033f92d98 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/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 { EditRoleMappingPage } from './edit_role_mapping_page'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/index.ts new file mode 100644 index 0000000000000..5042499bf00ac --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/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 { MappingInfoPanel } from './mapping_info_panel'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.test.tsx new file mode 100644 index 0000000000000..d821b33ace6a7 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.test.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 from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { MappingInfoPanel } from '.'; +import { RoleMapping } from '../../../../../../../common/model'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { RoleSelector } from '../role_selector'; +import { RoleTemplateEditor } from '../role_selector/role_template_editor'; + +jest.mock('../../../../../../lib/roles_api', () => { + return { + RolesApi: { + getRoles: () => Promise.resolve([{ name: 'foo_role' }, { name: 'bar role' }]), + }, + }; +}); + +describe('MappingInfoPanel', () => { + it('renders when creating a role mapping, default to the "roles" view', () => { + const props = { + roleMapping: { + name: 'my role mapping', + enabled: true, + roles: [], + role_templates: [], + rules: {}, + metadata: {}, + } as RoleMapping, + mode: 'create', + } as MappingInfoPanel['props']; + + const wrapper = mountWithIntl(); + + // Name input validation + const { value: nameInputValue, readOnly: nameInputReadOnly } = findTestSubject( + wrapper, + 'roleMappingFormNameInput' + ) + .find('input') + .props(); + + expect(nameInputValue).toEqual(props.roleMapping.name); + expect(nameInputReadOnly).toEqual(false); + + // Enabled switch validation + const { checked: enabledInputValue } = wrapper + .find('EuiSwitch[data-test-subj="roleMappingsEnabledSwitch"]') + .props(); + + expect(enabledInputValue).toEqual(props.roleMapping.enabled); + + // Verify "roles" mode + expect(wrapper.find(RoleSelector).props()).toMatchObject({ + mode: 'roles', + }); + }); + + it('renders the role templates view if templates are provided', () => { + const props = { + roleMapping: { + name: 'my role mapping', + enabled: true, + roles: [], + role_templates: [ + { + template: { + source: '', + }, + }, + ], + rules: {}, + metadata: {}, + } as RoleMapping, + mode: 'edit', + } as MappingInfoPanel['props']; + + const wrapper = mountWithIntl(); + + expect(wrapper.find(RoleSelector).props()).toMatchObject({ + mode: 'templates', + }); + }); + + it('renders a blank inline template by default when switching from roles to role templates', () => { + const props = { + roleMapping: { + name: 'my role mapping', + enabled: true, + roles: ['foo_role'], + role_templates: [], + rules: {}, + metadata: {}, + } as RoleMapping, + mode: 'create' as any, + onChange: jest.fn(), + canUseInlineScripts: true, + canUseStoredScripts: false, + validateForm: false, + }; + + const wrapper = mountWithIntl(); + + findTestSubject(wrapper, 'switchToRoleTemplatesButton').simulate('click'); + + expect(props.onChange).toHaveBeenCalledWith({ + name: 'my role mapping', + enabled: true, + roles: [], + role_templates: [ + { + template: { source: '' }, + }, + ], + rules: {}, + metadata: {}, + }); + + wrapper.setProps({ roleMapping: props.onChange.mock.calls[0][0] }); + + expect(wrapper.find(RoleTemplateEditor)).toHaveLength(1); + }); + + it('renders a blank stored template by default when switching from roles to role templates and inline scripts are disabled', () => { + const props = { + roleMapping: { + name: 'my role mapping', + enabled: true, + roles: ['foo_role'], + role_templates: [], + rules: {}, + metadata: {}, + } as RoleMapping, + mode: 'create' as any, + onChange: jest.fn(), + canUseInlineScripts: false, + canUseStoredScripts: true, + validateForm: false, + }; + + const wrapper = mountWithIntl(); + + findTestSubject(wrapper, 'switchToRoleTemplatesButton').simulate('click'); + + expect(props.onChange).toHaveBeenCalledWith({ + name: 'my role mapping', + enabled: true, + roles: [], + role_templates: [ + { + template: { id: '' }, + }, + ], + rules: {}, + metadata: {}, + }); + + wrapper.setProps({ roleMapping: props.onChange.mock.calls[0][0] }); + + expect(wrapper.find(RoleTemplateEditor)).toHaveLength(1); + }); + + it('does not create a blank role template if no script types are enabled', () => { + const props = { + roleMapping: { + name: 'my role mapping', + enabled: true, + roles: ['foo_role'], + role_templates: [], + rules: {}, + metadata: {}, + } as RoleMapping, + mode: 'create' as any, + onChange: jest.fn(), + canUseInlineScripts: false, + canUseStoredScripts: false, + validateForm: false, + }; + + const wrapper = mountWithIntl(); + + findTestSubject(wrapper, 'switchToRoleTemplatesButton').simulate('click'); + + wrapper.update(); + + expect(props.onChange).not.toHaveBeenCalled(); + expect(wrapper.find(RoleTemplateEditor)).toHaveLength(0); + }); + + it('renders the name input as readonly when editing an existing role mapping', () => { + const props = { + roleMapping: { + name: 'my role mapping', + enabled: true, + roles: [], + role_templates: [], + rules: {}, + metadata: {}, + } as RoleMapping, + mode: 'edit', + } as MappingInfoPanel['props']; + + const wrapper = mountWithIntl(); + + // Name input validation + const { value: nameInputValue, readOnly: nameInputReadOnly } = findTestSubject( + wrapper, + 'roleMappingFormNameInput' + ) + .find('input') + .props(); + + expect(nameInputValue).toEqual(props.roleMapping.name); + expect(nameInputReadOnly).toEqual(true); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.tsx new file mode 100644 index 0000000000000..a02b4fc1709f0 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/mapping_info_panel/mapping_info_panel.tsx @@ -0,0 +1,323 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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, { Component, ChangeEvent, Fragment } from 'react'; +import { + EuiPanel, + EuiTitle, + EuiText, + EuiSpacer, + EuiDescribedFormGroup, + EuiFormRow, + EuiFieldText, + EuiLink, + EuiIcon, + EuiSwitch, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { RoleMapping } from '../../../../../../../common/model'; +import { + validateRoleMappingName, + validateRoleMappingRoles, + validateRoleMappingRoleTemplates, +} from '../../services/role_mapping_validation'; +import { RoleSelector } from '../role_selector'; +import { documentationLinks } from '../../../services/documentation_links'; + +interface Props { + roleMapping: RoleMapping; + onChange: (roleMapping: RoleMapping) => void; + mode: 'create' | 'edit'; + validateForm: boolean; + canUseInlineScripts: boolean; + canUseStoredScripts: boolean; +} + +interface State { + rolesMode: 'roles' | 'templates'; +} + +export class MappingInfoPanel extends Component { + constructor(props: Props) { + super(props); + this.state = { + rolesMode: + props.roleMapping.role_templates && props.roleMapping.role_templates.length > 0 + ? 'templates' + : 'roles', + }; + } + public render() { + return ( + + +

+ +

+
+ + {this.getRoleMappingName()} + {this.getEnabledSwitch()} + {this.getRolesOrRoleTemplatesSelector()} +
+ ); + } + + private getRoleMappingName = () => { + return ( + + + + } + description={ + + } + fullWidth + > + + } + fullWidth + {...(this.props.validateForm && validateRoleMappingName(this.props.roleMapping))} + > + + + + ); + }; + + private getRolesOrRoleTemplatesSelector = () => { + if (this.state.rolesMode === 'roles') { + return this.getRolesSelector(); + } + return this.getRoleTemplatesSelector(); + }; + + private getRolesSelector = () => { + const validationFunction = () => { + if (!this.props.validateForm) { + return {}; + } + return validateRoleMappingRoles(this.props.roleMapping); + }; + return ( + + + + } + description={ + + + + + + { + this.onRolesModeChange('templates'); + }} + > + + {' '} + + + + + } + fullWidth + > + + this.props.onChange(roleMapping)} + /> + + + ); + }; + + private getRoleTemplatesSelector = () => { + const validationFunction = () => { + if (!this.props.validateForm) { + return {}; + } + return validateRoleMappingRoleTemplates(this.props.roleMapping); + }; + return ( + + + + } + description={ + + + {' '} + + + + + + { + this.onRolesModeChange('roles'); + }} + data-test-subj="switchToRolesButton" + > + + {' '} + + + + + } + fullWidth + > + + this.props.onChange(roleMapping)} + /> + + + ); + }; + + private getEnabledSwitch = () => { + return ( + + + + } + description={ + + } + fullWidth + > + + } + fullWidth + > + + } + showLabel={false} + data-test-subj="roleMappingsEnabledSwitch" + checked={this.props.roleMapping.enabled} + onChange={e => { + this.props.onChange({ + ...this.props.roleMapping, + enabled: e.target.checked, + }); + }} + /> + + + ); + }; + + private onNameChange = (e: ChangeEvent) => { + const name = e.target.value; + + this.props.onChange({ + ...this.props.roleMapping, + name, + }); + }; + + private onRolesModeChange = (rolesMode: State['rolesMode']) => { + const canUseTemplates = this.props.canUseInlineScripts || this.props.canUseStoredScripts; + if (rolesMode === 'templates' && canUseTemplates) { + // Create blank template as a starting point + const defaultTemplate = this.props.canUseInlineScripts + ? { + template: { source: '' }, + } + : { + template: { id: '' }, + }; + this.props.onChange({ + ...this.props.roleMapping, + roles: [], + role_templates: [defaultTemplate], + }); + } + this.setState({ rolesMode }); + }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.test.tsx new file mode 100644 index 0000000000000..230664f6fc997 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; +import { AddRoleTemplateButton } from './add_role_template_button'; + +describe('AddRoleTemplateButton', () => { + it('renders a warning instead of a button if all script types are disabled', () => { + const wrapper = shallowWithIntl( + + ); + + expect(wrapper).toMatchInlineSnapshot(` + + } + > +

+ +

+
+ `); + }); + + it(`asks for an inline template to be created if both script types are enabled`, () => { + const onClickHandler = jest.fn(); + const wrapper = mountWithIntl( + + ); + wrapper.simulate('click'); + expect(onClickHandler).toHaveBeenCalledTimes(1); + expect(onClickHandler).toHaveBeenCalledWith('inline'); + }); + + it(`asks for a stored template to be created if inline scripts are disabled`, () => { + const onClickHandler = jest.fn(); + const wrapper = mountWithIntl( + + ); + wrapper.simulate('click'); + expect(onClickHandler).toHaveBeenCalledTimes(1); + expect(onClickHandler).toHaveBeenCalledWith('stored'); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.tsx new file mode 100644 index 0000000000000..5a78e399bacc7 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/add_role_template_button.tsx @@ -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 React from 'react'; +import { EuiButtonEmpty, EuiCallOut } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface Props { + canUseStoredScripts: boolean; + canUseInlineScripts: boolean; + onClick: (templateType: 'inline' | 'stored') => void; +} + +export const AddRoleTemplateButton = (props: Props) => { + if (!props.canUseStoredScripts && !props.canUseInlineScripts) { + return ( + + } + > +

+ +

+
+ ); + } + + const addRoleTemplate = ( + + ); + if (props.canUseInlineScripts) { + return ( + props.onClick('inline')} + data-test-subj="addRoleTemplateButton" + > + {addRoleTemplate} + + ); + } + + return ( + props.onClick('stored')} + data-test-subj="addRoleTemplateButton" + > + {addRoleTemplate} + + ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/index.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/index.tsx new file mode 100644 index 0000000000000..0011f6ea77bc6 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/index.tsx @@ -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 { RoleSelector } from './role_selector'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.test.tsx new file mode 100644 index 0000000000000..89815c50e5547 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.test.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { findTestSubject } from 'test_utils/find_test_subject'; +import { EuiComboBox } from '@elastic/eui'; +import { RoleSelector } from './role_selector'; +import { RoleMapping } from '../../../../../../../common/model'; +import { RoleTemplateEditor } from './role_template_editor'; +import { AddRoleTemplateButton } from './add_role_template_button'; + +jest.mock('../../../../../../lib/roles_api', () => { + return { + RolesApi: { + getRoles: () => Promise.resolve([{ name: 'foo_role' }, { name: 'bar role' }]), + }, + }; +}); + +describe('RoleSelector', () => { + it('allows roles to be selected, removing any previously selected role templates', () => { + const props = { + roleMapping: { + roles: [] as string[], + role_templates: [ + { + template: { source: '' }, + }, + ], + } as RoleMapping, + canUseStoredScripts: true, + canUseInlineScripts: true, + onChange: jest.fn(), + mode: 'roles', + } as RoleSelector['props']; + + const wrapper = mountWithIntl(); + (wrapper.find(EuiComboBox).props() as any).onChange([{ label: 'foo_role' }]); + + expect(props.onChange).toHaveBeenCalledWith({ + roles: ['foo_role'], + role_templates: [], + }); + }); + + it('allows role templates to be created, removing any previously selected roles', () => { + const props = { + roleMapping: { + roles: ['foo_role'], + role_templates: [] as any, + } as RoleMapping, + canUseStoredScripts: true, + canUseInlineScripts: true, + onChange: jest.fn(), + mode: 'templates', + } as RoleSelector['props']; + + const wrapper = mountWithIntl(); + + wrapper.find(AddRoleTemplateButton).simulate('click'); + + expect(props.onChange).toHaveBeenCalledWith({ + roles: [], + role_templates: [ + { + template: { source: '' }, + }, + ], + }); + }); + + it('allows role templates to be edited', () => { + const props = { + roleMapping: { + roles: [] as string[], + role_templates: [ + { + template: { source: 'foo_role' }, + }, + ], + } as RoleMapping, + canUseStoredScripts: true, + canUseInlineScripts: true, + onChange: jest.fn(), + mode: 'templates', + } as RoleSelector['props']; + + const wrapper = mountWithIntl(); + + wrapper + .find(RoleTemplateEditor) + .props() + .onChange({ + template: { source: '{{username}}_role' }, + }); + + expect(props.onChange).toHaveBeenCalledWith({ + roles: [], + role_templates: [ + { + template: { source: '{{username}}_role' }, + }, + ], + }); + }); + + it('allows role templates to be deleted', () => { + const props = { + roleMapping: { + roles: [] as string[], + role_templates: [ + { + template: { source: 'foo_role' }, + }, + ], + } as RoleMapping, + canUseStoredScripts: true, + canUseInlineScripts: true, + onChange: jest.fn(), + mode: 'templates', + } as RoleSelector['props']; + + const wrapper = mountWithIntl(); + + findTestSubject(wrapper, 'deleteRoleTemplateButton').simulate('click'); + + expect(props.onChange).toHaveBeenCalledWith({ + roles: [], + role_templates: [], + }); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.tsx new file mode 100644 index 0000000000000..6b92d6b4907f1 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_selector.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { i18n } from '@kbn/i18n'; +import { EuiComboBox, EuiFormRow, EuiHorizontalRule } from '@elastic/eui'; +import { RoleMapping, Role } from '../../../../../../../common/model'; +import { RolesApi } from '../../../../../../lib/roles_api'; +import { AddRoleTemplateButton } from './add_role_template_button'; +import { RoleTemplateEditor } from './role_template_editor'; + +interface Props { + roleMapping: RoleMapping; + canUseInlineScripts: boolean; + canUseStoredScripts: boolean; + mode: 'roles' | 'templates'; + onChange: (roleMapping: RoleMapping) => void; +} + +interface State { + roles: Role[]; +} + +export class RoleSelector extends React.Component { + constructor(props: Props) { + super(props); + + this.state = { roles: [] }; + } + + public async componentDidMount() { + const roles = await RolesApi.getRoles(); + this.setState({ roles }); + } + + public render() { + const { mode } = this.props; + return ( + + {mode === 'roles' ? this.getRoleComboBox() : this.getRoleTemplates()} + + ); + } + + private getRoleComboBox = () => { + const { roles = [] } = this.props.roleMapping; + return ( + ({ label: r.name }))} + selectedOptions={roles!.map(r => ({ label: r }))} + onChange={selectedOptions => { + this.props.onChange({ + ...this.props.roleMapping, + roles: selectedOptions.map(so => so.label), + role_templates: [], + }); + }} + /> + ); + }; + + private getRoleTemplates = () => { + const { role_templates: roleTemplates = [] } = this.props.roleMapping; + return ( +
+ {roleTemplates.map((rt, index) => ( + + { + const templates = [...(this.props.roleMapping.role_templates || [])]; + templates.splice(index, 1, updatedTemplate); + this.props.onChange({ + ...this.props.roleMapping, + role_templates: templates, + }); + }} + onDelete={() => { + const templates = [...(this.props.roleMapping.role_templates || [])]; + templates.splice(index, 1); + this.props.onChange({ + ...this.props.roleMapping, + role_templates: templates, + }); + }} + /> + + + ))} + { + switch (type) { + case 'inline': { + const templates = this.props.roleMapping.role_templates || []; + this.props.onChange({ + ...this.props.roleMapping, + roles: [], + role_templates: [...templates, { template: { source: '' } }], + }); + break; + } + case 'stored': { + const templates = this.props.roleMapping.role_templates || []; + this.props.onChange({ + ...this.props.roleMapping, + roles: [], + role_templates: [...templates, { template: { id: '' } }], + }); + break; + } + default: + throw new Error(`Unsupported template type: ${type}`); + } + }} + /> +
+ ); + }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.test.tsx new file mode 100644 index 0000000000000..6d4af97e12def --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.test.tsx @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { RoleTemplateEditor } from './role_template_editor'; +import { findTestSubject } from 'test_utils/find_test_subject'; + +describe('RoleTemplateEditor', () => { + it('allows inline templates to be edited', () => { + const props = { + roleTemplate: { + template: { + source: '{{username}}_foo', + }, + }, + onChange: jest.fn(), + onDelete: jest.fn(), + canUseStoredScripts: true, + canUseInlineScripts: true, + }; + + const wrapper = mountWithIntl(); + (wrapper + .find('EuiFieldText[data-test-subj="roleTemplateSourceEditor"]') + .props() as any).onChange({ target: { value: 'new_script' } }); + + expect(props.onChange).toHaveBeenCalledWith({ + template: { + source: 'new_script', + }, + }); + }); + + it('warns when editing inline scripts when they are disabled', () => { + const props = { + roleTemplate: { + template: { + source: '{{username}}_foo', + }, + }, + onChange: jest.fn(), + onDelete: jest.fn(), + canUseStoredScripts: true, + canUseInlineScripts: false, + }; + + const wrapper = mountWithIntl(); + expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(1); + expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(0); + expect(findTestSubject(wrapper, 'roleMappingInvalidRoleTemplate')).toHaveLength(0); + }); + + it('warns when editing stored scripts when they are disabled', () => { + const props = { + roleTemplate: { + template: { + id: '{{username}}_foo', + }, + }, + onChange: jest.fn(), + onDelete: jest.fn(), + canUseStoredScripts: false, + canUseInlineScripts: true, + }; + + const wrapper = mountWithIntl(); + expect(findTestSubject(wrapper, 'roleMappingInlineScriptsDisabled')).toHaveLength(0); + expect(findTestSubject(wrapper, 'roleMappingStoredScriptsDisabled')).toHaveLength(1); + expect(findTestSubject(wrapper, 'roleMappingInvalidRoleTemplate')).toHaveLength(0); + }); + + it('allows template types to be changed', () => { + const props = { + roleTemplate: { + template: { + source: '{{username}}_foo', + }, + }, + onChange: jest.fn(), + onDelete: jest.fn(), + canUseStoredScripts: true, + canUseInlineScripts: true, + }; + + const wrapper = mountWithIntl(); + (wrapper + .find('EuiComboBox[data-test-subj="roleMappingsFormTemplateType"]') + .props() as any).onChange('stored'); + + expect(props.onChange).toHaveBeenCalledWith({ + template: { + id: '', + }, + }); + }); + + it('warns when an invalid role template is specified', () => { + const props = { + roleTemplate: { + template: `This is a string instead of an object if the template was stored in an unparsable format in ES`, + }, + onChange: jest.fn(), + onDelete: jest.fn(), + canUseStoredScripts: true, + canUseInlineScripts: true, + }; + + const wrapper = mountWithIntl(); + expect(findTestSubject(wrapper, 'roleMappingInvalidRoleTemplate')).toHaveLength(1); + expect(findTestSubject(wrapper, 'roleTemplateSourceEditor')).toHaveLength(0); + expect(findTestSubject(wrapper, 'roleTemplateScriptIdEditor')).toHaveLength(0); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.tsx new file mode 100644 index 0000000000000..4b8d34d271996 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_editor.tsx @@ -0,0 +1,254 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldText, + EuiCallOut, + EuiText, + EuiSwitch, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { RoleTemplate } from '../../../../../../../common/model'; +import { + isInlineRoleTemplate, + isStoredRoleTemplate, + isInvalidRoleTemplate, +} from '../../services/role_template_type'; +import { RoleTemplateTypeSelect } from './role_template_type_select'; + +interface Props { + roleTemplate: RoleTemplate; + canUseInlineScripts: boolean; + canUseStoredScripts: boolean; + onChange: (roleTemplate: RoleTemplate) => void; + onDelete: (roleTemplate: RoleTemplate) => void; +} + +export const RoleTemplateEditor = ({ + roleTemplate, + onChange, + onDelete, + canUseInlineScripts, + canUseStoredScripts, +}: Props) => { + return ( + + {getTemplateConfigurationFields()} + {getEditorForTemplate()} + + + + + onDelete(roleTemplate)} + data-test-subj="deleteRoleTemplateButton" + > + + + + + + + ); + + function getTemplateFormatSwitch() { + const returnsJsonLabel = i18n.translate( + 'xpack.security.management.editRoleMapping.roleTemplateReturnsJson', + { + defaultMessage: 'Returns JSON', + } + ); + + return ( + + { + onChange({ + ...roleTemplate, + format: e.target.checked ? 'json' : 'string', + }); + }} + /> + + ); + } + + function getTemplateConfigurationFields() { + const templateTypeComboBox = ( + + + } + > + + + + ); + + const templateFormatSwitch = {getTemplateFormatSwitch()}; + + return ( + + + {templateTypeComboBox} + {templateFormatSwitch} + + + ); + } + + function getEditorForTemplate() { + if (isInlineRoleTemplate(roleTemplate)) { + const extraProps: Record = {}; + if (!canUseInlineScripts) { + extraProps.isInvalid = true; + extraProps.error = ( + + + + ); + } + const example = '{{username}}_role'; + return ( + + + + } + helpText={ + + } + {...extraProps} + > + { + onChange({ + ...roleTemplate, + template: { + source: e.target.value, + }, + }); + }} + /> + + + + ); + } + + if (isStoredRoleTemplate(roleTemplate)) { + const extraProps: Record = {}; + if (!canUseStoredScripts) { + extraProps.isInvalid = true; + extraProps.error = ( + + + + ); + } + return ( + + + + } + helpText={ + + } + {...extraProps} + > + { + onChange({ + ...roleTemplate, + template: { + id: e.target.value, + }, + }); + }} + /> + + + + ); + } + + if (isInvalidRoleTemplate(roleTemplate)) { + return ( + + + } + > + + + + ); + } + + throw new Error(`Unable to determine role template type`); + } +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_type_select.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_type_select.tsx new file mode 100644 index 0000000000000..4a06af0fb436b --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/role_selector/role_template_type_select.tsx @@ -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 React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiComboBox } from '@elastic/eui'; +import { RoleTemplate } from '../../../../../../../common/model'; +import { isInlineRoleTemplate, isStoredRoleTemplate } from '../../services/role_template_type'; + +const templateTypeOptions = [ + { + id: 'inline', + label: i18n.translate( + 'xpack.security.management.editRoleMapping.roleTemplate.inlineTypeLabel', + { defaultMessage: 'Role template' } + ), + }, + { + id: 'stored', + label: i18n.translate( + 'xpack.security.management.editRoleMapping.roleTemplate.storedTypeLabel', + { defaultMessage: 'Stored script' } + ), + }, +]; + +interface Props { + roleTemplate: RoleTemplate; + onChange: (roleTempplate: RoleTemplate) => void; + canUseStoredScripts: boolean; + canUseInlineScripts: boolean; +} + +export const RoleTemplateTypeSelect = (props: Props) => { + const availableOptions = templateTypeOptions.filter( + ({ id }) => + (id === 'inline' && props.canUseInlineScripts) || + (id === 'stored' && props.canUseStoredScripts) + ); + + const selectedOptions = templateTypeOptions.filter( + ({ id }) => + (id === 'inline' && isInlineRoleTemplate(props.roleTemplate)) || + (id === 'stored' && isStoredRoleTemplate(props.roleTemplate)) + ); + + return ( + { + const [{ id }] = selected; + if (id === 'inline') { + props.onChange({ + ...props.roleTemplate, + template: { + source: '', + }, + }); + } else { + props.onChange({ + ...props.roleTemplate, + template: { + id: '', + }, + }); + } + }} + isClearable={false} + /> + ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/_index.scss b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/_index.scss new file mode 100644 index 0000000000000..de64b80599720 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/_index.scss @@ -0,0 +1,7 @@ +.secRoleMapping__ruleEditorGroup--even { + background-color: $euiColorLightestShade; +} + +.secRoleMapping__ruleEditorGroup--odd { + background-color: $euiColorEmptyShade; +} \ No newline at end of file diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.test.tsx new file mode 100644 index 0000000000000..917b822acef3f --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.test.tsx @@ -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 React from 'react'; +import { AddRuleButton } from './add_rule_button'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { FieldRule, AllRule } from '../../../model'; + +describe('AddRuleButton', () => { + it('allows a field rule to be created', () => { + const props = { + onClick: jest.fn(), + }; + + const wrapper = mountWithIntl(); + findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click'); + expect(findTestSubject(wrapper, 'addRuleContextMenu')).toHaveLength(1); + + // EUI renders this ID twice, so we need to target the button itself + wrapper.find('button[id="addRuleOption"]').simulate('click'); + + expect(props.onClick).toHaveBeenCalledTimes(1); + + const [newRule] = props.onClick.mock.calls[0]; + expect(newRule).toBeInstanceOf(FieldRule); + expect(newRule.toRaw()).toEqual({ + field: { username: '*' }, + }); + }); + + it('allows a rule group to be created', () => { + const props = { + onClick: jest.fn(), + }; + + const wrapper = mountWithIntl(); + findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click'); + expect(findTestSubject(wrapper, 'addRuleContextMenu')).toHaveLength(1); + + // EUI renders this ID twice, so we need to target the button itself + wrapper.find('button[id="addRuleGroupOption"]').simulate('click'); + + expect(props.onClick).toHaveBeenCalledTimes(1); + + const [newRule] = props.onClick.mock.calls[0]; + expect(newRule).toBeInstanceOf(AllRule); + expect(newRule.toRaw()).toEqual({ + all: [{ field: { username: '*' } }], + }); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.tsx new file mode 100644 index 0000000000000..100c0dd3eeaee --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/add_rule_button.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { EuiButtonEmpty, EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Rule, FieldRule, AllRule } from '../../../model'; + +interface Props { + onClick: (newRule: Rule) => void; +} + +export const AddRuleButton = (props: Props) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const button = ( + { + setIsMenuOpen(!isMenuOpen); + }} + > + + + ); + + const options = [ + { + setIsMenuOpen(false); + props.onClick(new FieldRule('username', '*')); + }} + > + + , + { + setIsMenuOpen(false); + props.onClick(new AllRule([new FieldRule('username', '*')])); + }} + > + + , + ]; + + return ( + setIsMenuOpen(false)} + panelPaddingSize="none" + withTitle + anchorPosition="downLeft" + > + + + ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.test.tsx new file mode 100644 index 0000000000000..8d5d5c99ee99d --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.test.tsx @@ -0,0 +1,230 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { FieldRuleEditor } from './field_rule_editor'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { FieldRule } from '../../../model'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { ReactWrapper } from 'enzyme'; + +function assertField(wrapper: ReactWrapper, index: number, field: string) { + const isFirst = index === 0; + if (isFirst) { + expect( + wrapper.find(`EuiComboBox[data-test-subj~="fieldRuleEditorField-${index}"]`).props() + ).toMatchObject({ + selectedOptions: [{ label: field }], + }); + + expect(findTestSubject(wrapper, `fieldRuleEditorField-${index}-combo`)).toHaveLength(1); + expect(findTestSubject(wrapper, `fieldRuleEditorField-${index}-expression`)).toHaveLength(0); + } else { + expect( + wrapper.find(`EuiExpression[data-test-subj~="fieldRuleEditorField-${index}"]`).props() + ).toMatchObject({ + value: field, + }); + + expect(findTestSubject(wrapper, `fieldRuleEditorField-${index}-combo`)).toHaveLength(0); + expect(findTestSubject(wrapper, `fieldRuleEditorField-${index}-expression`)).toHaveLength(1); + } +} + +function assertValueType(wrapper: ReactWrapper, index: number, type: string) { + const valueTypeField = findTestSubject(wrapper, `fieldRuleEditorValueType-${index}`); + expect(valueTypeField.props()).toMatchObject({ value: type }); +} + +function assertValue(wrapper: ReactWrapper, index: number, value: any) { + const valueField = findTestSubject(wrapper, `fieldRuleEditorValue-${index}`); + expect(valueField.props()).toMatchObject({ value }); +} + +describe('FieldRuleEditor', () => { + it('can render a text-based field rule', () => { + const props = { + rule: new FieldRule('username', '*'), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + assertField(wrapper, 0, 'username'); + assertValueType(wrapper, 0, 'text'); + assertValue(wrapper, 0, '*'); + }); + + it('can render a number-based field rule', () => { + const props = { + rule: new FieldRule('username', 12), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + assertField(wrapper, 0, 'username'); + assertValueType(wrapper, 0, 'number'); + assertValue(wrapper, 0, 12); + }); + + it('can render a null-based field rule', () => { + const props = { + rule: new FieldRule('username', null), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + assertField(wrapper, 0, 'username'); + assertValueType(wrapper, 0, 'null'); + assertValue(wrapper, 0, '-- null --'); + }); + + it('can render a boolean-based field rule (true)', () => { + const props = { + rule: new FieldRule('username', true), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + assertField(wrapper, 0, 'username'); + assertValueType(wrapper, 0, 'boolean'); + assertValue(wrapper, 0, 'true'); + }); + + it('can render a boolean-based field rule (false)', () => { + const props = { + rule: new FieldRule('username', false), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + assertField(wrapper, 0, 'username'); + assertValueType(wrapper, 0, 'boolean'); + assertValue(wrapper, 0, 'false'); + }); + + it('can render with alternate values specified', () => { + const props = { + rule: new FieldRule('username', ['*', 12, null, true, false]), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + expect(findTestSubject(wrapper, 'addAlternateValueButton')).toHaveLength(1); + + assertField(wrapper, 0, 'username'); + assertValueType(wrapper, 0, 'text'); + assertValue(wrapper, 0, '*'); + + assertField(wrapper, 1, 'username'); + assertValueType(wrapper, 1, 'number'); + assertValue(wrapper, 1, 12); + + assertField(wrapper, 2, 'username'); + assertValueType(wrapper, 2, 'null'); + assertValue(wrapper, 2, '-- null --'); + + assertField(wrapper, 3, 'username'); + assertValueType(wrapper, 3, 'boolean'); + assertValue(wrapper, 3, 'true'); + + assertField(wrapper, 4, 'username'); + assertValueType(wrapper, 4, 'boolean'); + assertValue(wrapper, 4, 'false'); + }); + + it('allows alternate values to be added when "allowAdd" is set to true', () => { + const props = { + rule: new FieldRule('username', null), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + findTestSubject(wrapper, 'addAlternateValueButton').simulate('click'); + expect(props.onChange).toHaveBeenCalledTimes(1); + const [updatedRule] = props.onChange.mock.calls[0]; + expect(updatedRule.toRaw()).toEqual({ + field: { + username: [null, '*'], + }, + }); + }); + + it('allows values to be deleted; deleting all values invokes "onDelete"', () => { + const props = { + rule: new FieldRule('username', ['*', 12, null]), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + + expect(findTestSubject(wrapper, `fieldRuleEditorDeleteValue`)).toHaveLength(3); + findTestSubject(wrapper, `fieldRuleEditorDeleteValue-0`).simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(1); + const [updatedRule1] = props.onChange.mock.calls[0]; + expect(updatedRule1.toRaw()).toEqual({ + field: { + username: [12, null], + }, + }); + + props.onChange.mockReset(); + + // simulate updated rule being fed back in + wrapper.setProps({ rule: updatedRule1 }); + + expect(findTestSubject(wrapper, `fieldRuleEditorDeleteValue`)).toHaveLength(2); + findTestSubject(wrapper, `fieldRuleEditorDeleteValue-1`).simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(1); + const [updatedRule2] = props.onChange.mock.calls[0]; + expect(updatedRule2.toRaw()).toEqual({ + field: { + username: [12], + }, + }); + + props.onChange.mockReset(); + + // simulate updated rule being fed back in + wrapper.setProps({ rule: updatedRule2 }); + + expect(findTestSubject(wrapper, `fieldRuleEditorDeleteValue`)).toHaveLength(1); + findTestSubject(wrapper, `fieldRuleEditorDeleteValue-0`).simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(0); + expect(props.onDelete).toHaveBeenCalledTimes(1); + }); + + it('allows field data types to be changed', () => { + const props = { + rule: new FieldRule('username', '*'), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = mountWithIntl(); + + const { onChange } = findTestSubject(wrapper, `fieldRuleEditorValueType-0`).props(); + onChange!({ target: { value: 'number' } as any } as any); + + expect(props.onChange).toHaveBeenCalledTimes(1); + const [updatedRule] = props.onChange.mock.calls[0]; + expect(updatedRule.toRaw()).toEqual({ + field: { + username: 0, + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.tsx new file mode 100644 index 0000000000000..52cf70dbd12bd --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/field_rule_editor.tsx @@ -0,0 +1,380 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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, { Component, ChangeEvent } from 'react'; +import { + EuiButtonIcon, + EuiExpression, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldText, + EuiComboBox, + EuiSelect, + EuiFieldNumber, + EuiIcon, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FieldRule, FieldRuleValue } from '../../../model'; + +interface Props { + rule: FieldRule; + onChange: (rule: FieldRule) => void; + onDelete: () => void; +} + +const userFields = [ + { + name: 'username', + }, + { + name: 'dn', + }, + { + name: 'groups', + }, + { + name: 'realm', + }, +]; + +const fieldOptions = userFields.map(f => ({ label: f.name })); + +type ComparisonOption = 'text' | 'number' | 'null' | 'boolean'; +const comparisonOptions: Record< + ComparisonOption, + { id: ComparisonOption; defaultValue: FieldRuleValue } +> = { + text: { + id: 'text', + defaultValue: '*', + }, + number: { + id: 'number', + defaultValue: 0, + }, + null: { + id: 'null', + defaultValue: null, + }, + boolean: { + id: 'boolean', + defaultValue: true, + }, +}; + +export class FieldRuleEditor extends Component { + public render() { + const { field, value } = this.props.rule; + + const content = Array.isArray(value) + ? value.map((v, index) => this.renderFieldRow(field, value, index)) + : [this.renderFieldRow(field, value, 0)]; + + return ( + + {content.map((row, index) => { + return {row}; + })} + + ); + } + + private renderFieldRow = (field: string, ruleValue: FieldRuleValue, valueIndex: number) => { + const isPrimaryRow = valueIndex === 0; + + let renderAddValueButton = true; + let rowRuleValue: FieldRuleValue = ruleValue; + if (Array.isArray(ruleValue)) { + renderAddValueButton = ruleValue.length - 1 === valueIndex; + rowRuleValue = ruleValue[valueIndex]; + } + + const comparisonType = this.getComparisonType(rowRuleValue); + + return ( + + + {isPrimaryRow ? ( + + + + ) : ( + + + + )} + + + {this.renderFieldTypeInput(comparisonType.id, valueIndex)} + + + {this.renderFieldValueInput(comparisonType.id, rowRuleValue, valueIndex)} + + + + {renderAddValueButton ? ( + + ) : ( + + )} + + + + + this.onRemoveAlternateValue(valueIndex)} + /> + + + + ); + }; + + private renderFieldTypeInput = (inputType: ComparisonOption, valueIndex: number) => { + return ( + + + this.onComparisonTypeChange(valueIndex, e.target.value as ComparisonOption) + } + /> + + ); + }; + + private renderFieldValueInput = ( + fieldType: ComparisonOption, + rowRuleValue: FieldRuleValue, + valueIndex: number + ) => { + const inputField = this.getInputFieldForType(fieldType, rowRuleValue, valueIndex); + + return ( + + {inputField} + + ); + }; + + private getInputFieldForType = ( + fieldType: ComparisonOption, + rowRuleValue: FieldRuleValue, + valueIndex: number + ) => { + const isNullValue = rowRuleValue === null; + + const commonProps = { + 'data-test-subj': `fieldRuleEditorValue-${valueIndex}`, + }; + + switch (fieldType) { + case 'boolean': + return ( + + ); + case 'text': + case 'null': + return ( + + ); + case 'number': + return ( + + ); + default: + throw new Error(`Unsupported input field type: ${fieldType}`); + } + }; + + private onAddAlternateValue = () => { + const { field, value } = this.props.rule; + const nextValue = Array.isArray(value) ? [...value] : [value]; + nextValue.push('*'); + this.props.onChange(new FieldRule(field, nextValue)); + }; + + private onRemoveAlternateValue = (index: number) => { + const { field, value } = this.props.rule; + + if (!Array.isArray(value) || value.length === 1) { + // Only one value left. Delete entire rule instead. + this.props.onDelete(); + return; + } + const nextValue = [...value]; + nextValue.splice(index, 1); + this.props.onChange(new FieldRule(field, nextValue)); + }; + + private onFieldChange = ([newField]: Array<{ label: string }>) => { + if (!newField) { + return; + } + + const { value } = this.props.rule; + this.props.onChange(new FieldRule(newField.label, value)); + }; + + private onAddField = (newField: string) => { + const { value } = this.props.rule; + this.props.onChange(new FieldRule(newField, value)); + }; + + private onValueChange = (index: number) => (e: ChangeEvent) => { + const { field, value } = this.props.rule; + let nextValue; + if (Array.isArray(value)) { + nextValue = [...value]; + nextValue.splice(index, 1, e.target.value); + } else { + nextValue = e.target.value; + } + this.props.onChange(new FieldRule(field, nextValue)); + }; + + private onNumericValueChange = (index: number) => (e: ChangeEvent) => { + const { field, value } = this.props.rule; + let nextValue; + if (Array.isArray(value)) { + nextValue = [...value]; + nextValue.splice(index, 1, parseFloat(e.target.value)); + } else { + nextValue = parseFloat(e.target.value); + } + this.props.onChange(new FieldRule(field, nextValue)); + }; + + private onBooleanValueChange = (index: number) => (e: ChangeEvent) => { + const boolValue = e.target.value === 'true'; + + const { field, value } = this.props.rule; + let nextValue; + if (Array.isArray(value)) { + nextValue = [...value]; + nextValue.splice(index, 1, boolValue); + } else { + nextValue = boolValue; + } + this.props.onChange(new FieldRule(field, nextValue)); + }; + + private onComparisonTypeChange = (index: number, newType: ComparisonOption) => { + const comparison = comparisonOptions[newType]; + if (!comparison) { + throw new Error(`Unexpected comparison type: ${newType}`); + } + const { field, value } = this.props.rule; + let nextValue = value; + if (Array.isArray(value)) { + nextValue = [...value]; + nextValue.splice(index, 1, comparison.defaultValue as any); + } else { + nextValue = comparison.defaultValue; + } + this.props.onChange(new FieldRule(field, nextValue)); + }; + + private getComparisonType(ruleValue: FieldRuleValue) { + const valueType = typeof ruleValue; + if (valueType === 'string' || valueType === 'undefined') { + return comparisonOptions.text; + } + if (valueType === 'number') { + return comparisonOptions.number; + } + if (valueType === 'boolean') { + return comparisonOptions.boolean; + } + if (ruleValue === null) { + return comparisonOptions.null; + } + throw new Error(`Unable to detect comparison type for rule value [${ruleValue}]`); + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/index.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/index.tsx new file mode 100644 index 0000000000000..dc09cb1e591fa --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/index.tsx @@ -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 { RuleEditorPanel } from './rule_editor_panel'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.test.tsx new file mode 100644 index 0000000000000..8a9b37ab0f406 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.test.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 'brace'; +import 'brace/mode/json'; + +// brace/ace uses the Worker class, which is not currently provided by JSDOM. +// This is not required for the tests to pass, but it rather suppresses lengthy +// warnings in the console which adds unnecessary noise to the test output. +import 'test_utils/stub_web_worker'; + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { JSONRuleEditor } from './json_rule_editor'; +import { EuiCodeEditor } from '@elastic/eui'; +import { AllRule, AnyRule, FieldRule, ExceptAnyRule, ExceptAllRule } from '../../../model'; + +describe('JSONRuleEditor', () => { + it('renders an empty rule set', () => { + const props = { + rules: null, + onChange: jest.fn(), + onValidityChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + + expect(props.onChange).not.toHaveBeenCalled(); + expect(props.onValidityChange).not.toHaveBeenCalled(); + + expect(wrapper.find(EuiCodeEditor).props().value).toMatchInlineSnapshot(`"{}"`); + }); + + it('renders a rule set', () => { + const props = { + rules: new AllRule([ + new AnyRule([new FieldRule('username', '*')]), + new ExceptAnyRule([ + new FieldRule('metadata.foo.bar', '*'), + new AllRule([new FieldRule('realm', 'special-one')]), + ]), + new ExceptAllRule([new FieldRule('realm', '*')]), + ]), + onChange: jest.fn(), + onValidityChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + + const { value } = wrapper.find(EuiCodeEditor).props(); + expect(JSON.parse(value)).toEqual({ + all: [ + { + any: [{ field: { username: '*' } }], + }, + { + except: { + any: [ + { field: { 'metadata.foo.bar': '*' } }, + { + all: [{ field: { realm: 'special-one' } }], + }, + ], + }, + }, + { + except: { + all: [{ field: { realm: '*' } }], + }, + }, + ], + }); + }); + + it('notifies when input contains invalid JSON', () => { + const props = { + rules: null, + onChange: jest.fn(), + onValidityChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + + const allRule = JSON.stringify(new AllRule().toRaw()); + act(() => { + wrapper + .find(EuiCodeEditor) + .props() + .onChange(allRule + ', this makes invalid JSON'); + }); + + expect(props.onValidityChange).toHaveBeenCalledTimes(1); + expect(props.onValidityChange).toHaveBeenCalledWith(false); + expect(props.onChange).not.toHaveBeenCalled(); + }); + + it('notifies when input contains an invalid rule set, even if it is valid JSON', () => { + const props = { + rules: null, + onChange: jest.fn(), + onValidityChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + + const invalidRule = JSON.stringify({ + all: [ + { + field: { + foo: {}, + }, + }, + ], + }); + + act(() => { + wrapper + .find(EuiCodeEditor) + .props() + .onChange(invalidRule); + }); + + expect(props.onValidityChange).toHaveBeenCalledTimes(1); + expect(props.onValidityChange).toHaveBeenCalledWith(false); + expect(props.onChange).not.toHaveBeenCalled(); + }); + + it('fires onChange when a valid rule set is provided after being previously invalidated', () => { + const props = { + rules: null, + onChange: jest.fn(), + onValidityChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + + const allRule = JSON.stringify(new AllRule().toRaw()); + act(() => { + wrapper + .find(EuiCodeEditor) + .props() + .onChange(allRule + ', this makes invalid JSON'); + }); + + expect(props.onValidityChange).toHaveBeenCalledTimes(1); + expect(props.onValidityChange).toHaveBeenCalledWith(false); + expect(props.onChange).not.toHaveBeenCalled(); + + props.onValidityChange.mockReset(); + + act(() => { + wrapper + .find(EuiCodeEditor) + .props() + .onChange(allRule); + }); + + expect(props.onValidityChange).toHaveBeenCalledTimes(1); + expect(props.onValidityChange).toHaveBeenCalledWith(true); + + expect(props.onChange).toHaveBeenCalledTimes(1); + const [updatedRule] = props.onChange.mock.calls[0]; + expect(JSON.stringify(updatedRule.toRaw())).toEqual(allRule); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.tsx new file mode 100644 index 0000000000000..371fb59f7a5d1 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/json_rule_editor.tsx @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, Fragment } from 'react'; + +import 'brace/mode/json'; +import 'brace/theme/github'; +import { EuiCodeEditor, EuiFormRow, EuiButton, EuiSpacer, EuiLink, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { Rule, RuleBuilderError, generateRulesFromRaw } from '../../../model'; +import { documentationLinks } from '../../../services/documentation_links'; + +interface Props { + rules: Rule | null; + onChange: (updatedRules: Rule | null) => void; + onValidityChange: (isValid: boolean) => void; +} + +export const JSONRuleEditor = (props: Props) => { + const [rawRules, setRawRules] = useState( + JSON.stringify(props.rules ? props.rules.toRaw() : {}, null, 2) + ); + + const [ruleBuilderError, setRuleBuilderError] = useState(null); + + function onRulesChange(updatedRules: string) { + setRawRules(updatedRules); + // Fire onChange only if rules are valid + try { + const ruleJSON = JSON.parse(updatedRules); + props.onChange(generateRulesFromRaw(ruleJSON).rules); + props.onValidityChange(true); + setRuleBuilderError(null); + } catch (e) { + if (e instanceof RuleBuilderError) { + setRuleBuilderError(e); + } else { + setRuleBuilderError(null); + } + props.onValidityChange(false); + } + } + + function reformatRules() { + try { + const ruleJSON = JSON.parse(rawRules); + setRawRules(JSON.stringify(ruleJSON, null, 2)); + } catch (ignore) { + // ignore + } + } + + return ( + + + + + + + + + +

+ + + + ), + }} + /> +

+
+
+
+ ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.test.tsx new file mode 100644 index 0000000000000..809264183d30c --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { RuleEditorPanel } from '.'; +import { VisualRuleEditor } from './visual_rule_editor'; +import { JSONRuleEditor } from './json_rule_editor'; +import { findTestSubject } from 'test_utils/find_test_subject'; + +// brace/ace uses the Worker class, which is not currently provided by JSDOM. +// This is not required for the tests to pass, but it rather suppresses lengthy +// warnings in the console which adds unnecessary noise to the test output. +import 'test_utils/stub_web_worker'; +import { AllRule, FieldRule } from '../../../model'; +import { EuiErrorBoundary } from '@elastic/eui'; + +describe('RuleEditorPanel', () => { + it('renders the visual editor when no rules are defined', () => { + const props = { + rawRules: {}, + onChange: jest.fn(), + onValidityChange: jest.fn(), + validateForm: false, + }; + const wrapper = mountWithIntl(); + expect(wrapper.find(VisualRuleEditor)).toHaveLength(1); + expect(wrapper.find(JSONRuleEditor)).toHaveLength(0); + }); + + it('allows switching to the JSON editor, carrying over rules', () => { + const props = { + rawRules: { + all: [ + { + field: { + username: ['*'], + }, + }, + ], + }, + onChange: jest.fn(), + onValidityChange: jest.fn(), + validateForm: false, + }; + const wrapper = mountWithIntl(); + expect(wrapper.find(VisualRuleEditor)).toHaveLength(1); + expect(wrapper.find(JSONRuleEditor)).toHaveLength(0); + + findTestSubject(wrapper, 'roleMappingsJSONRuleEditorButton').simulate('click'); + + expect(wrapper.find(VisualRuleEditor)).toHaveLength(0); + + const jsonEditor = wrapper.find(JSONRuleEditor); + expect(jsonEditor).toHaveLength(1); + const { rules } = jsonEditor.props(); + expect(rules!.toRaw()).toEqual(props.rawRules); + }); + + it('allows switching to the visual editor, carrying over rules', () => { + const props = { + rawRules: { + field: { username: '*' }, + }, + onChange: jest.fn(), + onValidityChange: jest.fn(), + validateForm: false, + }; + const wrapper = mountWithIntl(); + + findTestSubject(wrapper, 'roleMappingsJSONRuleEditorButton').simulate('click'); + + expect(wrapper.find(VisualRuleEditor)).toHaveLength(0); + expect(wrapper.find(JSONRuleEditor)).toHaveLength(1); + + const jsonEditor = wrapper.find(JSONRuleEditor); + expect(jsonEditor).toHaveLength(1); + const { rules: initialRules, onChange } = jsonEditor.props(); + expect(initialRules?.toRaw()).toEqual({ + field: { username: '*' }, + }); + + onChange(new AllRule([new FieldRule('otherRule', 12)])); + + findTestSubject(wrapper, 'roleMappingsVisualRuleEditorButton').simulate('click'); + + expect(wrapper.find(VisualRuleEditor)).toHaveLength(1); + expect(wrapper.find(JSONRuleEditor)).toHaveLength(0); + + expect(props.onChange).toHaveBeenCalledTimes(1); + const [rules] = props.onChange.mock.calls[0]; + expect(rules).toEqual({ + all: [{ field: { otherRule: 12 } }], + }); + }); + + it('catches errors thrown by child components', () => { + const props = { + rawRules: {}, + onChange: jest.fn(), + onValidityChange: jest.fn(), + validateForm: false, + }; + const wrapper = mountWithIntl(); + + wrapper.find(VisualRuleEditor).simulateError(new Error('Something awful happened here.')); + + expect(wrapper.find(VisualRuleEditor)).toHaveLength(0); + expect(wrapper.find(EuiErrorBoundary)).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.tsx new file mode 100644 index 0000000000000..4aab49b2b2efc --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_editor_panel.tsx @@ -0,0 +1,298 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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, { Component, Fragment } from 'react'; +import { + EuiSpacer, + EuiConfirmModal, + EuiOverlayMask, + EuiCallOut, + EuiErrorBoundary, + EuiIcon, + EuiLink, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiFormRow, + EuiPanel, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { RoleMapping } from '../../../../../../../common/model'; +import { VisualRuleEditor } from './visual_rule_editor'; +import { JSONRuleEditor } from './json_rule_editor'; +import { VISUAL_MAX_RULE_DEPTH } from '../../services/role_mapping_constants'; +import { Rule, generateRulesFromRaw } from '../../../model'; +import { validateRoleMappingRules } from '../../services/role_mapping_validation'; +import { documentationLinks } from '../../../services/documentation_links'; + +interface Props { + rawRules: RoleMapping['rules']; + onChange: (rawRules: RoleMapping['rules']) => void; + onValidityChange: (isValid: boolean) => void; + validateForm: boolean; +} + +interface State { + rules: Rule | null; + maxDepth: number; + isRuleValid: boolean; + showConfirmModeChange: boolean; + showVisualEditorDisabledAlert: boolean; + mode: 'visual' | 'json'; +} + +export class RuleEditorPanel extends Component { + constructor(props: Props) { + super(props); + this.state = { + ...this.initializeFromRawRules(props.rawRules), + isRuleValid: true, + showConfirmModeChange: false, + showVisualEditorDisabledAlert: false, + }; + } + + public render() { + const validationResult = + this.props.validateForm && + validateRoleMappingRules({ rules: this.state.rules ? this.state.rules.toRaw() : {} }); + + let validationWarning = null; + if (validationResult && validationResult.error) { + validationWarning = ( + + + + ); + } + + return ( + + +

+ +

+
+ + + +

+ + + + ), + }} + /> +

+
+
+ + + + + {validationWarning} + {this.getEditor()} + + {this.getModeToggle()} + {this.getConfirmModeChangePrompt()} + + + + +
+
+ ); + } + + private initializeFromRawRules = (rawRules: Props['rawRules']) => { + const { rules, maxDepth } = generateRulesFromRaw(rawRules); + const mode: State['mode'] = maxDepth >= VISUAL_MAX_RULE_DEPTH ? 'json' : 'visual'; + return { + rules, + mode, + maxDepth, + }; + }; + + private getModeToggle() { + if (this.state.mode === 'json' && this.state.maxDepth > VISUAL_MAX_RULE_DEPTH) { + return ( + + + + ); + } + + // Don't offer swith if no rules are present yet + if (this.state.mode === 'visual' && this.state.rules === null) { + return null; + } + + switch (this.state.mode) { + case 'visual': + return ( + { + this.trySwitchEditorMode('json'); + }} + > + + {' '} + + + + ); + case 'json': + return ( + { + this.trySwitchEditorMode('visual'); + }} + > + + {' '} + + + + ); + default: + throw new Error(`Unexpected rule editor mode: ${this.state.mode}`); + } + } + + private getEditor() { + switch (this.state.mode) { + case 'visual': + return ( + this.trySwitchEditorMode('json')} + /> + ); + case 'json': + return ( + + ); + default: + throw new Error(`Unexpected rule editor mode: ${this.state.mode}`); + } + } + + private getConfirmModeChangePrompt = () => { + if (!this.state.showConfirmModeChange) { + return null; + } + return ( + + + } + onCancel={() => this.setState({ showConfirmModeChange: false })} + onConfirm={() => { + this.setState({ mode: 'visual', showConfirmModeChange: false }); + this.onValidityChange(true); + }} + cancelButtonText={ + + } + confirmButtonText={ + + } + > +

+ +

+
+
+ ); + }; + + private onRuleChange = (updatedRule: Rule | null) => { + const raw = updatedRule ? updatedRule.toRaw() : {}; + this.props.onChange(raw); + this.setState({ + ...generateRulesFromRaw(raw), + }); + }; + + private onValidityChange = (isRuleValid: boolean) => { + this.setState({ isRuleValid }); + this.props.onValidityChange(isRuleValid); + }; + + private trySwitchEditorMode = (newMode: State['mode']) => { + switch (newMode) { + case 'visual': { + if (this.state.isRuleValid) { + this.setState({ mode: newMode }); + this.onValidityChange(true); + } else { + this.setState({ showConfirmModeChange: true }); + } + break; + } + case 'json': + this.setState({ mode: newMode }); + this.onValidityChange(true); + break; + default: + throw new Error(`Unexpected rule editor mode: ${this.state.mode}`); + } + }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.test.tsx new file mode 100644 index 0000000000000..3e0e0e386e98c --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.test.tsx @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { RuleGroupEditor } from './rule_group_editor'; +import { shallowWithIntl, mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { AllRule, FieldRule, AnyRule, ExceptAnyRule } from '../../../model'; +import { FieldRuleEditor } from './field_rule_editor'; +import { AddRuleButton } from './add_rule_button'; +import { EuiContextMenuItem } from '@elastic/eui'; +import { findTestSubject } from 'test_utils/find_test_subject'; + +describe('RuleGroupEditor', () => { + it('renders an empty group', () => { + const props = { + rule: new AllRule([]), + allowAdd: true, + ruleDepth: 0, + onChange: jest.fn(), + onDelete: jest.fn(), + }; + const wrapper = shallowWithIntl(); + expect(wrapper.find(RuleGroupEditor)).toHaveLength(0); + expect(wrapper.find(FieldRuleEditor)).toHaveLength(0); + expect(wrapper.find(AddRuleButton)).toHaveLength(1); + }); + + it('allows the group type to be changed, maintaining child rules', async () => { + const props = { + rule: new AllRule([new FieldRule('username', '*')]), + allowAdd: true, + ruleDepth: 0, + onChange: jest.fn(), + onDelete: jest.fn(), + }; + const wrapper = mountWithIntl(); + expect(wrapper.find(RuleGroupEditor)).toHaveLength(1); + expect(wrapper.find(FieldRuleEditor)).toHaveLength(1); + expect(wrapper.find(AddRuleButton)).toHaveLength(1); + expect(findTestSubject(wrapper, 'deleteRuleGroupButton')).toHaveLength(1); + + const anyRule = new AnyRule(); + + findTestSubject(wrapper, 'ruleGroupTitle').simulate('click'); + await nextTick(); + wrapper.update(); + + const anyRuleOption = wrapper.find(EuiContextMenuItem).filterWhere(menuItem => { + return menuItem.text() === anyRule.getDisplayTitle(); + }); + + anyRuleOption.simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(1); + const [newRule] = props.onChange.mock.calls[0]; + expect(newRule).toBeInstanceOf(AnyRule); + expect(newRule.toRaw()).toEqual(new AnyRule([new FieldRule('username', '*')]).toRaw()); + }); + + it('warns when changing group types which would invalidate child rules', async () => { + const props = { + rule: new AllRule([new ExceptAnyRule([new FieldRule('my_custom_field', 'foo*')])]), + allowAdd: true, + ruleDepth: 0, + onChange: jest.fn(), + onDelete: jest.fn(), + }; + const wrapper = mountWithIntl(); + expect(wrapper.find(RuleGroupEditor)).toHaveLength(2); + expect(wrapper.find(FieldRuleEditor)).toHaveLength(1); + expect(wrapper.find(AddRuleButton)).toHaveLength(2); + expect(findTestSubject(wrapper, 'deleteRuleGroupButton')).toHaveLength(2); + + const anyRule = new AnyRule(); + + findTestSubject(wrapper, 'ruleGroupTitle') + .first() + .simulate('click'); + await nextTick(); + wrapper.update(); + + const anyRuleOption = wrapper.find(EuiContextMenuItem).filterWhere(menuItem => { + return menuItem.text() === anyRule.getDisplayTitle(); + }); + + anyRuleOption.simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(0); + expect(findTestSubject(wrapper, 'confirmRuleChangeModal')).toHaveLength(1); + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(1); + const [newRule] = props.onChange.mock.calls[0]; + expect(newRule).toBeInstanceOf(AnyRule); + + // new rule should a defaulted field sub rule, as the existing rules are not valid for the new type + expect(newRule.toRaw()).toEqual(new AnyRule([new FieldRule('username', '*')]).toRaw()); + }); + + it('does not change groups when canceling the confirmation', async () => { + const props = { + rule: new AllRule([new ExceptAnyRule([new FieldRule('username', '*')])]), + allowAdd: true, + ruleDepth: 0, + onChange: jest.fn(), + onDelete: jest.fn(), + }; + const wrapper = mountWithIntl(); + expect(wrapper.find(RuleGroupEditor)).toHaveLength(2); + expect(wrapper.find(FieldRuleEditor)).toHaveLength(1); + expect(wrapper.find(AddRuleButton)).toHaveLength(2); + expect(findTestSubject(wrapper, 'deleteRuleGroupButton')).toHaveLength(2); + + const anyRule = new AnyRule(); + + findTestSubject(wrapper, 'ruleGroupTitle') + .first() + .simulate('click'); + await nextTick(); + wrapper.update(); + + const anyRuleOption = wrapper.find(EuiContextMenuItem).filterWhere(menuItem => { + return menuItem.text() === anyRule.getDisplayTitle(); + }); + + anyRuleOption.simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(0); + expect(findTestSubject(wrapper, 'confirmRuleChangeModal')).toHaveLength(1); + findTestSubject(wrapper, 'confirmModalCancelButton').simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(0); + }); + + it('hides the add rule button when instructed to', () => { + const props = { + rule: new AllRule([]), + allowAdd: false, + ruleDepth: 0, + onChange: jest.fn(), + onDelete: jest.fn(), + }; + const wrapper = shallowWithIntl(); + expect(wrapper.find(AddRuleButton)).toHaveLength(0); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.tsx new file mode 100644 index 0000000000000..6fb33db179e8a --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_editor.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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, { Component, Fragment } from 'react'; +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiButtonEmpty, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AddRuleButton } from './add_rule_button'; +import { RuleGroupTitle } from './rule_group_title'; +import { FieldRuleEditor } from './field_rule_editor'; +import { RuleGroup, Rule, FieldRule } from '../../../model'; +import { isRuleGroup } from '../../services/is_rule_group'; + +interface Props { + rule: RuleGroup; + allowAdd: boolean; + parentRule?: RuleGroup; + ruleDepth: number; + onChange: (rule: RuleGroup) => void; + onDelete: () => void; +} +export class RuleGroupEditor extends Component { + public render() { + return ( + + + + + + + + + + + + + + + {this.renderSubRules()} + {this.props.allowAdd && ( + + + + )} + + + ); + } + + private renderSubRules = () => { + return this.props.rule.getRules().map((subRule, subRuleIndex, rules) => { + const isLastRule = subRuleIndex === rules.length - 1; + const divider = isLastRule ? null : ( + + + + ); + + if (isRuleGroup(subRule)) { + return ( + + + { + const updatedRule = this.props.rule.clone() as RuleGroup; + updatedRule.replaceRule(subRuleIndex, updatedSubRule); + this.props.onChange(updatedRule); + }} + onDelete={() => { + const updatedRule = this.props.rule.clone() as RuleGroup; + updatedRule.removeRule(subRuleIndex); + this.props.onChange(updatedRule); + }} + /> + + {divider} + + ); + } + + return ( + + + { + const updatedRule = this.props.rule.clone() as RuleGroup; + updatedRule.replaceRule(subRuleIndex, updatedSubRule); + this.props.onChange(updatedRule); + }} + onDelete={() => { + const updatedRule = this.props.rule.clone() as RuleGroup; + updatedRule.removeRule(subRuleIndex); + this.props.onChange(updatedRule); + }} + /> + + {divider} + + ); + }); + }; + + private onAddRuleClick = (newRule: Rule) => { + const updatedRule = this.props.rule.clone() as RuleGroup; + updatedRule.addRule(newRule); + this.props.onChange(updatedRule); + }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_title.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_title.tsx new file mode 100644 index 0000000000000..e46893afd4d86 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/rule_group_title.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { + EuiPopover, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiLink, + EuiIcon, + EuiOverlayMask, + EuiConfirmModal, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + RuleGroup, + AllRule, + AnyRule, + ExceptAllRule, + ExceptAnyRule, + FieldRule, +} from '../../../model'; + +interface Props { + rule: RuleGroup; + readonly?: boolean; + parentRule?: RuleGroup; + onChange: (rule: RuleGroup) => void; +} + +const rules = [new AllRule(), new AnyRule()]; +const exceptRules = [new ExceptAllRule(), new ExceptAnyRule()]; + +export const RuleGroupTitle = (props: Props) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const [showConfirmChangeModal, setShowConfirmChangeModal] = useState(false); + const [pendingNewRule, setPendingNewRule] = useState(null); + + const canUseExcept = props.parentRule && props.parentRule.canContainRules(exceptRules); + + const availableRuleTypes = [...rules, ...(canUseExcept ? exceptRules : [])]; + + const onChange = (newRule: RuleGroup) => { + const currentSubRules = props.rule.getRules(); + const areSubRulesValid = newRule.canContainRules(currentSubRules); + if (areSubRulesValid) { + const clone = newRule.clone() as RuleGroup; + currentSubRules.forEach(subRule => clone.addRule(subRule)); + + props.onChange(clone); + setIsMenuOpen(false); + } else { + setPendingNewRule(newRule); + setShowConfirmChangeModal(true); + } + }; + + const changeRuleDiscardingSubRules = (newRule: RuleGroup) => { + // Ensure a default sub rule is present when not carrying over the original sub rules + const newRuleInstance = newRule.clone() as RuleGroup; + if (newRuleInstance.getRules().length === 0) { + newRuleInstance.addRule(new FieldRule('username', '*')); + } + + props.onChange(newRuleInstance); + setIsMenuOpen(false); + }; + + const ruleButton = ( + setIsMenuOpen(!isMenuOpen)} data-test-subj="ruleGroupTitle"> + {props.rule.getDisplayTitle()} + + ); + + const ruleTypeSelector = ( + setIsMenuOpen(false)}> + { + const isSelected = rt.getDisplayTitle() === props.rule.getDisplayTitle(); + const icon = isSelected ? 'check' : 'empty'; + return ( + onChange(rt as RuleGroup)}> + {rt.getDisplayTitle()} + + ); + })} + /> + + ); + + const confirmChangeModal = showConfirmChangeModal ? ( + + + } + onCancel={() => { + setShowConfirmChangeModal(false); + setPendingNewRule(null); + }} + onConfirm={() => { + setShowConfirmChangeModal(false); + changeRuleDiscardingSubRules(pendingNewRule!); + setPendingNewRule(null); + }} + cancelButtonText={ + + } + confirmButtonText={ + + } + > +

+ +

+
+
+ ) : null; + + return ( +

+ {ruleTypeSelector} + {confirmChangeModal} +

+ ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.test.tsx new file mode 100644 index 0000000000000..7c63613ee1cc9 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.test.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * 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 { VisualRuleEditor } from './visual_rule_editor'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { AnyRule, AllRule, FieldRule, ExceptAnyRule, ExceptAllRule } from '../../../model'; +import { RuleGroupEditor } from './rule_group_editor'; +import { FieldRuleEditor } from './field_rule_editor'; + +describe('VisualRuleEditor', () => { + it('renders an empty prompt when no rules are defined', () => { + const props = { + rules: null, + maxDepth: 0, + onSwitchEditorMode: jest.fn(), + onChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + + findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click'); + expect(props.onChange).toHaveBeenCalledTimes(1); + const [newRule] = props.onChange.mock.calls[0]; + expect(newRule.toRaw()).toEqual({ + all: [{ field: { username: '*' } }], + }); + }); + + it('adds a rule group when the "Add rules" button is clicked', () => { + const props = { + rules: null, + maxDepth: 0, + onSwitchEditorMode: jest.fn(), + onChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + expect(findTestSubject(wrapper, 'roleMappingsNoRulesDefined')).toHaveLength(1); + expect(findTestSubject(wrapper, 'roleMappingsRulesTooComplex')).toHaveLength(0); + }); + + it('clicking the add button when no rules are defined populates an initial rule set', () => { + const props = { + rules: null, + maxDepth: 0, + onSwitchEditorMode: jest.fn(), + onChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + findTestSubject(wrapper, 'roleMappingsAddRuleButton').simulate('click'); + + expect(props.onChange).toHaveBeenCalledTimes(1); + const [newRule] = props.onChange.mock.calls[0]; + expect(newRule).toBeInstanceOf(AllRule); + expect(newRule.toRaw()).toEqual({ + all: [ + { + field: { + username: '*', + }, + }, + ], + }); + }); + + it('renders a nested rule set', () => { + const props = { + rules: new AllRule([ + new AnyRule([new FieldRule('username', '*')]), + new ExceptAnyRule([ + new FieldRule('metadata.foo.bar', '*'), + new AllRule([new FieldRule('realm', 'special-one')]), + ]), + new ExceptAllRule([new FieldRule('realm', '*')]), + ]), + maxDepth: 4, + onSwitchEditorMode: jest.fn(), + onChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + + expect(wrapper.find(RuleGroupEditor)).toHaveLength(5); + expect(wrapper.find(FieldRuleEditor)).toHaveLength(4); + expect(findTestSubject(wrapper, 'roleMappingsRulesTooComplex')).toHaveLength(0); + }); + + it('warns when the rule set is too complex', () => { + const props = { + rules: new AllRule([ + new AnyRule([ + new AllRule([ + new AnyRule([ + new AllRule([ + new AnyRule([ + new AllRule([ + new AnyRule([ + new AllRule([ + new AnyRule([ + new AllRule([ + new AnyRule([ + new AnyRule([ + new AllRule([new AnyRule([new FieldRule('username', '*')])]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + maxDepth: 11, + onSwitchEditorMode: jest.fn(), + onChange: jest.fn(), + }; + const wrapper = mountWithIntl(); + expect(findTestSubject(wrapper, 'roleMappingsRulesTooComplex')).toHaveLength(1); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.tsx new file mode 100644 index 0000000000000..214c583189fb8 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/components/rule_editor_panel/visual_rule_editor.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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, { Component, Fragment } from 'react'; +import { EuiEmptyPrompt, EuiCallOut, EuiSpacer, EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { FieldRuleEditor } from './field_rule_editor'; +import { RuleGroupEditor } from './rule_group_editor'; +import { VISUAL_MAX_RULE_DEPTH } from '../../services/role_mapping_constants'; +import { Rule, FieldRule, RuleGroup, AllRule } from '../../../model'; +import { isRuleGroup } from '../../services/is_rule_group'; + +interface Props { + rules: Rule | null; + maxDepth: number; + onChange: (rules: Rule | null) => void; + onSwitchEditorMode: () => void; +} + +export class VisualRuleEditor extends Component { + public render() { + if (this.props.rules) { + const rules = this.renderRule(this.props.rules, this.onRuleChange); + return ( + + {this.getRuleDepthWarning()} + {rules} + + ); + } + + return ( + + + + } + titleSize="s" + body={ +
+ +
+ } + data-test-subj="roleMappingsNoRulesDefined" + actions={ + { + this.props.onChange(new AllRule([new FieldRule('username', '*')])); + }} + > + + + } + /> + ); + } + + private canUseVisualEditor = () => this.props.maxDepth < VISUAL_MAX_RULE_DEPTH; + + private getRuleDepthWarning = () => { + if (this.canUseVisualEditor()) { + return null; + } + return ( + + + } + data-test-subj="roleMappingsRulesTooComplex" + > +

+ +

+ + + + +
+ +
+ ); + }; + + private onRuleChange = (updatedRule: Rule) => { + this.props.onChange(updatedRule); + }; + + private onRuleDelete = () => { + this.props.onChange(null); + }; + + private renderRule = (rule: Rule, onChange: (updatedRule: Rule) => void) => { + return this.getEditorForRuleType(rule, onChange); + }; + + private getEditorForRuleType(rule: Rule, onChange: (updatedRule: Rule) => void) { + if (isRuleGroup(rule)) { + return ( + onChange(value)} + onDelete={this.onRuleDelete} + /> + ); + } + return ( + onChange(value)} + onDelete={this.onRuleDelete} + /> + ); + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/edit_role_mapping.html b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/edit_role_mapping.html new file mode 100644 index 0000000000000..ca8ab9c35c49b --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/edit_role_mapping.html @@ -0,0 +1,3 @@ + +
+ diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/index.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/index.tsx new file mode 100644 index 0000000000000..b064a4dc50a22 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/index.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import routes from 'ui/routes'; +import { I18nContext } from 'ui/i18n'; +import { npSetup } from 'ui/new_platform'; +import { RoleMappingsAPI } from '../../../../lib/role_mappings_api'; +// @ts-ignore +import template from './edit_role_mapping.html'; +import { CREATE_ROLE_MAPPING_PATH } from '../../management_urls'; +import { getEditRoleMappingBreadcrumbs } from '../../breadcrumbs'; +import { EditRoleMappingPage } from './components'; + +routes.when(`${CREATE_ROLE_MAPPING_PATH}/:name?`, { + template, + k7Breadcrumbs: getEditRoleMappingBreadcrumbs, + controller($scope, $route) { + $scope.$$postDigest(() => { + const domNode = document.getElementById('editRoleMappingReactRoot'); + + const { name } = $route.current.params; + + render( + + + , + domNode + ); + + // unmount react on controller destroy + $scope.$on('$destroy', () => { + if (domNode) { + unmountComponentAtNode(domNode); + } + }); + }); + }, +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/is_rule_group.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/is_rule_group.ts new file mode 100644 index 0000000000000..60a879c6c29df --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/is_rule_group.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Rule, FieldRule } from '../../model'; + +export function isRuleGroup(rule: Rule) { + return !(rule instanceof FieldRule); +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_constants.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_constants.ts new file mode 100644 index 0000000000000..28010013c9f4f --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_constants.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 const VISUAL_MAX_RULE_DEPTH = 5; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.test.ts new file mode 100644 index 0000000000000..9614c4338b631 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { + validateRoleMappingName, + validateRoleMappingRoles, + validateRoleMappingRoleTemplates, + validateRoleMappingRules, + validateRoleMappingForSave, +} from './role_mapping_validation'; +import { RoleMapping } from '../../../../../../common/model'; + +describe('validateRoleMappingName', () => { + it('requires a value', () => { + expect(validateRoleMappingName({ name: '' } as RoleMapping)).toMatchInlineSnapshot(` + Object { + "error": "Name is required.", + "isInvalid": true, + } + `); + }); +}); + +describe('validateRoleMappingRoles', () => { + it('requires a value', () => { + expect(validateRoleMappingRoles(({ roles: [] } as unknown) as RoleMapping)) + .toMatchInlineSnapshot(` + Object { + "error": "At least one role is required.", + "isInvalid": true, + } + `); + }); +}); + +describe('validateRoleMappingRoleTemplates', () => { + it('requires a value', () => { + expect(validateRoleMappingRoleTemplates(({ role_templates: [] } as unknown) as RoleMapping)) + .toMatchInlineSnapshot(` + Object { + "error": "At least one role template is required.", + "isInvalid": true, + } + `); + }); +}); + +describe('validateRoleMappingRules', () => { + it('requires at least one rule', () => { + expect(validateRoleMappingRules({ rules: {} } as RoleMapping)).toMatchInlineSnapshot(` + Object { + "error": "At least one rule is required.", + "isInvalid": true, + } + `); + }); + + // more exhaustive testing is done in other unit tests + it('requires rules to be valid', () => { + expect(validateRoleMappingRules(({ rules: { something: [] } } as unknown) as RoleMapping)) + .toMatchInlineSnapshot(` + Object { + "error": "Unknown rule type: something.", + "isInvalid": true, + } + `); + }); +}); + +describe('validateRoleMappingForSave', () => { + it('fails if the role mapping is missing a name', () => { + expect( + validateRoleMappingForSave(({ + enabled: true, + roles: ['superuser'], + rules: { field: { username: '*' } }, + } as unknown) as RoleMapping) + ).toMatchInlineSnapshot(` + Object { + "error": "Name is required.", + "isInvalid": true, + } + `); + }); + + it('fails if the role mapping is missing rules', () => { + expect( + validateRoleMappingForSave(({ + name: 'foo', + enabled: true, + roles: ['superuser'], + rules: {}, + } as unknown) as RoleMapping) + ).toMatchInlineSnapshot(` + Object { + "error": "At least one rule is required.", + "isInvalid": true, + } + `); + }); + + it('fails if the role mapping is missing both roles and templates', () => { + expect( + validateRoleMappingForSave(({ + name: 'foo', + enabled: true, + roles: [], + role_templates: [], + rules: { field: { username: '*' } }, + } as unknown) as RoleMapping) + ).toMatchInlineSnapshot(` + Object { + "error": "At least one role is required.", + "isInvalid": true, + } + `); + }); + + it('validates a correct role mapping using role templates', () => { + expect( + validateRoleMappingForSave(({ + name: 'foo', + enabled: true, + roles: [], + role_templates: [{ template: { id: 'foo' } }], + rules: { field: { username: '*' } }, + } as unknown) as RoleMapping) + ).toMatchInlineSnapshot(` + Object { + "isInvalid": false, + } + `); + }); + + it('validates a correct role mapping using roles', () => { + expect( + validateRoleMappingForSave(({ + name: 'foo', + enabled: true, + roles: ['superuser'], + rules: { field: { username: '*' } }, + } as unknown) as RoleMapping) + ).toMatchInlineSnapshot(` + Object { + "isInvalid": false, + } + `); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.ts new file mode 100644 index 0000000000000..5916d6fd9e189 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_mapping_validation.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { RoleMapping } from '../../../../../../common/model'; +import { generateRulesFromRaw } from '../../model'; + +interface ValidationResult { + isInvalid: boolean; + error?: string; +} + +export function validateRoleMappingName({ name }: RoleMapping): ValidationResult { + if (!name) { + return invalid( + i18n.translate('xpack.security.role_mappings.validation.invalidName', { + defaultMessage: 'Name is required.', + }) + ); + } + return valid(); +} + +export function validateRoleMappingRoles({ roles }: RoleMapping): ValidationResult { + if (roles && !roles.length) { + return invalid( + i18n.translate('xpack.security.role_mappings.validation.invalidRoles', { + defaultMessage: 'At least one role is required.', + }) + ); + } + return valid(); +} + +export function validateRoleMappingRoleTemplates({ + role_templates: roleTemplates, +}: RoleMapping): ValidationResult { + if (roleTemplates && !roleTemplates.length) { + return invalid( + i18n.translate('xpack.security.role_mappings.validation.invalidRoleTemplates', { + defaultMessage: 'At least one role template is required.', + }) + ); + } + return valid(); +} + +export function validateRoleMappingRules({ rules }: Pick): ValidationResult { + try { + const { rules: parsedRules } = generateRulesFromRaw(rules); + if (!parsedRules) { + return invalid( + i18n.translate('xpack.security.role_mappings.validation.invalidRoleRule', { + defaultMessage: 'At least one rule is required.', + }) + ); + } + } catch (e) { + return invalid(e.message); + } + + return valid(); +} + +export function validateRoleMappingForSave(roleMapping: RoleMapping): ValidationResult { + const { isInvalid: isNameInvalid, error: nameError } = validateRoleMappingName(roleMapping); + const { isInvalid: areRolesInvalid, error: rolesError } = validateRoleMappingRoles(roleMapping); + const { + isInvalid: areRoleTemplatesInvalid, + error: roleTemplatesError, + } = validateRoleMappingRoleTemplates(roleMapping); + + const { isInvalid: areRulesInvalid, error: rulesError } = validateRoleMappingRules(roleMapping); + + const canSave = + !isNameInvalid && (!areRolesInvalid || !areRoleTemplatesInvalid) && !areRulesInvalid; + + if (canSave) { + return valid(); + } + return invalid(nameError || rulesError || rolesError || roleTemplatesError); +} + +function valid() { + return { isInvalid: false }; +} + +function invalid(error?: string) { + return { isInvalid: true, error }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.test.ts new file mode 100644 index 0000000000000..8e1f47a4157ae --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + isStoredRoleTemplate, + isInlineRoleTemplate, + isInvalidRoleTemplate, +} from './role_template_type'; +import { RoleTemplate } from '../../../../../../common/model'; + +describe('#isStoredRoleTemplate', () => { + it('returns true for stored templates, false otherwise', () => { + expect(isStoredRoleTemplate({ template: { id: '' } })).toEqual(true); + expect(isStoredRoleTemplate({ template: { source: '' } })).toEqual(false); + expect(isStoredRoleTemplate({ template: 'asdf' })).toEqual(false); + expect(isStoredRoleTemplate({} as RoleTemplate)).toEqual(false); + }); +}); + +describe('#isInlineRoleTemplate', () => { + it('returns true for inline templates, false otherwise', () => { + expect(isInlineRoleTemplate({ template: { source: '' } })).toEqual(true); + expect(isInlineRoleTemplate({ template: { id: '' } })).toEqual(false); + expect(isInlineRoleTemplate({ template: 'asdf' })).toEqual(false); + expect(isInlineRoleTemplate({} as RoleTemplate)).toEqual(false); + }); +}); + +describe('#isInvalidRoleTemplate', () => { + it('returns true for invalid templates, false otherwise', () => { + expect(isInvalidRoleTemplate({ template: 'asdf' })).toEqual(true); + expect(isInvalidRoleTemplate({} as RoleTemplate)).toEqual(true); + expect(isInvalidRoleTemplate({ template: { source: '' } })).toEqual(false); + expect(isInvalidRoleTemplate({ template: { id: '' } })).toEqual(false); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.ts new file mode 100644 index 0000000000000..90d8d1a09e587 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/edit_role_mapping/services/role_template_type.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 { + RoleTemplate, + StoredRoleTemplate, + InlineRoleTemplate, + InvalidRoleTemplate, +} from '../../../../../../common/model'; + +export function isStoredRoleTemplate( + roleMappingTemplate: RoleTemplate +): roleMappingTemplate is StoredRoleTemplate { + return ( + roleMappingTemplate.template != null && + roleMappingTemplate.template.hasOwnProperty('id') && + typeof ((roleMappingTemplate as unknown) as StoredRoleTemplate).template.id === 'string' + ); +} + +export function isInlineRoleTemplate( + roleMappingTemplate: RoleTemplate +): roleMappingTemplate is InlineRoleTemplate { + return ( + roleMappingTemplate.template != null && + roleMappingTemplate.template.hasOwnProperty('source') && + typeof ((roleMappingTemplate as unknown) as InlineRoleTemplate).template.source === 'string' + ); +} + +export function isInvalidRoleTemplate( + roleMappingTemplate: RoleTemplate +): roleMappingTemplate is InvalidRoleTemplate { + return !isStoredRoleTemplate(roleMappingTemplate) && !isInlineRoleTemplate(roleMappingTemplate); +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/__snapshots__/rule_builder.test.ts.snap b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/__snapshots__/rule_builder.test.ts.snap new file mode 100644 index 0000000000000..1c61383b951ae --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/__snapshots__/rule_builder.test.ts.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateRulesFromRaw "field" does not support a value of () => null 1`] = `"Invalid value type for field. Expected one of null, string, number, or boolean, but found function ()."`; + +exports[`generateRulesFromRaw "field" does not support a value of [object Object] 1`] = `"Invalid value type for field. Expected one of null, string, number, or boolean, but found object ({})."`; + +exports[`generateRulesFromRaw "field" does not support a value of [object Object],,,() => null 1`] = `"Invalid value type for field. Expected one of null, string, number, or boolean, but found object ([{},null,[],null])."`; + +exports[`generateRulesFromRaw "field" does not support a value of undefined 1`] = `"Invalid value type for field. Expected one of null, string, number, or boolean, but found undefined ()."`; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.test.ts new file mode 100644 index 0000000000000..ddf3b4499f73b --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.test.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 { AllRule, AnyRule, FieldRule, ExceptAllRule, ExceptAnyRule, RuleGroup } from '.'; + +describe('All rule', () => { + it('can be constructed without sub rules', () => { + const rule = new AllRule(); + expect(rule.getRules()).toHaveLength(0); + }); + + it('can be constructed with sub rules', () => { + const rule = new AllRule([new AnyRule()]); + expect(rule.getRules()).toHaveLength(1); + }); + + it('can accept rules of any type', () => { + const subRules = [ + new AllRule(), + new AnyRule(), + new FieldRule('username', '*'), + new ExceptAllRule(), + new ExceptAnyRule(), + ]; + + const rule = new AllRule() as RuleGroup; + expect(rule.canContainRules(subRules)).toEqual(true); + subRules.forEach(sr => rule.addRule(sr)); + expect(rule.getRules()).toEqual([...subRules]); + }); + + it('can replace an existing rule', () => { + const rule = new AllRule([new AnyRule()]); + const newRule = new FieldRule('username', '*'); + rule.replaceRule(0, newRule); + expect(rule.getRules()).toEqual([newRule]); + }); + + it('can remove an existing rule', () => { + const rule = new AllRule([new AnyRule()]); + rule.removeRule(0); + expect(rule.getRules()).toHaveLength(0); + }); + + it('can covert itself into a raw representation', () => { + const rule = new AllRule([new AnyRule()]); + expect(rule.toRaw()).toEqual({ + all: [{ any: [] }], + }); + }); + + it('can clone itself', () => { + const subRules = [new AnyRule()]; + const rule = new AllRule(subRules); + const clone = rule.clone(); + + expect(clone.toRaw()).toEqual(rule.toRaw()); + expect(clone.getRules()).toEqual(rule.getRules()); + expect(clone.getRules()).not.toBe(rule.getRules()); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.ts new file mode 100644 index 0000000000000..ecea27a7fb87f --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/all_rule.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { RuleGroup } from './rule_group'; +import { Rule } from './rule'; + +/** + * Represents a group of rules which must all evaluate to true. + */ +export class AllRule extends RuleGroup { + constructor(private rules: Rule[] = []) { + super(); + } + + /** {@see RuleGroup.getRules} */ + public getRules() { + return [...this.rules]; + } + + /** {@see RuleGroup.getDisplayTitle} */ + public getDisplayTitle() { + return i18n.translate('xpack.security.management.editRoleMapping.allRule.displayTitle', { + defaultMessage: 'All are true', + }); + } + + /** {@see RuleGroup.replaceRule} */ + public replaceRule(ruleIndex: number, rule: Rule) { + this.rules.splice(ruleIndex, 1, rule); + } + + /** {@see RuleGroup.removeRule} */ + public removeRule(ruleIndex: number) { + this.rules.splice(ruleIndex, 1); + } + + /** {@see RuleGroup.addRule} */ + public addRule(rule: Rule) { + this.rules.push(rule); + } + + /** {@see RuleGroup.canContainRules} */ + public canContainRules() { + return true; + } + + /** {@see RuleGroup.clone} */ + public clone() { + return new AllRule(this.rules.map(r => r.clone())); + } + + /** {@see RuleGroup.toRaw} */ + public toRaw() { + return { + all: [...this.rules.map(rule => rule.toRaw())], + }; + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.test.ts new file mode 100644 index 0000000000000..767aa075755af --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { AllRule, AnyRule, FieldRule, ExceptAllRule, ExceptAnyRule, RuleGroup } from '.'; + +describe('Any rule', () => { + it('can be constructed without sub rules', () => { + const rule = new AnyRule(); + expect(rule.getRules()).toHaveLength(0); + }); + + it('can be constructed with sub rules', () => { + const rule = new AnyRule([new AllRule()]); + expect(rule.getRules()).toHaveLength(1); + }); + + it('can accept non-except rules', () => { + const subRules = [new AllRule(), new AnyRule(), new FieldRule('username', '*')]; + + const rule = new AnyRule() as RuleGroup; + expect(rule.canContainRules(subRules)).toEqual(true); + subRules.forEach(sr => rule.addRule(sr)); + expect(rule.getRules()).toEqual([...subRules]); + }); + + it('cannot accept except rules', () => { + const subRules = [new ExceptAllRule(), new ExceptAnyRule()]; + + const rule = new AnyRule() as RuleGroup; + expect(rule.canContainRules(subRules)).toEqual(false); + }); + + it('can replace an existing rule', () => { + const rule = new AnyRule([new AllRule()]); + const newRule = new FieldRule('username', '*'); + rule.replaceRule(0, newRule); + expect(rule.getRules()).toEqual([newRule]); + }); + + it('can remove an existing rule', () => { + const rule = new AnyRule([new AllRule()]); + rule.removeRule(0); + expect(rule.getRules()).toHaveLength(0); + }); + + it('can covert itself into a raw representation', () => { + const rule = new AnyRule([new AllRule()]); + expect(rule.toRaw()).toEqual({ + any: [{ all: [] }], + }); + }); + + it('can clone itself', () => { + const subRules = [new AllRule()]; + const rule = new AnyRule(subRules); + const clone = rule.clone(); + + expect(clone.toRaw()).toEqual(rule.toRaw()); + expect(clone.getRules()).toEqual(rule.getRules()); + expect(clone.getRules()).not.toBe(rule.getRules()); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.ts new file mode 100644 index 0000000000000..6a4f2eaf1b362 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/any_rule.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 { i18n } from '@kbn/i18n'; +import { RuleGroup } from './rule_group'; +import { Rule } from './rule'; +import { ExceptAllRule } from './except_all_rule'; +import { ExceptAnyRule } from './except_any_rule'; + +/** + * Represents a group of rules in which at least one must evaluate to true. + */ +export class AnyRule extends RuleGroup { + constructor(private rules: Rule[] = []) { + super(); + } + + /** {@see RuleGroup.getRules} */ + public getRules() { + return [...this.rules]; + } + + /** {@see RuleGroup.getDisplayTitle} */ + public getDisplayTitle() { + return i18n.translate('xpack.security.management.editRoleMapping.anyRule.displayTitle', { + defaultMessage: 'Any are true', + }); + } + + /** {@see RuleGroup.replaceRule} */ + public replaceRule(ruleIndex: number, rule: Rule) { + this.rules.splice(ruleIndex, 1, rule); + } + + /** {@see RuleGroup.removeRule} */ + public removeRule(ruleIndex: number) { + this.rules.splice(ruleIndex, 1); + } + + /** {@see RuleGroup.addRule} */ + public addRule(rule: Rule) { + this.rules.push(rule); + } + + /** {@see RuleGroup.canContainRules} */ + public canContainRules(rules: Rule[]) { + const forbiddenRules = [ExceptAllRule, ExceptAnyRule]; + return rules.every( + candidate => !forbiddenRules.some(forbidden => candidate instanceof forbidden) + ); + } + + /** {@see RuleGroup.clone} */ + public clone() { + return new AnyRule(this.rules.map(r => r.clone())); + } + + /** {@see RuleGroup.toRaw} */ + public toRaw() { + return { + any: [...this.rules.map(rule => rule.toRaw())], + }; + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.test.ts new file mode 100644 index 0000000000000..7a00e5b19638f --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.test.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 { AllRule, AnyRule, FieldRule, ExceptAllRule, ExceptAnyRule, RuleGroup } from '.'; + +describe('Except All rule', () => { + it('can be constructed without sub rules', () => { + const rule = new ExceptAllRule(); + expect(rule.getRules()).toHaveLength(0); + }); + + it('can be constructed with sub rules', () => { + const rule = new ExceptAllRule([new AnyRule()]); + expect(rule.getRules()).toHaveLength(1); + }); + + it('can accept rules of any type', () => { + const subRules = [ + new AllRule(), + new AnyRule(), + new FieldRule('username', '*'), + new ExceptAllRule(), + new ExceptAnyRule(), + ]; + + const rule = new ExceptAllRule() as RuleGroup; + expect(rule.canContainRules(subRules)).toEqual(true); + subRules.forEach(sr => rule.addRule(sr)); + expect(rule.getRules()).toEqual([...subRules]); + }); + + it('can replace an existing rule', () => { + const rule = new ExceptAllRule([new AnyRule()]); + const newRule = new FieldRule('username', '*'); + rule.replaceRule(0, newRule); + expect(rule.getRules()).toEqual([newRule]); + }); + + it('can remove an existing rule', () => { + const rule = new ExceptAllRule([new AnyRule()]); + rule.removeRule(0); + expect(rule.getRules()).toHaveLength(0); + }); + + it('can covert itself into a raw representation', () => { + const rule = new ExceptAllRule([new AnyRule()]); + expect(rule.toRaw()).toEqual({ + except: { all: [{ any: [] }] }, + }); + }); + + it('can clone itself', () => { + const subRules = [new AllRule()]; + const rule = new ExceptAllRule(subRules); + const clone = rule.clone(); + + expect(clone.toRaw()).toEqual(rule.toRaw()); + expect(clone.getRules()).toEqual(rule.getRules()); + expect(clone.getRules()).not.toBe(rule.getRules()); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.ts new file mode 100644 index 0000000000000..a67c2622a2533 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_all_rule.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 { i18n } from '@kbn/i18n'; +import { RuleGroup } from './rule_group'; +import { Rule } from './rule'; + +/** + * Represents a group of rules in which at least one must evaluate to false. + */ +export class ExceptAllRule extends RuleGroup { + constructor(private rules: Rule[] = []) { + super(); + } + + /** {@see RuleGroup.getRules} */ + public getRules() { + return [...this.rules]; + } + + /** {@see RuleGroup.getDisplayTitle} */ + public getDisplayTitle() { + return i18n.translate('xpack.security.management.editRoleMapping.exceptAllRule.displayTitle', { + defaultMessage: 'Any are false', + }); + } + + /** {@see RuleGroup.replaceRule} */ + public replaceRule(ruleIndex: number, rule: Rule) { + this.rules.splice(ruleIndex, 1, rule); + } + + /** {@see RuleGroup.removeRule} */ + public removeRule(ruleIndex: number) { + this.rules.splice(ruleIndex, 1); + } + + /** {@see RuleGroup.addRule} */ + public addRule(rule: Rule) { + this.rules.push(rule); + } + + /** {@see RuleGroup.canContainRules} */ + public canContainRules() { + return true; + } + + /** {@see RuleGroup.clone} */ + public clone() { + return new ExceptAllRule(this.rules.map(r => r.clone())); + } + + /** {@see RuleGroup.toRaw} */ + public toRaw() { + const rawRule = { + all: [...this.rules.map(rule => rule.toRaw())], + }; + + return { + except: rawRule, + }; + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.test.ts new file mode 100644 index 0000000000000..e4e182ce88d8d --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { AllRule, AnyRule, FieldRule, ExceptAllRule, ExceptAnyRule, RuleGroup } from '.'; + +describe('Except Any rule', () => { + it('can be constructed without sub rules', () => { + const rule = new ExceptAnyRule(); + expect(rule.getRules()).toHaveLength(0); + }); + + it('can be constructed with sub rules', () => { + const rule = new ExceptAnyRule([new AllRule()]); + expect(rule.getRules()).toHaveLength(1); + }); + + it('can accept non-except rules', () => { + const subRules = [new AllRule(), new AnyRule(), new FieldRule('username', '*')]; + + const rule = new ExceptAnyRule() as RuleGroup; + expect(rule.canContainRules(subRules)).toEqual(true); + subRules.forEach(sr => rule.addRule(sr)); + expect(rule.getRules()).toEqual([...subRules]); + }); + + it('cannot accept except rules', () => { + const subRules = [new ExceptAllRule(), new ExceptAnyRule()]; + + const rule = new ExceptAnyRule() as RuleGroup; + expect(rule.canContainRules(subRules)).toEqual(false); + }); + + it('can replace an existing rule', () => { + const rule = new ExceptAnyRule([new AllRule()]); + const newRule = new FieldRule('username', '*'); + rule.replaceRule(0, newRule); + expect(rule.getRules()).toEqual([newRule]); + }); + + it('can remove an existing rule', () => { + const rule = new ExceptAnyRule([new AllRule()]); + rule.removeRule(0); + expect(rule.getRules()).toHaveLength(0); + }); + + it('can covert itself into a raw representation', () => { + const rule = new ExceptAnyRule([new AllRule()]); + expect(rule.toRaw()).toEqual({ + except: { any: [{ all: [] }] }, + }); + }); + + it('can clone itself', () => { + const subRules = [new AllRule()]; + const rule = new ExceptAnyRule(subRules); + const clone = rule.clone(); + + expect(clone.toRaw()).toEqual(rule.toRaw()); + expect(clone.getRules()).toEqual(rule.getRules()); + expect(clone.getRules()).not.toBe(rule.getRules()); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.ts new file mode 100644 index 0000000000000..12ec3fe85b80d --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/except_any_rule.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { RuleGroup } from './rule_group'; +import { Rule } from './rule'; +import { ExceptAllRule } from './except_all_rule'; + +/** + * Represents a group of rules in which none can evaluate to true (all must evaluate to false). + */ +export class ExceptAnyRule extends RuleGroup { + constructor(private rules: Rule[] = []) { + super(); + } + + /** {@see RuleGroup.getRules} */ + public getRules() { + return [...this.rules]; + } + + /** {@see RuleGroup.getDisplayTitle} */ + public getDisplayTitle() { + return i18n.translate('xpack.security.management.editRoleMapping.exceptAnyRule.displayTitle', { + defaultMessage: 'All are false', + }); + } + + /** {@see RuleGroup.replaceRule} */ + public replaceRule(ruleIndex: number, rule: Rule) { + this.rules.splice(ruleIndex, 1, rule); + } + + /** {@see RuleGroup.removeRule} */ + public removeRule(ruleIndex: number) { + this.rules.splice(ruleIndex, 1); + } + + /** {@see RuleGroup.addRule} */ + public addRule(rule: Rule) { + this.rules.push(rule); + } + + /** {@see RuleGroup.canContainRules} */ + public canContainRules(rules: Rule[]) { + const forbiddenRules = [ExceptAllRule, ExceptAnyRule]; + return rules.every( + candidate => !forbiddenRules.some(forbidden => candidate instanceof forbidden) + ); + } + + /** {@see RuleGroup.clone} */ + public clone() { + return new ExceptAnyRule(this.rules.map(r => r.clone())); + } + + /** {@see RuleGroup.toRaw} */ + public toRaw() { + const rawRule = { + any: [...this.rules.map(rule => rule.toRaw())], + }; + + return { + except: rawRule, + }; + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.test.ts new file mode 100644 index 0000000000000..3447e81707002 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FieldRule } from '.'; + +describe('FieldRule', () => { + ['*', 1, null, true, false].forEach(value => { + it(`can convert itself to raw form with a single value of ${value}`, () => { + const rule = new FieldRule('username', value); + expect(rule.toRaw()).toEqual({ + field: { + username: value, + }, + }); + }); + }); + + it('can convert itself to raw form with an array of values', () => { + const values = ['*', 1, null, true, false]; + const rule = new FieldRule('username', values); + const raw = rule.toRaw(); + expect(raw).toEqual({ + field: { + username: ['*', 1, null, true, false], + }, + }); + + // shoud not be the same array instance + expect(raw.field.username).not.toBe(values); + }); + + it('can clone itself', () => { + const values = ['*', 1, null]; + const rule = new FieldRule('username', values); + + const clone = rule.clone(); + expect(clone.field).toEqual(rule.field); + expect(clone.value).toEqual(rule.value); + expect(clone.value).not.toBe(rule.value); + expect(clone.toRaw()).toEqual(rule.toRaw()); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.ts new file mode 100644 index 0000000000000..3e6a0e1e7ecb3 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/field_rule.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 { Rule } from './rule'; + +/** The allowed types for field rule values */ +export type FieldRuleValue = + | string + | number + | null + | boolean + | Array; + +/** + * Represents a single field rule. + * Ex: "username = 'foo'" + */ +export class FieldRule extends Rule { + constructor(public readonly field: string, public readonly value: FieldRuleValue) { + super(); + } + + /** {@see Rule.getDisplayTitle} */ + public getDisplayTitle() { + return i18n.translate('xpack.security.management.editRoleMapping.fieldRule.displayTitle', { + defaultMessage: 'The following is true', + }); + } + + /** {@see Rule.clone} */ + public clone() { + return new FieldRule(this.field, Array.isArray(this.value) ? [...this.value] : this.value); + } + + /** {@see Rule.toRaw} */ + public toRaw() { + return { + field: { + [this.field]: Array.isArray(this.value) ? [...this.value] : this.value, + }, + }; + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/index.ts new file mode 100644 index 0000000000000..cbc970f02b03e --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AllRule } from './all_rule'; +export { AnyRule } from './any_rule'; +export { Rule } from './rule'; +export { RuleGroup } from './rule_group'; +export { ExceptAllRule } from './except_all_rule'; +export { ExceptAnyRule } from './except_any_rule'; +export { FieldRule, FieldRuleValue } from './field_rule'; +export { generateRulesFromRaw } from './rule_builder'; +export { RuleBuilderError } from './rule_builder_error'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule.ts new file mode 100644 index 0000000000000..5cab2f1736e94 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule.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. + */ + +/** + * Represents a Role Mapping rule. + */ +export abstract class Rule { + /** + * Converts this rule into a raw object for use in the persisted Role Mapping. + */ + abstract toRaw(): Record; + + /** + * The display title for this rule. + */ + abstract getDisplayTitle(): string; + + /** + * Returns a new instance of this rule. + */ + abstract clone(): Rule; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.test.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.test.ts new file mode 100644 index 0000000000000..ebd48f6d15d99 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.test.ts @@ -0,0 +1,343 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { generateRulesFromRaw, FieldRule } from '.'; +import { RoleMapping } from '../../../../../common/model'; +import { RuleBuilderError } from './rule_builder_error'; + +describe('generateRulesFromRaw', () => { + it('returns null for an empty rule set', () => { + expect(generateRulesFromRaw({})).toEqual({ + rules: null, + maxDepth: 0, + }); + }); + + it('returns a correctly parsed rule set', () => { + const rawRules: RoleMapping['rules'] = { + all: [ + { + except: { + all: [ + { + field: { username: '*' }, + }, + ], + }, + }, + { + any: [ + { + field: { dn: '*' }, + }, + ], + }, + ], + }; + + const { rules, maxDepth } = generateRulesFromRaw(rawRules); + + expect(rules).toMatchInlineSnapshot(` + AllRule { + "rules": Array [ + ExceptAllRule { + "rules": Array [ + FieldRule { + "field": "username", + "value": "*", + }, + ], + }, + AnyRule { + "rules": Array [ + FieldRule { + "field": "dn", + "value": "*", + }, + ], + }, + ], + } + `); + expect(maxDepth).toEqual(3); + }); + + it('does not support multiple rules at the root level', () => { + expect(() => { + generateRulesFromRaw({ + all: [ + { + field: { username: '*' }, + }, + ], + any: [ + { + field: { username: '*' }, + }, + ], + }); + }).toThrowError('Expected a single rule definition, but found 2.'); + }); + + it('provides a rule trace describing the location of the error', () => { + try { + generateRulesFromRaw({ + all: [ + { + field: { username: '*' }, + }, + { + any: [ + { + field: { username: '*' }, + }, + { + except: { field: { username: '*' } }, + }, + ], + }, + ], + }); + throw new Error(`Expected generateRulesFromRaw to throw error.`); + } catch (e) { + if (e instanceof RuleBuilderError) { + expect(e.message).toEqual(`"except" rule can only exist within an "all" rule.`); + expect(e.ruleTrace).toEqual(['all', '[1]', 'any', '[1]', 'except']); + } else { + throw e; + } + } + }); + + it('calculates the max depth of the rule tree', () => { + const rules = { + all: [ + // depth = 1 + { + // depth = 2 + all: [ + // depth = 3 + { + any: [ + // depth == 4 + { field: { username: 'foo' } }, + ], + }, + { except: { field: { username: 'foo' } } }, + ], + }, + { + // depth = 2 + any: [ + { + // depth = 3 + all: [ + { + // depth = 4 + any: [ + { + // depth = 5 + all: [ + { + // depth = 6 + all: [ + // depth = 7 + { + except: { + field: { username: 'foo' }, + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + expect(generateRulesFromRaw(rules).maxDepth).toEqual(7); + }); + + describe('"any"', () => { + it('expects an array value', () => { + expect(() => { + generateRulesFromRaw({ + any: { + field: { username: '*' }, + } as any, + }); + }).toThrowError('Expected an array of rules, but found object.'); + }); + + it('expects each entry to be an object with a single property', () => { + expect(() => { + generateRulesFromRaw({ + any: [ + { + any: [{ field: { foo: 'bar' } }], + all: [{ field: { foo: 'bar' } }], + } as any, + ], + }); + }).toThrowError('Expected a single rule definition, but found 2.'); + }); + }); + + describe('"all"', () => { + it('expects an array value', () => { + expect(() => { + generateRulesFromRaw({ + all: { + field: { username: '*' }, + } as any, + }); + }).toThrowError('Expected an array of rules, but found object.'); + }); + + it('expects each entry to be an object with a single property', () => { + expect(() => { + generateRulesFromRaw({ + all: [ + { + field: { username: '*' }, + any: [{ field: { foo: 'bar' } }], + } as any, + ], + }); + }).toThrowError('Expected a single rule definition, but found 2.'); + }); + }); + + describe('"field"', () => { + it(`expects an object value`, () => { + expect(() => { + generateRulesFromRaw({ + field: [ + { + username: '*', + }, + ], + }); + }).toThrowError('Expected an object, but found array.'); + }); + + it(`expects an single property in its object value`, () => { + expect(() => { + generateRulesFromRaw({ + field: { + username: '*', + dn: '*', + }, + }); + }).toThrowError('Expected a single field, but found 2.'); + }); + + it('accepts an array of possible values', () => { + const { rules } = generateRulesFromRaw({ + field: { + username: [0, '*', null, 'foo', true, false], + }, + }); + + expect(rules).toBeInstanceOf(FieldRule); + expect((rules as FieldRule).field).toEqual('username'); + expect((rules as FieldRule).value).toEqual([0, '*', null, 'foo', true, false]); + }); + + [{}, () => null, undefined, [{}, undefined, [], () => null]].forEach(invalidValue => { + it(`does not support a value of ${invalidValue}`, () => { + expect(() => { + generateRulesFromRaw({ + field: { + username: invalidValue, + }, + }); + }).toThrowErrorMatchingSnapshot(); + }); + }); + }); + + describe('"except"', () => { + it(`expects an object value`, () => { + expect(() => { + generateRulesFromRaw({ + all: [ + { + except: [ + { + field: { username: '*' }, + }, + ], + }, + ], + } as any); + }).toThrowError('Expected an object, but found array.'); + }); + + it(`can only be nested inside an "all" clause`, () => { + expect(() => { + generateRulesFromRaw({ + any: [ + { + except: { + field: { + username: '*', + }, + }, + }, + ], + }); + }).toThrowError(`"except" rule can only exist within an "all" rule.`); + + expect(() => { + generateRulesFromRaw({ + except: { + field: { + username: '*', + }, + }, + }); + }).toThrowError(`"except" rule can only exist within an "all" rule.`); + }); + + it('converts an "except field" rule into an equivilent "except all" rule', () => { + expect( + generateRulesFromRaw({ + all: [ + { + except: { + field: { + username: '*', + }, + }, + }, + ], + }) + ).toMatchInlineSnapshot(` + Object { + "maxDepth": 2, + "rules": AllRule { + "rules": Array [ + ExceptAllRule { + "rules": Array [ + FieldRule { + "field": "username", + "value": "*", + }, + ], + }, + ], + }, + } + `); + }); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.ts new file mode 100644 index 0000000000000..fe344b2ae38dd --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder.ts @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { RoleMapping } from '../../../../../common/model'; +import { FieldRule, FieldRuleValue } from './field_rule'; +import { AllRule } from './all_rule'; +import { AnyRule } from './any_rule'; +import { Rule } from './rule'; +import { ExceptAllRule } from './except_all_rule'; +import { ExceptAnyRule } from './except_any_rule'; +import { RuleBuilderError } from '.'; + +interface RuleBuilderResult { + /** The maximum rule depth within the parsed rule set. */ + maxDepth: number; + + /** The parsed rule set. */ + rules: Rule | null; +} + +/** + * Given a set of raw rules, this constructs a class based tree for consumption by the Role Management UI. + * This also performs validation on the raw rule set, as it is possible to enter raw JSON in the JSONRuleEditor, + * so we have no guarantees that the rule set is valid ahead of time. + * + * @param rawRules the raw rules to translate. + */ +export function generateRulesFromRaw(rawRules: RoleMapping['rules'] = {}): RuleBuilderResult { + return parseRawRules(rawRules, null, [], 0); +} + +function parseRawRules( + rawRules: RoleMapping['rules'], + parentRuleType: string | null, + ruleTrace: string[], + depth: number +): RuleBuilderResult { + const entries = Object.entries(rawRules); + if (!entries.length) { + return { + rules: null, + maxDepth: 0, + }; + } + if (entries.length > 1) { + throw new RuleBuilderError( + i18n.translate('xpack.security.management.editRoleMapping.ruleBuilder.expectSingleRule', { + defaultMessage: `Expected a single rule definition, but found {numberOfRules}.`, + values: { numberOfRules: entries.length }, + }), + ruleTrace + ); + } + + const rule = entries[0]; + const [ruleType, ruleDefinition] = rule; + return createRuleForType(ruleType, ruleDefinition, parentRuleType, ruleTrace, depth + 1); +} + +function createRuleForType( + ruleType: string, + ruleDefinition: any, + parentRuleType: string | null, + ruleTrace: string[] = [], + depth: number +): RuleBuilderResult { + const isRuleNegated = parentRuleType === 'except'; + + const currentRuleTrace = [...ruleTrace, ruleType]; + + switch (ruleType) { + case 'field': { + assertIsObject(ruleDefinition, currentRuleTrace); + + const entries = Object.entries(ruleDefinition); + if (entries.length !== 1) { + throw new RuleBuilderError( + i18n.translate( + 'xpack.security.management.editRoleMapping.ruleBuilder.expectedSingleFieldRule', + { + defaultMessage: `Expected a single field, but found {count}.`, + values: { count: entries.length }, + } + ), + currentRuleTrace + ); + } + + const [field, value] = entries[0] as [string, FieldRuleValue]; + const values = Array.isArray(value) ? value : [value]; + values.forEach(fieldValue => { + const valueType = typeof fieldValue; + if (fieldValue !== null && !['string', 'number', 'boolean'].includes(valueType)) { + throw new RuleBuilderError( + i18n.translate( + 'xpack.security.management.editRoleMapping.ruleBuilder.invalidFieldValueType', + { + defaultMessage: `Invalid value type for field. Expected one of null, string, number, or boolean, but found {valueType} ({value}).`, + values: { valueType, value: JSON.stringify(value) }, + } + ), + currentRuleTrace + ); + } + }); + + const fieldRule = new FieldRule(field, value); + return { + rules: isRuleNegated ? new ExceptAllRule([fieldRule]) : fieldRule, + maxDepth: depth, + }; + } + case 'any': // intentional fall-through to 'all', as validation logic is identical + case 'all': { + if (ruleDefinition != null && !Array.isArray(ruleDefinition)) { + throw new RuleBuilderError( + i18n.translate( + 'xpack.security.management.editRoleMapping.ruleBuilder.expectedArrayForGroupRule', + { + defaultMessage: `Expected an array of rules, but found {type}.`, + values: { type: typeof ruleDefinition }, + } + ), + currentRuleTrace + ); + } + + const subRulesResults = ((ruleDefinition as any[]) || []).map((definition: any, index) => + parseRawRules(definition, ruleType, [...currentRuleTrace, `[${index}]`], depth) + ) as RuleBuilderResult[]; + + const { subRules, maxDepth } = subRulesResults.reduce( + (acc, result) => { + return { + subRules: [...acc.subRules, result.rules!], + maxDepth: Math.max(acc.maxDepth, result.maxDepth), + }; + }, + { subRules: [] as Rule[], maxDepth: 0 } + ); + + if (ruleType === 'all') { + return { + rules: isRuleNegated ? new ExceptAllRule(subRules) : new AllRule(subRules), + maxDepth, + }; + } else { + return { + rules: isRuleNegated ? new ExceptAnyRule(subRules) : new AnyRule(subRules), + maxDepth, + }; + } + } + case 'except': { + assertIsObject(ruleDefinition, currentRuleTrace); + + if (parentRuleType !== 'all') { + throw new RuleBuilderError( + i18n.translate( + 'xpack.security.management.editRoleMapping.ruleBuilder.exceptOnlyInAllRule', + { + defaultMessage: `"except" rule can only exist within an "all" rule.`, + } + ), + currentRuleTrace + ); + } + // subtracting 1 from depth because we don't currently count the "except" level itself as part of the depth calculation + // for the purpose of determining if the rule set is "too complex" for the visual rule editor. + // The "except" rule MUST be nested within an "all" rule type (see validation above), so the depth itself will always be a non-negative number. + return parseRawRules(ruleDefinition || {}, ruleType, currentRuleTrace, depth - 1); + } + default: + throw new RuleBuilderError( + i18n.translate('xpack.security.management.editRoleMapping.ruleBuilder.unknownRuleType', { + defaultMessage: `Unknown rule type: {ruleType}.`, + values: { ruleType }, + }), + currentRuleTrace + ); + } +} + +function assertIsObject(ruleDefinition: any, ruleTrace: string[]) { + let fieldType: string = typeof ruleDefinition; + if (Array.isArray(ruleDefinition)) { + fieldType = 'array'; + } + + if (ruleDefinition && fieldType !== 'object') { + throw new RuleBuilderError( + i18n.translate('xpack.security.management.editRoleMapping.ruleBuilder.expectedObjectError', { + defaultMessage: `Expected an object, but found {type}.`, + values: { type: fieldType }, + }), + ruleTrace + ); + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder_error.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder_error.ts new file mode 100644 index 0000000000000..87d73cde00dd6 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_builder_error.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. + */ + +/** + * Describes an error during rule building. + * In addition to a user-"friendly" message, this also includes a rule trace, + * which is the "JSON path" where the error occurred. + */ +export class RuleBuilderError extends Error { + constructor(message: string, public readonly ruleTrace: string[]) { + super(message); + + // Set the prototype explicitly, see: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + Object.setPrototypeOf(this, RuleBuilderError.prototype); + } +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_group.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_group.ts new file mode 100644 index 0000000000000..3e1e7fad9b36f --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/model/rule_group.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. + */ + +import { Rule } from './rule'; + +/** + * Represents a catagory of Role Mapping rules which are capable of containing other rules. + */ +export abstract class RuleGroup extends Rule { + /** + * Returns all immediate sub-rules within this group (non-recursive). + */ + abstract getRules(): Rule[]; + + /** + * Replaces the rule at the indicated location. + * @param ruleIndex the location of the rule to replace. + * @param rule the new rule. + */ + abstract replaceRule(ruleIndex: number, rule: Rule): void; + + /** + * Removes the rule at the indicated location. + * @param ruleIndex the location of the rule to remove. + */ + abstract removeRule(ruleIndex: number): void; + + /** + * Adds a rule to this group. + * @param rule the rule to add. + */ + abstract addRule(rule: Rule): void; + + /** + * Determines if the provided rules are allowed to be contained within this group. + * @param rules the rules to test. + */ + abstract canContainRules(rules: Rule[]): boolean; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/create_role_mapping_button.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/create_role_mapping_button.tsx new file mode 100644 index 0000000000000..2342eeb97d03e --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/create_role_mapping_button.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'; +import { EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { getCreateRoleMappingHref } from '../../../../management_urls'; + +export const CreateRoleMappingButton = () => { + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/index.ts new file mode 100644 index 0000000000000..417bf50d754e6 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/create_role_mapping_button/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 { CreateRoleMappingButton } from './create_role_mapping_button'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/empty_prompt.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/empty_prompt.tsx new file mode 100644 index 0000000000000..1919d3fbf4785 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/empty_prompt.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, { Fragment } from 'react'; +import { EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { CreateRoleMappingButton } from '../create_role_mapping_button'; + +export const EmptyPrompt: React.FunctionComponent<{}> = () => ( + + + + } + body={ + +

+ +

+
+ } + actions={} + data-test-subj="roleMappingsEmptyPrompt" + /> +); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/index.ts new file mode 100644 index 0000000000000..982e34a0ceed5 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/empty_prompt/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 { EmptyPrompt } from './empty_prompt'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/index.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/index.ts new file mode 100644 index 0000000000000..4bd5df71da446 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/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 { RoleMappingsGridPage } from './role_mappings_grid_page'; diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.test.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.test.tsx new file mode 100644 index 0000000000000..259cdc71e25a2 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.test.tsx @@ -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 React from 'react'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { RoleMappingsGridPage } from '.'; +import { SectionLoading, PermissionDenied, NoCompatibleRealms } from '../../components'; +import { EmptyPrompt } from './empty_prompt'; +import { findTestSubject } from 'test_utils/find_test_subject'; +import { EuiLink } from '@elastic/eui'; +import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api'; +import { act } from '@testing-library/react'; + +describe('RoleMappingsGridPage', () => { + it('renders an empty prompt when no role mappings exist', async () => { + const roleMappingsAPI = ({ + getRoleMappings: jest.fn().mockResolvedValue([]), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + expect(wrapper.find(SectionLoading)).toHaveLength(1); + expect(wrapper.find(EmptyPrompt)).toHaveLength(0); + + await nextTick(); + wrapper.update(); + + expect(wrapper.find(SectionLoading)).toHaveLength(0); + expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0); + expect(wrapper.find(EmptyPrompt)).toHaveLength(1); + }); + + it('renders a permission denied message when unauthorized to manage role mappings', async () => { + const roleMappingsAPI = ({ + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: false, + hasCompatibleRealms: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + expect(wrapper.find(SectionLoading)).toHaveLength(1); + expect(wrapper.find(PermissionDenied)).toHaveLength(0); + + await nextTick(); + wrapper.update(); + + expect(wrapper.find(SectionLoading)).toHaveLength(0); + expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0); + expect(wrapper.find(PermissionDenied)).toHaveLength(1); + }); + + it('renders a warning when there are no compatible realms enabled', async () => { + const roleMappingsAPI = ({ + getRoleMappings: jest.fn().mockResolvedValue([ + { + name: 'some realm', + enabled: true, + roles: [], + rules: { field: { username: '*' } }, + }, + ]), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: false, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + expect(wrapper.find(SectionLoading)).toHaveLength(1); + expect(wrapper.find(NoCompatibleRealms)).toHaveLength(0); + + await nextTick(); + wrapper.update(); + + expect(wrapper.find(SectionLoading)).toHaveLength(0); + expect(wrapper.find(NoCompatibleRealms)).toHaveLength(1); + }); + + it('renders links to mapped roles', async () => { + const roleMappingsAPI = ({ + getRoleMappings: jest.fn().mockResolvedValue([ + { + name: 'some realm', + enabled: true, + roles: ['superuser'], + rules: { field: { username: '*' } }, + }, + ]), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + await nextTick(); + wrapper.update(); + + const links = findTestSubject(wrapper, 'roleMappingRoles').find(EuiLink); + expect(links).toHaveLength(1); + expect(links.at(0).props()).toMatchObject({ + href: '#/management/security/roles/edit/superuser', + }); + }); + + it('describes the number of mapped role templates', async () => { + const roleMappingsAPI = ({ + getRoleMappings: jest.fn().mockResolvedValue([ + { + name: 'some realm', + enabled: true, + role_templates: [{}, {}], + rules: { field: { username: '*' } }, + }, + ]), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + }), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + await nextTick(); + wrapper.update(); + + const templates = findTestSubject(wrapper, 'roleMappingRoles'); + expect(templates).toHaveLength(1); + expect(templates.text()).toEqual(`2 role templates defined`); + }); + + it('allows role mappings to be deleted, refreshing the grid after', async () => { + const roleMappingsAPI = ({ + getRoleMappings: jest.fn().mockResolvedValue([ + { + name: 'some-realm', + enabled: true, + roles: ['superuser'], + rules: { field: { username: '*' } }, + }, + ]), + checkRoleMappingFeatures: jest.fn().mockResolvedValue({ + canManageRoleMappings: true, + hasCompatibleRealms: true, + }), + deleteRoleMappings: jest.fn().mockReturnValue( + Promise.resolve([ + { + name: 'some-realm', + success: true, + }, + ]) + ), + } as unknown) as RoleMappingsAPI; + + const wrapper = mountWithIntl(); + await nextTick(); + wrapper.update(); + + expect(roleMappingsAPI.getRoleMappings).toHaveBeenCalledTimes(1); + expect(roleMappingsAPI.deleteRoleMappings).not.toHaveBeenCalled(); + + findTestSubject(wrapper, `deleteRoleMappingButton-some-realm`).simulate('click'); + expect(findTestSubject(wrapper, 'deleteRoleMappingConfirmationModal')).toHaveLength(1); + + await act(async () => { + findTestSubject(wrapper, 'confirmModalConfirmButton').simulate('click'); + await nextTick(); + wrapper.update(); + }); + + expect(roleMappingsAPI.deleteRoleMappings).toHaveBeenCalledWith(['some-realm']); + // Expect an additional API call to refresh the grid + expect(roleMappingsAPI.getRoleMappings).toHaveBeenCalledTimes(2); + }); +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.tsx new file mode 100644 index 0000000000000..7b23f2288d1ba --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/components/role_mappings_grid_page.tsx @@ -0,0 +1,474 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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, { Component, Fragment } from 'react'; +import { + EuiBadge, + EuiButton, + EuiButtonIcon, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiInMemoryTable, + EuiLink, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiSpacer, + EuiText, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { RoleMapping } from '../../../../../../common/model'; +import { RoleMappingsAPI } from '../../../../../lib/role_mappings_api'; +import { EmptyPrompt } from './empty_prompt'; +import { + NoCompatibleRealms, + DeleteProvider, + PermissionDenied, + SectionLoading, +} from '../../components'; +import { documentationLinks } from '../../services/documentation_links'; +import { + getCreateRoleMappingHref, + getEditRoleMappingHref, + getEditRoleHref, +} from '../../../management_urls'; + +interface Props { + roleMappingsAPI: RoleMappingsAPI; +} + +interface State { + loadState: 'loadingApp' | 'loadingTable' | 'permissionDenied' | 'finished'; + roleMappings: null | RoleMapping[]; + selectedItems: RoleMapping[]; + hasCompatibleRealms: boolean; + error: any; +} + +export class RoleMappingsGridPage extends Component { + constructor(props: any) { + super(props); + this.state = { + loadState: 'loadingApp', + roleMappings: null, + hasCompatibleRealms: true, + selectedItems: [], + error: undefined, + }; + } + + public componentDidMount() { + this.checkPrivileges(); + } + + public render() { + const { loadState, error, roleMappings } = this.state; + + if (loadState === 'permissionDenied') { + return ; + } + + if (loadState === 'loadingApp') { + return ( + + + + + + ); + } + + if (error) { + const { + body: { error: errorTitle, message, statusCode }, + } = error; + + return ( + + + } + color="danger" + iconType="alert" + > + {statusCode}: {errorTitle} - {message} + + + ); + } + + if (loadState === 'finished' && roleMappings && roleMappings.length === 0) { + return ( + + + + ); + } + + return ( + + + + +

+ +

+
+ +

+ + + + ), + }} + /> +

+
+
+ + + + + +
+ + + {!this.state.hasCompatibleRealms && ( + <> + + + + )} + {this.renderTable()} + + +
+ ); + } + + private renderTable = () => { + const { roleMappings, selectedItems, loadState } = this.state; + + const message = + loadState === 'loadingTable' ? ( + + ) : ( + undefined + ); + + const sorting = { + sort: { + field: 'name', + direction: 'asc' as any, + }, + }; + + const pagination = { + initialPageSize: 20, + pageSizeOptions: [10, 20, 50], + }; + + const selection = { + onSelectionChange: (newSelectedItems: RoleMapping[]) => { + this.setState({ + selectedItems: newSelectedItems, + }); + }, + }; + + const search = { + toolsLeft: selectedItems.length ? ( + + {deleteRoleMappingsPrompt => { + return ( + deleteRoleMappingsPrompt(selectedItems, this.onRoleMappingsDeleted)} + color="danger" + data-test-subj="bulkDeleteActionButton" + > + + + ); + }} + + ) : ( + undefined + ), + toolsRight: ( + this.reloadRoleMappings()} + data-test-subj="reloadButton" + > + + + ), + box: { + incremental: true, + }, + filters: undefined, + }; + + return ( + { + return { + 'data-test-subj': 'roleMappingRow', + }; + }} + /> + ); + }; + + private getColumnConfig = () => { + const config = [ + { + field: 'name', + name: i18n.translate('xpack.security.management.roleMappings.nameColumnName', { + defaultMessage: 'Name', + }), + sortable: true, + render: (roleMappingName: string) => { + return ( + + {roleMappingName} + + ); + }, + }, + { + field: 'roles', + name: i18n.translate('xpack.security.management.roleMappings.rolesColumnName', { + defaultMessage: 'Roles', + }), + sortable: true, + render: (entry: any, record: RoleMapping) => { + const { roles = [], role_templates: roleTemplates = [] } = record; + if (roleTemplates.length > 0) { + return ( + + {i18n.translate('xpack.security.management.roleMappings.roleTemplates', { + defaultMessage: + '{templateCount, plural, one{# role template} other {# role templates}} defined', + values: { + templateCount: roleTemplates.length, + }, + })} + + ); + } + const roleLinks = roles.map((rolename, index) => { + return ( + + {rolename} + {index === roles.length - 1 ? null : ', '} + + ); + }); + return
{roleLinks}
; + }, + }, + { + field: 'enabled', + name: i18n.translate('xpack.security.management.roleMappings.enabledColumnName', { + defaultMessage: 'Enabled', + }), + sortable: true, + render: (enabled: boolean) => { + if (enabled) { + return ( + + + + ); + } + + return ( + + + + ); + }, + }, + { + name: i18n.translate('xpack.security.management.roleMappings.actionsColumnName', { + defaultMessage: 'Actions', + }), + actions: [ + { + render: (record: RoleMapping) => { + return ( + + + + ); + }, + }, + { + render: (record: RoleMapping) => { + return ( + + + + {deleteRoleMappingPrompt => { + return ( + + + deleteRoleMappingPrompt([record], this.onRoleMappingsDeleted) + } + /> + + ); + }} + + + + ); + }, + }, + ], + }, + ]; + return config; + }; + + private onRoleMappingsDeleted = (roleMappings: string[]): void => { + if (roleMappings.length) { + this.reloadRoleMappings(); + } + }; + + private async checkPrivileges() { + try { + const { + canManageRoleMappings, + hasCompatibleRealms, + } = await this.props.roleMappingsAPI.checkRoleMappingFeatures(); + + this.setState({ + loadState: canManageRoleMappings ? this.state.loadState : 'permissionDenied', + hasCompatibleRealms, + }); + + if (canManageRoleMappings) { + this.loadRoleMappings(); + } + } catch (e) { + this.setState({ error: e, loadState: 'finished' }); + } + } + + private reloadRoleMappings = () => { + this.setState({ roleMappings: [], loadState: 'loadingTable' }); + this.loadRoleMappings(); + }; + + private loadRoleMappings = async () => { + try { + const roleMappings = await this.props.roleMappingsAPI.getRoleMappings(); + this.setState({ roleMappings }); + } catch (e) { + this.setState({ error: e }); + } + + this.setState({ loadState: 'finished' }); + }; +} diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/index.tsx b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/index.tsx new file mode 100644 index 0000000000000..9e925d0fa6dc0 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/index.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import routes from 'ui/routes'; +import { I18nContext } from 'ui/i18n'; +import { npSetup } from 'ui/new_platform'; +import { RoleMappingsAPI } from '../../../../lib/role_mappings_api'; +// @ts-ignore +import template from './role_mappings.html'; +import { ROLE_MAPPINGS_PATH } from '../../management_urls'; +import { getRoleMappingBreadcrumbs } from '../../breadcrumbs'; +import { RoleMappingsGridPage } from './components'; + +routes.when(ROLE_MAPPINGS_PATH, { + template, + k7Breadcrumbs: getRoleMappingBreadcrumbs, + controller($scope) { + $scope.$$postDigest(() => { + const domNode = document.getElementById('roleMappingsGridReactRoot'); + + render( + + + , + domNode + ); + + // unmount react on controller destroy + $scope.$on('$destroy', () => { + if (domNode) { + unmountComponentAtNode(domNode); + } + }); + }); + }, +}); diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/role_mappings.html b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/role_mappings.html new file mode 100644 index 0000000000000..cff3b821d132c --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/role_mappings_grid/role_mappings.html @@ -0,0 +1,3 @@ + +
+ diff --git a/x-pack/legacy/plugins/security/public/views/management/role_mappings/services/documentation_links.ts b/x-pack/legacy/plugins/security/public/views/management/role_mappings/services/documentation_links.ts new file mode 100644 index 0000000000000..36351f49890a1 --- /dev/null +++ b/x-pack/legacy/plugins/security/public/views/management/role_mappings/services/documentation_links.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 { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; + +class DocumentationLinksService { + private esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; + + public getRoleMappingDocUrl() { + return `${this.esDocBasePath}/mapping-roles.html`; + } + + public getRoleMappingAPIDocUrl() { + return `${this.esDocBasePath}/security-api-put-role-mapping.html`; + } + + public getRoleMappingTemplateDocUrl() { + return `${this.esDocBasePath}/security-api-put-role-mapping.html#_role_templates`; + } + + public getRoleMappingFieldRulesDocUrl() { + return `${this.esDocBasePath}/role-mapping-resources.html#mapping-roles-rule-field`; + } +} + +export const documentationLinks = new DocumentationLinksService(); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts index 73711f1434d5f..3853e703a7c07 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/pagination/pagination.spec.ts @@ -18,9 +18,16 @@ import { import { DEFAULT_TIMEOUT, loginAndWaitForPage, waitForTableLoad } from '../../lib/util/helpers'; describe('Pagination', () => { - it('pagination updates results and page number', () => { + before(() => { loginAndWaitForPage(HOSTS_PAGE_TAB_URLS.uncommonProcesses); waitForTableLoad(UNCOMMON_PROCCESSES_TABLE); + }); + + afterEach(() => { + cy.get(getPageButtonSelector(0)).click({ force: true }); + }); + + it('pagination updates results and page number', () => { cy.get(getPageButtonSelector(0)).should('have.class', 'euiPaginationButton-isActive'); cy.get(getDraggableField('process.name')) @@ -42,8 +49,6 @@ describe('Pagination', () => { }); it('pagination keeps track of page results when tabs change', () => { - loginAndWaitForPage(HOSTS_PAGE_TAB_URLS.uncommonProcesses); - waitForTableLoad(UNCOMMON_PROCCESSES_TABLE); cy.get(getPageButtonSelector(0)).should('have.class', 'euiPaginationButton-isActive'); let thirdPageResult: string; cy.get(getPageButtonSelector(2)).click({ force: true }); @@ -78,7 +83,6 @@ describe('Pagination', () => { * when we figure out a way to really mock the data, we should come back to it */ it('pagination resets results and page number to first page when refresh is clicked', () => { - loginAndWaitForPage(HOSTS_PAGE_TAB_URLS.uncommonProcesses); cy.get(NUMBERED_PAGINATION, { timeout: DEFAULT_TIMEOUT }); cy.get(getPageButtonSelector(0)).should('have.class', 'euiPaginationButton-isActive'); // let firstResult: string; diff --git a/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx b/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx index 04d6d94d6624d..a2a0ffdde34a5 100644 --- a/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx @@ -20,7 +20,7 @@ import * as i18n from './translations'; const InspectContainer = styled.div<{ showInspect: boolean }>` .euiButtonIcon { - ${props => (props.showInspect ? 'opacity: 1;' : 'opacity: 0')} + ${props => (props.showInspect ? 'opacity: 1;' : 'opacity: 0;')} transition: opacity ${props => getOr(250, 'theme.eui.euiAnimSpeedNormal', props)} ease; } `; diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap index 098f54640e4b2..5ed750b519cbf 100644 --- a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap @@ -105,7 +105,7 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] = showInspect={false} >
` - padding-left: ${({ selected }) => (selected ? '3px' : '0px')}; +const MyEuiFlexItem = styled(EuiFlexItem)` + display: inline-block; + max-width: 296px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `; -const MyEuiTextColor = styled(EuiTextColor)<{ selected: boolean }>` - padding-left: ${({ selected }) => (selected ? '20px' : '0px')}; +const EuiSelectableContainer = styled.div` + .euiSelectable { + .euiFormControlLayout__childrenWrapper { + display: flex; + } + } +`; + +const MyEuiFlexGroup = styled(EuiFlexGroup)` + padding 0px 4px; `; interface SearchTimelineSuperSelectProps { @@ -83,6 +95,7 @@ const SearchTimelineSuperSelectComponent: React.FC(null); const onSearchTimeline = useCallback(val => { setSearchTimelineValue(val); @@ -102,37 +115,57 @@ const SearchTimelineSuperSelectComponent: React.FC { return ( - <> - {option.checked === 'on' && } - - {isUntitled(option) ? i18nTimeline.UNTITLED_TIMELINE : option.title} - -
- - - {option.description != null && option.description.trim().length > 0 - ? option.description - : getEmptyTagValue()} - - - + + + + + + + + + {isUntitled(option) ? i18nTimeline.UNTITLED_TIMELINE : option.title} + + + + + + {option.description != null && option.description.trim().length > 0 + ? option.description + : getEmptyTagValue()} + + + + + + + + + ); }, []); - const handleTimelineChange = useCallback(options => { - const selectedTimeline = options.filter( - (option: { checked: string }) => option.checked === 'on' - ); - if (selectedTimeline != null && selectedTimeline.length > 0 && onTimelineChange != null) { - onTimelineChange( - isEmpty(selectedTimeline[0].title) - ? i18nTimeline.UNTITLED_TIMELINE - : selectedTimeline[0].title, - selectedTimeline[0].id + const handleTimelineChange = useCallback( + options => { + const selectedTimeline = options.filter( + (option: { checked: string }) => option.checked === 'on' ); - } - setIsPopoverOpen(false); - }, []); + if (selectedTimeline != null && selectedTimeline.length > 0) { + onTimelineChange( + isEmpty(selectedTimeline[0].title) + ? i18nTimeline.UNTITLED_TIMELINE + : selectedTimeline[0].title, + selectedTimeline[0].id === '-1' ? null : selectedTimeline[0].id + ); + } + setIsPopoverOpen(false); + }, + [onTimelineChange] + ); const handleOnScroll = useCallback( ( @@ -187,6 +220,29 @@ const SearchTimelineSuperSelectComponent: React.FC + searchRef != null ? ( + + + + + + {i18nTimeline.ONLY_FAVORITES} + + + + + + ) : null, + [searchRef, onlyFavorites, handleOnToggleOnlyFavorites] + ); + return ( {({ timelines, loading, totalCount }) => ( - <> - - - - - {i18nTimeline.ONLY_FAVORITES} - - - - - + { + setSearchRef(ref); + }, }} singleSelection={true} options={[ @@ -249,6 +293,7 @@ const SearchTimelineSuperSelectComponent: React.FC ({ description: t.description, + favorite: !isEmpty(t.favorite), label: t.title, id: t.savedObjectId, key: `${t.title}-${index}`, @@ -261,11 +306,12 @@ const SearchTimelineSuperSelectComponent: React.FC ( <> {search} + {favoritePortal} {list} )} - + )} 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 f9611995cdb04..b69a8de29e047 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 @@ -15,9 +15,13 @@ import { NewRule, Rule, FetchRuleProps, + BasicFetchProps, } from './types'; import { throwIfNotOk } from '../../../hooks/api/api'; -import { DETECTION_ENGINE_RULES_URL } from '../../../../common/constants'; +import { + DETECTION_ENGINE_RULES_URL, + DETECTION_ENGINE_PREPACKAGED_URL, +} from '../../../../common/constants'; /** * Add provided Rule @@ -199,3 +203,22 @@ export const duplicateRules = async ({ rules }: DuplicateRulesProps): Promise>(response => response.json()) ); }; + +/** + * Create Prepackaged Rules + * + * @param signal AbortSignal for cancelling request + */ +export const createPrepackagedRules = async ({ signal }: BasicFetchProps): Promise => { + const response = await fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_PREPACKAGED_URL}`, { + method: 'PUT', + credentials: 'same-origin', + headers: { + 'content-type': 'application/json', + 'kbn-xsrf': 'true', + }, + signal, + }); + await throwIfNotOk(response); + return true; +}; 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 655299c4a2a34..a329d96d444aa 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 @@ -132,3 +132,7 @@ export interface DeleteRulesProps { export interface DuplicateRulesProps { rules: Rules; } + +export interface BasicFetchProps { + signal: AbortSignal; +} diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts index 5b5dc9e9699fe..2b8f54e5438df 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/translations.ts @@ -12,3 +12,24 @@ export const SIGNAL_FETCH_FAILURE = i18n.translate( defaultMessage: 'Failed to query signals', } ); + +export const PRIVILEGE_FETCH_FAILURE = i18n.translate( + 'xpack.siem.containers.detectionEngine.signals.errorFetchingSignalsDescription', + { + defaultMessage: 'Failed to query signals', + } +); + +export const SIGNAL_GET_NAME_FAILURE = i18n.translate( + 'xpack.siem.containers.detectionEngine.signals.errorGetSignalDescription', + { + defaultMessage: 'Failed to get signal index name', + } +); + +export const SIGNAL_POST_FAILURE = i18n.translate( + 'xpack.siem.containers.detectionEngine.signals.errorPostSignalDescription', + { + defaultMessage: 'Failed to create signal index', + } +); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx index aa66df53d9fd9..792ff29ad2488 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_privilege_user.tsx @@ -6,10 +6,18 @@ import { useEffect, useState } from 'react'; +import { errorToToaster } from '../../../components/ml/api/error_to_toaster'; +import { useStateToaster } from '../../../components/toasters'; import { getUserPrivilege } from './api'; +import * as i18n from './translations'; -type Return = [boolean, boolean | null, boolean | null]; - +interface Return { + loading: boolean; + isAuthenticated: boolean | null; + hasIndexManage: boolean | null; + hasManageApiKey: boolean | null; + hasIndexWrite: boolean | null; +} /** * Hook to get user privilege from * @@ -17,7 +25,10 @@ type Return = [boolean, boolean | null, boolean | null]; export const usePrivilegeUser = (): Return => { const [loading, setLoading] = useState(true); const [isAuthenticated, setAuthenticated] = useState(null); - const [hasWrite, setHasWrite] = useState(null); + const [hasIndexManage, setHasIndexManage] = useState(null); + const [hasIndexWrite, setHasIndexWrite] = useState(null); + const [hasManageApiKey, setHasManageApiKey] = useState(null); + const [, dispatchToaster] = useStateToaster(); useEffect(() => { let isSubscribed = true; @@ -34,13 +45,21 @@ export const usePrivilegeUser = (): Return => { setAuthenticated(privilege.isAuthenticated); if (privilege.index != null && Object.keys(privilege.index).length > 0) { const indexName = Object.keys(privilege.index)[0]; - setHasWrite(privilege.index[indexName].create_index); + setHasIndexManage(privilege.index[indexName].manage); + setHasIndexWrite(privilege.index[indexName].write); + setHasManageApiKey( + privilege.cluster.manage_security || + privilege.cluster.manage_api_key || + privilege.cluster.manage_own_api_key + ); } } } catch (error) { if (isSubscribed) { setAuthenticated(false); - setHasWrite(false); + setHasIndexManage(false); + setHasIndexWrite(false); + errorToToaster({ title: i18n.PRIVILEGE_FETCH_FAILURE, error, dispatchToaster }); } } if (isSubscribed) { @@ -55,5 +74,5 @@ export const usePrivilegeUser = (): Return => { }; }, []); - return [loading, isAuthenticated, hasWrite]; + return { loading, isAuthenticated, hasIndexManage, hasManageApiKey, hasIndexWrite }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx index 1ff4422cf6411..189d8a1bf3f75 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/signals/use_signal_index.tsx @@ -8,9 +8,10 @@ import { useEffect, useState, useRef } from 'react'; import { errorToToaster } from '../../../components/ml/api/error_to_toaster'; import { useStateToaster } from '../../../components/toasters'; +import { createPrepackagedRules } from '../rules'; import { createSignalIndex, getSignalIndex } from './api'; import * as i18n from './translations'; -import { PostSignalError } from './types'; +import { PostSignalError, SignalIndexError } from './types'; type Func = () => void; @@ -40,11 +41,15 @@ export const useSignalIndex = (): Return => { if (isSubscribed && signal != null) { setSignalIndexName(signal.name); setSignalIndexExists(true); + createPrepackagedRules({ signal: abortCtrl.signal }); } } catch (error) { if (isSubscribed) { setSignalIndexName(null); setSignalIndexExists(false); + if (error instanceof SignalIndexError && error.statusCode !== 404) { + errorToToaster({ title: i18n.SIGNAL_GET_NAME_FAILURE, error, dispatchToaster }); + } } } if (isSubscribed) { @@ -69,7 +74,7 @@ export const useSignalIndex = (): Return => { } else { setSignalIndexName(null); setSignalIndexExists(false); - errorToToaster({ title: i18n.SIGNAL_FETCH_FAILURE, error, dispatchToaster }); + errorToToaster({ title: i18n.SIGNAL_POST_FAILURE, error, dispatchToaster }); } } } diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.tsx new file mode 100644 index 0000000000000..1950531998450 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut, EuiButton } from '@elastic/eui'; +import React, { memo, useCallback, useState } from 'react'; + +import * as i18n from './translations'; + +const NoWriteSignalsCallOutComponent = () => { + const [showCallOut, setShowCallOut] = useState(true); + const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]); + + return showCallOut ? ( + +

{i18n.NO_WRITE_SIGNALS_CALLOUT_MSG}

+ + {i18n.DISMISS_CALLOUT} + +
+ ) : null; +}; + +export const NoWriteSignalsCallOut = memo(NoWriteSignalsCallOutComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/translations.ts new file mode 100644 index 0000000000000..065d775e1dc6a --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/no_write_signals_callout/translations.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 { i18n } from '@kbn/i18n'; + +export const NO_WRITE_SIGNALS_CALLOUT_TITLE = i18n.translate( + 'xpack.siem.detectionEngine.noWriteSignalsCallOutTitle', + { + defaultMessage: 'Signals index permissions required', + } +); + +export const NO_WRITE_SIGNALS_CALLOUT_MSG = i18n.translate( + 'xpack.siem.detectionEngine.noWriteSignalsCallOutMsg', + { + defaultMessage: + 'You are currently missing the required permissions to update signals. Please contact your administrator for further assistance.', + } +); + +export const DISMISS_CALLOUT = i18n.translate( + 'xpack.siem.detectionEngine.dismissNoWriteSignalButton', + { + defaultMessage: 'Dismiss', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx index 1a7ad5822a246..83b6ba690ec5b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/default_config.tsx @@ -168,55 +168,66 @@ export const requiredFieldsForActions = [ ]; export const getSignalsActions = ({ + canUserCRUD, + hasIndexWrite, setEventsLoading, setEventsDeleted, createTimeline, status, }: { + canUserCRUD: boolean; + hasIndexWrite: boolean; setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void; createTimeline: CreateTimeline; status: 'open' | 'closed'; -}): TimelineAction[] => [ - { - getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( - - sendSignalsToTimelineAction({ createTimeline, data: [data] })} - iconType="tableDensityNormal" - aria-label="Next" - /> - - ), - id: 'sendSignalToTimeline', - width: 26, - }, - { - getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( - - - updateSignalStatusAction({ - signalIds: [eventId], - status, - setEventsLoading, - setEventsDeleted, - }) - } - iconType={status === FILTER_OPEN ? 'indexOpen' : 'indexClose'} - aria-label="Next" - /> - - ), - id: 'updateSignalStatus', - width: 26, - }, -]; +}): TimelineAction[] => { + const actions = [ + { + getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( + + sendSignalsToTimelineAction({ createTimeline, data: [data] })} + iconType="tableDensityNormal" + aria-label="Next" + /> + + ), + id: 'sendSignalToTimeline', + width: 26, + }, + ]; + return canUserCRUD && hasIndexWrite + ? [ + ...actions, + { + getAction: ({ eventId, data }: TimelineActionProps): JSX.Element => ( + + + updateSignalStatusAction({ + signalIds: [eventId], + status, + setEventsLoading, + setEventsDeleted, + }) + } + iconType={status === FILTER_OPEN ? 'indexOpen' : 'indexClose'} + aria-label="Next" + /> + + ), + id: 'updateSignalStatus', + width: 26, + }, + ] + : actions; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx index 47a78482cfb6e..d149eb700ad03 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/index.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiPanel, EuiLoadingContent } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; @@ -46,6 +47,8 @@ import { useFetchIndexPatterns } from '../../../../containers/detection_engine/r import { InputsRange } from '../../../../store/inputs/model'; import { Query } from '../../../../../../../../../src/plugins/data/common/query'; +import { HeaderSection } from '../../../../components/header_section'; + const SIGNALS_PAGE_TIMELINE_ID = 'signals-page'; interface ReduxProps { @@ -88,8 +91,11 @@ interface DispatchProps { } interface OwnProps { + canUserCRUD: boolean; defaultFilters?: esFilters.Filter[]; + hasIndexWrite: boolean; from: number; + loading: boolean; signalsIndex: string; to: number; } @@ -98,6 +104,7 @@ type SignalsTableComponentProps = OwnProps & ReduxProps & DispatchProps; export const SignalsTableComponent = React.memo( ({ + canUserCRUD, createTimeline, clearEventsDeleted, clearEventsLoading, @@ -106,7 +113,9 @@ export const SignalsTableComponent = React.memo( from, globalFilters, globalQuery, + hasIndexWrite, isSelectAllChecked, + loading, loadingEventIds, removeTimelineLinkTo, selectedEventIds, @@ -228,8 +237,10 @@ export const SignalsTableComponent = React.memo( (totalCount: number) => { return ( 0} clearSelection={clearSelectionCallback} + hasIndexWrite={hasIndexWrite} isFilteredToOpen={filterGroup === FILTER_OPEN} selectAll={selectAllCallback} selectedEventIds={selectedEventIds} @@ -241,6 +252,8 @@ export const SignalsTableComponent = React.memo( ); }, [ + canUserCRUD, + hasIndexWrite, clearSelectionCallback, filterGroup, loadingEventIds.length, @@ -254,12 +267,14 @@ export const SignalsTableComponent = React.memo( const additionalActions = useMemo( () => getSignalsActions({ + canUserCRUD, + hasIndexWrite, createTimeline: createTimelineCallback, setEventsLoading: setEventsLoadingCallback, setEventsDeleted: setEventsDeletedCallback, status: filterGroup === FILTER_OPEN ? FILTER_CLOSED : FILTER_OPEN, }), - [createTimelineCallback, filterGroup] + [canUserCRUD, createTimelineCallback, filterGroup] ); const defaultIndices = useMemo(() => [signalsIndex], [signalsIndex]); @@ -279,11 +294,20 @@ export const SignalsTableComponent = React.memo( queryFields: requiredFieldsForActions, timelineActions: additionalActions, title: i18n.SIGNALS_TABLE_TITLE, - selectAll, + selectAll: canUserCRUD ? selectAll : false, }), - [additionalActions, selectAll] + [additionalActions, canUserCRUD, selectAll] ); + if (loading) { + return ( + + + + + ); + } + return ( >; + updateSignalsStatus: UpdateSignalsStatus; + sendSignalsToTimeline: SendSignalsToTimeline; + closePopover: () => void; + isFilteredToOpen: boolean; +} /** * Returns ViewInTimeline / UpdateSignalStatus actions to be display within an EuiContextMenuPanel * @@ -22,15 +31,15 @@ import { FILTER_CLOSED, FILTER_OPEN } from '../signals_filter_group'; * @param closePopover * @param isFilteredToOpen currently selected filter options */ -export const getBatchItems = ( - areEventsLoading: boolean, - allEventsSelected: boolean, - selectedEventIds: Readonly>, - updateSignalsStatus: UpdateSignalsStatus, - sendSignalsToTimeline: SendSignalsToTimeline, - closePopover: () => void, - isFilteredToOpen: boolean -) => { +export const getBatchItems = ({ + areEventsLoading, + allEventsSelected, + selectedEventIds, + updateSignalsStatus, + sendSignalsToTimeline, + closePopover, + isFilteredToOpen, +}: GetBatchItems) => { const allDisabled = areEventsLoading || Object.keys(selectedEventIds).length === 0; const sendToTimelineDisabled = allEventsSelected || uniqueRuleCount(selectedEventIds) > 1; const filterString = isFilteredToOpen diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx index f80de053b59bd..e28fb3e06870e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/signals_utility_bar/index.tsx @@ -22,6 +22,8 @@ import { TimelineNonEcsData } from '../../../../../graphql/types'; import { SendSignalsToTimeline, UpdateSignalsStatus } from '../types'; interface SignalsUtilityBarProps { + canUserCRUD: boolean; + hasIndexWrite: boolean; areEventsLoading: boolean; clearSelection: () => void; isFilteredToOpen: boolean; @@ -34,6 +36,8 @@ interface SignalsUtilityBarProps { } const SignalsUtilityBarComponent: React.FC = ({ + canUserCRUD, + hasIndexWrite, areEventsLoading, clearSelection, totalCount, @@ -49,15 +53,15 @@ const SignalsUtilityBarComponent: React.FC = ({ const getBatchItemsPopoverContent = useCallback( (closePopover: () => void) => ( ), [ @@ -66,6 +70,7 @@ const SignalsUtilityBarComponent: React.FC = ({ updateSignalsStatus, sendSignalsToTimeline, isFilteredToOpen, + hasIndexWrite, ] ); @@ -83,7 +88,7 @@ const SignalsUtilityBarComponent: React.FC = ({ - {totalCount > 0 && ( + {canUserCRUD && hasIndexWrite && ( <> {i18n.SELECTED_SIGNALS( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/user_info/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/user_info/index.tsx new file mode 100644 index 0000000000000..bbaccb7882484 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/components/user_info/index.tsx @@ -0,0 +1,238 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { noop } from 'lodash/fp'; +import React, { useEffect, useReducer, Dispatch, createContext, useContext } from 'react'; + +import { usePrivilegeUser } from '../../../../containers/detection_engine/signals/use_privilege_user'; +import { useSignalIndex } from '../../../../containers/detection_engine/signals/use_signal_index'; +import { useKibana } from '../../../../lib/kibana'; + +export interface State { + canUserCRUD: boolean | null; + hasIndexManage: boolean | null; + hasIndexWrite: boolean | null; + hasManageApiKey: boolean | null; + isSignalIndexExists: boolean | null; + isAuthenticated: boolean | null; + loading: boolean; + signalIndexName: string | null; +} + +const initialState: State = { + canUserCRUD: null, + hasIndexManage: null, + hasIndexWrite: null, + hasManageApiKey: null, + isSignalIndexExists: null, + isAuthenticated: null, + loading: true, + signalIndexName: null, +}; + +export type Action = + | { type: 'updateLoading'; loading: boolean } + | { + type: 'updateHasManageApiKey'; + hasManageApiKey: boolean | null; + } + | { + type: 'updateHasIndexManage'; + hasIndexManage: boolean | null; + } + | { + type: 'updateHasIndexWrite'; + hasIndexWrite: boolean | null; + } + | { + type: 'updateIsSignalIndexExists'; + isSignalIndexExists: boolean | null; + } + | { + type: 'updateIsAuthenticated'; + isAuthenticated: boolean | null; + } + | { + type: 'updateCanUserCRUD'; + canUserCRUD: boolean | null; + } + | { + type: 'updateSignalIndexName'; + signalIndexName: string | null; + }; + +export const userInfoReducer = (state: State, action: Action): State => { + switch (action.type) { + case 'updateLoading': { + return { + ...state, + loading: action.loading, + }; + } + case 'updateHasIndexManage': { + return { + ...state, + hasIndexManage: action.hasIndexManage, + }; + } + case 'updateHasIndexWrite': { + return { + ...state, + hasIndexWrite: action.hasIndexWrite, + }; + } + case 'updateHasManageApiKey': { + return { + ...state, + hasManageApiKey: action.hasManageApiKey, + }; + } + case 'updateIsSignalIndexExists': { + return { + ...state, + isSignalIndexExists: action.isSignalIndexExists, + }; + } + case 'updateIsAuthenticated': { + return { + ...state, + isAuthenticated: action.isAuthenticated, + }; + } + case 'updateCanUserCRUD': { + return { + ...state, + canUserCRUD: action.canUserCRUD, + }; + } + case 'updateSignalIndexName': { + return { + ...state, + signalIndexName: action.signalIndexName, + }; + } + default: + return state; + } +}; + +const StateUserInfoContext = createContext<[State, Dispatch]>([initialState, () => noop]); + +const useUserData = () => useContext(StateUserInfoContext); + +interface ManageUserInfoProps { + children: React.ReactNode; +} + +export const ManageUserInfo = ({ children }: ManageUserInfoProps) => ( + + {children} + +); + +export const useUserInfo = (): State => { + const [ + { + canUserCRUD, + hasIndexManage, + hasIndexWrite, + hasManageApiKey, + isSignalIndexExists, + isAuthenticated, + loading, + signalIndexName, + }, + dispatch, + ] = useUserData(); + const { + loading: privilegeLoading, + isAuthenticated: isApiAuthenticated, + hasIndexManage: hasApiIndexManage, + hasIndexWrite: hasApiIndexWrite, + hasManageApiKey: hasApiManageApiKey, + } = usePrivilegeUser(); + const [ + indexNameLoading, + isApiSignalIndexExists, + apiSignalIndexName, + createSignalIndex, + ] = useSignalIndex(); + + const uiCapabilities = useKibana().services.application.capabilities; + const capabilitiesCanUserCRUD: boolean = + typeof uiCapabilities.siem.crud === 'boolean' ? uiCapabilities.siem.crud : false; + + useEffect(() => { + if (loading !== privilegeLoading || indexNameLoading) { + dispatch({ type: 'updateLoading', loading: privilegeLoading || indexNameLoading }); + } + }, [loading, privilegeLoading, indexNameLoading]); + + useEffect(() => { + if (hasIndexManage !== hasApiIndexManage && hasApiIndexManage != null) { + dispatch({ type: 'updateHasIndexManage', hasIndexManage: hasApiIndexManage }); + } + }, [hasIndexManage, hasApiIndexManage]); + + useEffect(() => { + if (hasIndexWrite !== hasApiIndexWrite && hasApiIndexWrite != null) { + dispatch({ type: 'updateHasIndexWrite', hasIndexWrite: hasApiIndexWrite }); + } + }, [hasIndexWrite, hasApiIndexWrite]); + + useEffect(() => { + if (hasManageApiKey !== hasApiManageApiKey && hasApiManageApiKey != null) { + dispatch({ type: 'updateHasManageApiKey', hasManageApiKey: hasApiManageApiKey }); + } + }, [hasManageApiKey, hasApiManageApiKey]); + + useEffect(() => { + if (isSignalIndexExists !== isApiSignalIndexExists && isApiSignalIndexExists != null) { + dispatch({ type: 'updateIsSignalIndexExists', isSignalIndexExists: isApiSignalIndexExists }); + } + }, [isSignalIndexExists, isApiSignalIndexExists]); + + useEffect(() => { + if (isAuthenticated !== isApiAuthenticated && isApiAuthenticated != null) { + dispatch({ type: 'updateIsAuthenticated', isAuthenticated: isApiAuthenticated }); + } + }, [isAuthenticated, isApiAuthenticated]); + + useEffect(() => { + if (canUserCRUD !== capabilitiesCanUserCRUD && capabilitiesCanUserCRUD != null) { + dispatch({ type: 'updateCanUserCRUD', canUserCRUD: capabilitiesCanUserCRUD }); + } + }, [canUserCRUD, capabilitiesCanUserCRUD]); + + useEffect(() => { + if (signalIndexName !== apiSignalIndexName && apiSignalIndexName != null) { + dispatch({ type: 'updateSignalIndexName', signalIndexName: apiSignalIndexName }); + } + }, [signalIndexName, apiSignalIndexName]); + + useEffect(() => { + if ( + isAuthenticated && + hasIndexManage && + isSignalIndexExists != null && + !isSignalIndexExists && + createSignalIndex != null + ) { + createSignalIndex(); + } + }, [createSignalIndex, isAuthenticated, isSignalIndexExists, hasIndexManage]); + + return { + loading, + isSignalIndexExists, + isAuthenticated, + canUserCRUD, + hasIndexManage, + hasIndexWrite, + hasManageApiKey, + signalIndexName, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx index 2a91a559ec0e4..e638cf89e77bb 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/detection_engine.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton, EuiLoadingContent, EuiPanel, EuiSpacer } from '@elastic/eui'; +import { EuiButton, EuiSpacer } from '@elastic/eui'; import React, { useCallback } from 'react'; import { StickyContainer } from 'react-sticky'; @@ -18,30 +18,23 @@ import { GlobalTime } from '../../containers/global_time'; import { indicesExistOrDataTemporarilyUnavailable, WithSource } from '../../containers/source'; import { SpyRoute } from '../../utils/route/spy_routes'; -import { SignalsTable } from './components/signals'; -import * as signalsI18n from './components/signals/translations'; -import { SignalsHistogramPanel } from './components/signals_histogram_panel'; import { Query } from '../../../../../../../src/plugins/data/common/query'; import { esFilters } from '../../../../../../../src/plugins/data/common/es_query'; -import { inputsSelectors } from '../../store/inputs'; import { State } from '../../store'; +import { inputsSelectors } from '../../store/inputs'; +import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../store/inputs/actions'; +import { InputsModelId } from '../../store/inputs/constants'; import { InputsRange } from '../../store/inputs/model'; -import { signalsHistogramOptions } from './components/signals_histogram_panel/config'; import { useSignalInfo } from './components/signals_info'; +import { SignalsTable } from './components/signals'; +import { NoWriteSignalsCallOut } from './components/no_write_signals_callout'; +import { SignalsHistogramPanel } from './components/signals_histogram_panel'; +import { signalsHistogramOptions } from './components/signals_histogram_panel/config'; +import { useUserInfo } from './components/user_info'; import { DetectionEngineEmptyPage } from './detection_engine_empty_page'; import { DetectionEngineNoIndex } from './detection_engine_no_signal_index'; import { DetectionEngineUserUnauthenticated } from './detection_engine_user_unauthenticated'; import * as i18n from './translations'; -import { HeaderSection } from '../../components/header_section'; -import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../store/inputs/actions'; -import { InputsModelId } from '../../store/inputs/constants'; - -interface OwnProps { - loading: boolean; - isSignalIndexExists: boolean | null; - isUserAuthenticated: boolean | null; - signalsIndex: string | null; -} interface ReduxProps { filters: esFilters.Filter[]; @@ -56,18 +49,19 @@ export interface DispatchProps { }>; } -type DetectionEngineComponentProps = OwnProps & ReduxProps & DispatchProps; - -export const DetectionEngineComponent = React.memo( - ({ - filters, - loading, - isSignalIndexExists, - isUserAuthenticated, - query, - setAbsoluteRangeDatePicker, - signalsIndex, - }) => { +type DetectionEngineComponentProps = ReduxProps & DispatchProps; + +const DetectionEngineComponent = React.memo( + ({ filters, query, setAbsoluteRangeDatePicker }) => { + const { + loading, + isSignalIndexExists, + isAuthenticated: isUserAuthenticated, + canUserCRUD, + signalIndexName, + hasIndexWrite, + } = useUserInfo(); + const [lastSignals] = useSignalInfo({}); const updateDateRangeCallback = useCallback( @@ -95,6 +89,7 @@ export const DetectionEngineComponent = React.memo + {hasIndexWrite != null && !hasIndexWrite && } {({ indicesExist, indexPattern }) => { return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( @@ -102,7 +97,6 @@ export const DetectionEngineComponent = React.memo - - {!loading ? ( - isSignalIndexExists && ( - - ) - ) : ( - - - - - )} + )} @@ -160,7 +152,6 @@ export const DetectionEngineComponent = React.memo - ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx index c32cab7f933b2..c4e83429aebdb 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/index.tsx @@ -4,70 +4,38 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect } from 'react'; +import React from 'react'; import { Redirect, Route, Switch, RouteComponentProps } from 'react-router-dom'; -import { useSignalIndex } from '../../containers/detection_engine/signals/use_signal_index'; -import { usePrivilegeUser } from '../../containers/detection_engine/signals/use_privilege_user'; - import { CreateRuleComponent } from './rules/create'; import { DetectionEngine } from './detection_engine'; import { EditRuleComponent } from './rules/edit'; import { RuleDetails } from './rules/details'; import { RulesComponent } from './rules'; +import { ManageUserInfo } from './components/user_info'; const detectionEnginePath = `/:pageName(detection-engine)`; type Props = Partial> & { url: string }; -export const DetectionEngineContainer = React.memo(() => { - const [privilegeLoading, isAuthenticated, hasWrite] = usePrivilegeUser(); - const [ - indexNameLoading, - isSignalIndexExists, - signalIndexName, - createSignalIndex, - ] = useSignalIndex(); - - useEffect(() => { - if ( - isAuthenticated && - hasWrite && - isSignalIndexExists != null && - !isSignalIndexExists && - createSignalIndex != null - ) { - createSignalIndex(); - } - }, [createSignalIndex, isAuthenticated, isSignalIndexExists, hasWrite]); - - return ( +export const DetectionEngineContainer = React.memo(() => ( + - + + + + + + + + + + + + + - {isSignalIndexExists && isAuthenticated && ( - <> - - - - - - - - - - - - - - )} - ( @@ -75,6 +43,6 @@ export const DetectionEngineContainer = React.memo(() => { )} /> - ); -}); + +)); DetectionEngineContainer.displayName = 'DetectionEngineContainer'; 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 42c4bb1d0ef95..95b9c9324894f 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 @@ -68,111 +68,121 @@ const getActions = (dispatch: React.Dispatch, history: H.History) => [ }, ]; +type RulesColumns = EuiBasicTableColumn | EuiTableActionsColumnType; + // Michael: Are we able to do custom, in-table-header filters, as shown in my wireframes? export const getColumns = ( dispatch: React.Dispatch, - history: H.History -): Array | EuiTableActionsColumnType> => [ - { - field: 'rule', - name: i18n.COLUMN_RULE, - render: (value: TableData['rule']) => {value.name}, - truncateText: true, - width: '24%', - }, - { - field: 'method', - name: i18n.COLUMN_METHOD, - truncateText: true, - }, - { - field: 'severity', - name: i18n.COLUMN_SEVERITY, - render: (value: TableData['severity']) => ( - - {value} - - ), - truncateText: true, - }, - { - field: 'lastCompletedRun', - name: i18n.COLUMN_LAST_COMPLETE_RUN, - render: (value: TableData['lastCompletedRun']) => { - return value == null ? ( - getEmptyTagValue() - ) : ( - - ); + history: H.History, + hasNoPermissions: boolean +): RulesColumns[] => { + const cols: RulesColumns[] = [ + { + field: 'rule', + name: i18n.COLUMN_RULE, + render: (value: TableData['rule']) => {value.name}, + truncateText: true, + width: '24%', }, - sortable: true, - truncateText: true, - width: '16%', - }, - { - field: 'lastResponse', - name: i18n.COLUMN_LAST_RESPONSE, - render: (value: TableData['lastResponse']) => { - return value == null ? ( - getEmptyTagValue() - ) : ( - <> - {value.type === 'Fail' ? ( - - {value.type} - - ) : ( - {value.type} - )} - - ); + { + field: 'method', + name: i18n.COLUMN_METHOD, + truncateText: true, }, - truncateText: true, - }, - { - field: 'tags', - name: i18n.COLUMN_TAGS, - render: (value: TableData['tags']) => ( -
- <> - {value.map((tag, i) => ( - - {tag} - - ))} - -
- ), - truncateText: true, - width: '20%', - }, - { - align: 'center', - field: 'activate', - name: i18n.COLUMN_ACTIVATE, - render: (value: TableData['activate'], item: TableData) => ( - - ), - sortable: true, - width: '85px', - }, - { - actions: getActions(dispatch, history), - width: '40px', - } as EuiTableActionsColumnType, -]; + { + field: 'severity', + name: i18n.COLUMN_SEVERITY, + render: (value: TableData['severity']) => ( + + {value} + + ), + truncateText: true, + }, + { + field: 'lastCompletedRun', + name: i18n.COLUMN_LAST_COMPLETE_RUN, + render: (value: TableData['lastCompletedRun']) => { + return value == null ? ( + getEmptyTagValue() + ) : ( + + ); + }, + sortable: true, + truncateText: true, + width: '16%', + }, + { + field: 'lastResponse', + name: i18n.COLUMN_LAST_RESPONSE, + render: (value: TableData['lastResponse']) => { + return value == null ? ( + getEmptyTagValue() + ) : ( + <> + {value.type === 'Fail' ? ( + + {value.type} + + ) : ( + {value.type} + )} + + ); + }, + truncateText: true, + }, + { + field: 'tags', + name: i18n.COLUMN_TAGS, + render: (value: TableData['tags']) => ( +
+ <> + {value.map((tag, i) => ( + + {tag} + + ))} + +
+ ), + truncateText: true, + width: '20%', + }, + { + align: 'center', + field: 'activate', + name: i18n.COLUMN_ACTIVATE, + render: (value: TableData['activate'], item: TableData) => ( + + ), + sortable: true, + width: '85px', + }, + ]; + const actions: RulesColumns[] = [ + { + actions: getActions(dispatch, history), + width: '40px', + } as EuiTableActionsColumnType, + ]; + + return hasNoPermissions ? cols : [...cols, ...actions]; +}; 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 060f8baccc3b7..e900058b6c53c 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 @@ -11,7 +11,7 @@ import { EuiLoadingContent, EuiSpacer, } from '@elastic/eui'; -import React, { useCallback, useEffect, useReducer, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; import { useHistory } from 'react-router-dom'; import uuid from 'uuid'; @@ -60,7 +60,11 @@ const initialState: State = { * * Delete * * Import/Export */ -export const AllRules = React.memo<{ importCompleteToggle: boolean }>(importCompleteToggle => { +export const AllRules = React.memo<{ + hasNoPermissions: boolean; + importCompleteToggle: boolean; + loading: boolean; +}>(({ hasNoPermissions, importCompleteToggle, loading }) => { const [ { exportPayload, @@ -111,6 +115,15 @@ export const AllRules = React.memo<{ importCompleteToggle: boolean }>(importComp }); }, [rulesData]); + const euiBasicTableSelectionProps = useMemo( + () => ({ + selectable: (item: TableData) => !item.isLoading, + onSelectionChange: (selected: TableData[]) => + dispatch({ type: 'setSelected', selectedItems: selected }), + }), + [] + ); + return ( <> (importComp {i18n.SELECTED_RULES(selectedItems.length)} - - {i18n.BATCH_ACTIONS} - + {!hasNoPermissions && ( + + {i18n.BATCH_ACTIONS} + + )} (importComp { @@ -204,14 +219,12 @@ export const AllRules = React.memo<{ importCompleteToggle: boolean }>(importComp totalItemCount: pagination.total, pageSizeOptions: [5, 10, 20], }} - selection={{ - selectable: (item: TableData) => !item.isLoading, - onSelectionChange: (selected: TableData[]) => - dispatch({ type: 'setSelected', selectedItems: selected }), - }} sorting={{ sort: { field: 'activate', direction: filterOptions.sortOrder } }} + selection={hasNoPermissions ? undefined : euiBasicTableSelectionProps} /> - {isLoading && } + {(isLoading || loading) && ( + + )} )} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx index 725c7eeeedcfe..b3cc81b5cdfcf 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx @@ -45,6 +45,7 @@ export const AddItem = ({ isDisabled, validate, }: AddItemProps) => { + const [showValidation, setShowValidation] = useState(false); const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); const [haveBeenKeyboardDeleted, setHaveBeenKeyboardDeleted] = useState(-1); @@ -53,7 +54,8 @@ export const AddItem = ({ const removeItem = useCallback( (index: number) => { const values = field.value as string[]; - field.setValue([...values.slice(0, index), ...values.slice(index + 1)]); + const newValues = [...values.slice(0, index), ...values.slice(index + 1)]; + field.setValue(newValues.length === 0 ? [''] : newValues); inputsRef.current = [ ...inputsRef.current.slice(0, index), ...inputsRef.current.slice(index + 1), @@ -70,11 +72,7 @@ export const AddItem = ({ const addItem = useCallback(() => { const values = field.value as string[]; - if (!isEmpty(values) && values[values.length - 1]) { - field.setValue([...values, '']); - } else if (isEmpty(values)) { - field.setValue(['']); - } + field.setValue([...values, '']); }, [field]); const updateItem = useCallback( @@ -82,22 +80,7 @@ export const AddItem = ({ event.persist(); const values = field.value as string[]; const value = event.target.value; - if (isEmpty(value)) { - field.setValue([...values.slice(0, index), ...values.slice(index + 1)]); - inputsRef.current = [ - ...inputsRef.current.slice(0, index), - ...inputsRef.current.slice(index + 1), - ]; - setHaveBeenKeyboardDeleted(inputsRef.current.length - 1); - inputsRef.current = inputsRef.current.map((ref, i) => { - if (i >= index && inputsRef.current[index] != null) { - ref.value = 're-render'; - } - return ref; - }); - } else { - field.setValue([...values.slice(0, index), value, ...values.slice(index + 1)]); - } + field.setValue([...values.slice(0, index), value, ...values.slice(index + 1)]); }, [field] ); @@ -131,8 +114,8 @@ export const AddItem = ({ - updateItem(e, index)} fullWidth {...euiFieldProps} /> + setShowValidation(true)} + onChange={e => updateItem(e, index)} + fullWidth + {...euiFieldProps} + /> removeItem(index)} aria-label={RuleI18n.DELETE} /> 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 198756fc2336b..af4f93c0fdbcd 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 @@ -143,6 +143,14 @@ const getDescriptionItem = ( description: timeline.title ?? DEFAULT_TIMELINE_TITLE, }, ]; + } else if (field === 'riskScore') { + const description: string = get(field, value); + return [ + { + title: label, + description, + }, + ]; } const description: string = get(field, value); if (!isEmpty(description)) { 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 0995e0e916652..9695fd21067ee 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 @@ -11,7 +11,7 @@ export const FILTERS_LABEL = i18n.translate('xpack.siem.detectionEngine.createRu }); export const QUERY_LABEL = i18n.translate('xpack.siem.detectionEngine.createRule.QueryLabel', { - defaultMessage: 'Query', + defaultMessage: 'Custom query', }); export const SAVED_ID_LABEL = i18n.translate('xpack.siem.detectionEngine.createRule.savedIdLabel', { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx index 97c4c2fdd050a..2c19e99e90114 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx @@ -16,7 +16,7 @@ import { EuiText, } from '@elastic/eui'; import { isEmpty, kebabCase, camelCase } from 'lodash/fp'; -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import styled from 'styled-components'; import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_techniques'; @@ -41,6 +41,7 @@ interface AddItemProps { } export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddItemProps) => { + const [showValidation, setShowValidation] = useState(false); const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); const removeItem = useCallback( @@ -137,15 +138,16 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI t.tactics.includes(kebabCase(item.tactic.name)))} selectedOptions={item.techniques} onChange={updateTechniques.bind(null, index)} - isDisabled={disabled} + isDisabled={disabled || item.tactic.name === 'none'} fullWidth={true} - isInvalid={invalid} + isInvalid={showValidation && invalid} + onBlur={() => setShowValidation(true)} /> - {invalid && ( + {showValidation && invalid && (

{errorMessage}

@@ -155,7 +157,7 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI removeItem(index)} aria-label={Rulei18n.DELETE} /> @@ -186,7 +188,7 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI {index === 0 ? ( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts index dd4c55c1503ec..557e91691b6c7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/translations.ts @@ -18,7 +18,7 @@ export const TECHNIQUE = i18n.translate( ); export const ADD_MITRE_ATTACK = i18n.translate('xpack.siem.detectionEngine.mitreAttack.addTitle', { - defaultMessage: 'Add MITRE ATT&CK threat', + defaultMessage: 'Add MITRE ATT&CK\\u2122 threat', }); export const TECHNIQUES_PLACEHOLDER = i18n.translate( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/index.tsx new file mode 100644 index 0000000000000..6ec76bacc2323 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut, EuiButton } from '@elastic/eui'; +import React, { memo, useCallback, useState } from 'react'; + +import * as i18n from './translations'; + +const ReadOnlyCallOutComponent = () => { + const [showCallOut, setShowCallOut] = useState(true); + const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]); + + return showCallOut ? ( + +

{i18n.READ_ONLY_CALLOUT_MSG}

+ + {i18n.DISMISS_CALLOUT} + +
+ ) : null; +}; + +export const ReadOnlyCallOut = memo(ReadOnlyCallOutComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/translations.ts new file mode 100644 index 0000000000000..c3429f4365031 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/read_only_callout/translations.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 { i18n } from '@kbn/i18n'; + +export const READ_ONLY_CALLOUT_TITLE = i18n.translate( + 'xpack.siem.detectionEngine.readOnlyCallOutTitle', + { + defaultMessage: 'Rule permissions required', + } +); + +export const READ_ONLY_CALLOUT_MSG = i18n.translate( + 'xpack.siem.detectionEngine.readOnlyCallOutMsg', + { + defaultMessage: + 'You are currently missing the required permissions to create/edit detection engine rule. Please contact your administrator for further assistance.', + } +); + +export const DISMISS_CALLOUT = i18n.translate('xpack.siem.detectionEngine.dismissButton', { + defaultMessage: 'Dismiss', +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/__snapshots__/index.test.tsx.snap index f264dde07c594..604f86866d565 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/__snapshots__/index.test.tsx.snap @@ -11,7 +11,6 @@ exports[`RuleSwitch renders correctly against snapshot 1`] = ` ; id: string; enabled: boolean; + isDisabled?: boolean; isLoading?: boolean; optionLabel?: string; } @@ -42,6 +43,7 @@ export interface RuleSwitchProps { export const RuleSwitchComponent = ({ dispatch, id, + isDisabled, isLoading, enabled, optionLabel, @@ -92,7 +94,7 @@ export const RuleSwitchComponent = ({ data-test-subj="rule-switch" label={optionLabel ?? ''} showLabel={!isEmpty(optionLabel)} - disabled={false} + disabled={isDisabled} checked={myEnabled} onChange={onRuleStateChange} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx index 9355f1c8bfefa..008a1b48610d6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx @@ -136,7 +136,7 @@ export const schema: FormSchema = { label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldFalsePositiveLabel', { - defaultMessage: 'False positives', + defaultMessage: 'False positives examples', } ), labelAppend: {RuleI18n.OPTIONAL_FIELD}, @@ -145,7 +145,7 @@ export const schema: FormSchema = { label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldMitreThreatLabel', { - defaultMessage: 'MITRE ATT&CK', + defaultMessage: 'MITRE ATT&CK\\u2122', } ), labelAppend: {RuleI18n.OPTIONAL_FIELD}, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts index 052986480e9ab..9323769765739 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/translations.ts @@ -9,14 +9,14 @@ import { i18n } from '@kbn/i18n'; export const ADD_REFERENCE = i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRuleForm.addReferenceDescription', { - defaultMessage: 'Add reference', + defaultMessage: 'Add reference URL', } ); export const ADD_FALSE_POSITIVE = i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRuleForm.addFalsePositiveDescription', { - defaultMessage: 'Add false positive', + defaultMessage: 'Add false positive example', } ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx index dbd7e3b3f96aa..079ec0dab4c5a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx @@ -38,7 +38,7 @@ export const schema: FormSchema = { i18n.translate( 'xpack.siem.detectionEngine.createRule.stepDefineRule.outputIndiceNameFieldRequiredError', { - defaultMessage: 'Index patterns for signals is required.', + defaultMessage: 'A minimum of one index pattern is required.', } ) ), 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 12bbdbdfff3e9..ce91e15cdcf0d 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 @@ -85,8 +85,12 @@ const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRuleJson => false_positives: falsePositives.filter(item => !isEmpty(item)), references: references.filter(item => !isEmpty(item)), risk_score: riskScore, - timeline_id: timeline.id, - timeline_title: timeline.title, + ...(timeline.id != null && timeline.title != null + ? { + timeline_id: timeline.id, + timeline_title: timeline.title, + } + : {}), threats: threats .filter(threat => threat.tactic.name !== 'none') .map(threat => ({ 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 848b17aadbff4..9a0f41bbd8c51 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 @@ -14,6 +14,7 @@ import { DETECTION_ENGINE_PAGE_NAME } from '../../../../components/link_to/redir import { WrapperPage } from '../../../../components/wrapper_page'; import { usePersistRule } from '../../../../containers/detection_engine/rules'; import { SpyRoute } from '../../../../utils/route/spy_routes'; +import { useUserInfo } from '../../components/user_info'; import { AccordionTitle } from '../components/accordion_title'; import { FormData, FormHook } from '../components/shared_imports'; import { StepAboutRule } from '../components/step_about_rule'; @@ -56,6 +57,13 @@ const MyEuiPanel = styled(EuiPanel)` `; export const CreateRuleComponent = React.memo(() => { + const { + loading, + isSignalIndexExists, + isAuthenticated, + canUserCRUD, + hasManageApiKey, + } = useUserInfo(); const [heightAccordion, setHeightAccordion] = useState(-1); const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule); const defineRuleRef = useRef(null); @@ -77,6 +85,18 @@ export const CreateRuleComponent = React.memo(() => { [RuleStep.scheduleRule]: false, }); const [{ isLoading, isSaved }, setRule] = usePersistRule(); + const userHasNoPermissions = + canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; + + if ( + isSignalIndexExists != null && + isAuthenticated != null && + (!isSignalIndexExists || !isAuthenticated) + ) { + return ; + } else if (userHasNoPermissions) { + return ; + } const setStepData = useCallback( (step: RuleStep, data: unknown, isValid: boolean) => { @@ -216,7 +236,7 @@ export const CreateRuleComponent = React.memo(() => { @@ -242,7 +262,7 @@ export const CreateRuleComponent = React.memo(() => { setHeightAccordion(height)} @@ -273,7 +293,7 @@ export const CreateRuleComponent = React.memo(() => { @@ -303,7 +323,7 @@ export const CreateRuleComponent = React.memo(() => { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx index 9b6998ab4a132..679f42f025196 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/details/index.tsx @@ -7,7 +7,7 @@ import { EuiButton, EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { memo, useCallback, useMemo } from 'react'; -import { useParams } from 'react-router-dom'; +import { Redirect, useParams } from 'react-router-dom'; import { StickyContainer } from 'react-sticky'; import { ActionCreator } from 'typescript-fsa'; @@ -28,13 +28,16 @@ import { SpyRoute } from '../../../../utils/route/spy_routes'; import { SignalsHistogramPanel } from '../../components/signals_histogram_panel'; import { SignalsTable } from '../../components/signals'; +import { useUserInfo } from '../../components/user_info'; import { DetectionEngineEmptyPage } from '../../detection_engine_empty_page'; import { useSignalInfo } from '../../components/signals_info'; import { StepAboutRule } from '../components/step_about_rule'; import { StepDefineRule } from '../components/step_define_rule'; import { StepScheduleRule } from '../components/step_schedule_rule'; import { buildSignalsRuleIdFilter } from '../../components/signals/default_config'; +import { NoWriteSignalsCallOut } from '../../components/no_write_signals_callout'; import * as detectionI18n from '../../translations'; +import { ReadOnlyCallOut } from '../components/read_only_callout'; import { RuleSwitch } from '../components/rule_switch'; import { StepPanel } from '../components/step_panel'; import { getStepsData } from '../helpers'; @@ -50,10 +53,6 @@ import { State } from '../../../../store'; import { InputsRange } from '../../../../store/inputs/model'; import { setAbsoluteRangeDatePicker as dispatchSetAbsoluteRangeDatePicker } from '../../../../store/inputs/actions'; -interface OwnProps { - signalsIndex: string | null; -} - interface ReduxProps { filters: esFilters.Filter[]; query: Query; @@ -67,22 +66,41 @@ export interface DispatchProps { }>; } -type RuleDetailsComponentProps = OwnProps & ReduxProps & DispatchProps; +type RuleDetailsComponentProps = ReduxProps & DispatchProps; const RuleDetailsComponent = memo( - ({ filters, query, setAbsoluteRangeDatePicker, signalsIndex }) => { + ({ filters, query, setAbsoluteRangeDatePicker }) => { + const { + loading, + isSignalIndexExists, + isAuthenticated, + canUserCRUD, + hasManageApiKey, + hasIndexWrite, + signalIndexName, + } = useUserInfo(); const { ruleId } = useParams(); - const [loading, rule] = useRule(ruleId); + const [isLoading, rule] = useRule(ruleId); const { aboutRuleData, defineRuleData, scheduleRuleData } = getStepsData({ rule, detailsView: true, }); const [lastSignals] = useSignalInfo({ ruleId }); + const userHasNoPermissions = + canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; + + if ( + isSignalIndexExists != null && + isAuthenticated != null && + (!isSignalIndexExists || !isAuthenticated) + ) { + return ; + } - const title = loading === true || rule === null ? : rule.name; + const title = isLoading === true || rule === null ? : rule.name; const subTitle = useMemo( () => - loading === true || rule === null ? ( + isLoading === true || rule === null ? ( ) : ( [ @@ -118,7 +136,7 @@ const RuleDetailsComponent = memo( ), ] ), - [loading, rule] + [isLoading, rule] ); const signalDefaultFilters = useMemo( @@ -140,6 +158,8 @@ const RuleDetailsComponent = memo( return ( <> + {hasIndexWrite != null && !hasIndexWrite && } + {userHasNoPermissions && } {({ indicesExist, indexPattern }) => { return indicesExistOrDataTemporarilyUnavailable(indicesExist) ? ( @@ -175,6 +195,7 @@ const RuleDetailsComponent = memo( @@ -186,7 +207,7 @@ const RuleDetailsComponent = memo( {ruleI18n.EDIT_RULE_SETTINGS} @@ -200,7 +221,7 @@ const RuleDetailsComponent = memo( - + {defineRuleData != null && ( ( - + {aboutRuleData != null && ( ( - + {scheduleRuleData != null && ( ( {ruleId != null && ( )} 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 10b7f0e832f19..e583461f52439 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 @@ -22,6 +22,7 @@ import { WrapperPage } from '../../../../components/wrapper_page'; import { SpyRoute } from '../../../../utils/route/spy_routes'; import { DETECTION_ENGINE_PAGE_NAME } from '../../../../components/link_to/redirect_to_detection_engine'; import { useRule, usePersistRule } from '../../../../containers/detection_engine/rules'; +import { useUserInfo } from '../../components/user_info'; import { FormHook, FormData } from '../components/shared_imports'; import { StepPanel } from '../components/step_panel'; import { StepAboutRule } from '../components/step_about_rule'; @@ -47,9 +48,28 @@ interface ScheduleStepRuleForm extends StepRuleForm { } export const EditRuleComponent = memo(() => { + const { + loading: initLoading, + isSignalIndexExists, + isAuthenticated, + canUserCRUD, + hasManageApiKey, + } = useUserInfo(); const { ruleId } = useParams(); const [loading, rule] = useRule(ruleId); + const userHasNoPermissions = + canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; + if ( + isSignalIndexExists != null && + isAuthenticated != null && + (!isSignalIndexExists || !isAuthenticated) + ) { + return ; + } else if (userHasNoPermissions) { + return ; + } + const [initForm, setInitForm] = useState(false); const [myAboutRuleForm, setMyAboutRuleForm] = useState({ data: null, @@ -89,7 +109,7 @@ export const EditRuleComponent = memo(() => { content: ( <> - + {myDefineRuleForm.data != null && ( { content: ( <> - + {myAboutRuleForm.data != null && ( { content: ( <> - + {myScheduleRuleForm.data != null && ( { ], [ loading, + initLoading, isLoading, myAboutRuleForm, myDefineRuleForm, @@ -310,7 +331,13 @@ export const EditRuleComponent = memo(() => { - + {i18n.SAVE_CHANGES} 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 47b5c1051bcfc..cc0882dd7e426 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 @@ -5,6 +5,7 @@ */ import { pick } from 'lodash/fp'; +import { useLocation } from 'react-router-dom'; import { esFilters } from '../../../../../../../../src/plugins/data/public'; import { Rule } from '../../../containers/detection_engine/rules'; @@ -64,3 +65,5 @@ export const getStepsData = ({ return { aboutRuleData, defineRuleData, scheduleRuleData }; }; + +export const useQuery = () => new URLSearchParams(useLocation().search); 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 ef67f0a7d22c6..dd46b33ca7257 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 @@ -5,8 +5,9 @@ */ import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import React, { useState } from 'react'; +import { Redirect } from 'react-router-dom'; import { DETECTION_ENGINE_PAGE_NAME } from '../../../components/link_to/redirect_to_detection_engine'; import { FormattedRelativePreferenceDate } from '../../../components/formatted_date'; @@ -17,15 +18,34 @@ import { SpyRoute } from '../../../utils/route/spy_routes'; import { AllRules } from './all'; import { ImportRuleModal } from './components/import_rule_modal'; +import { ReadOnlyCallOut } from './components/read_only_callout'; +import { useUserInfo } from '../components/user_info'; import * as i18n from './translations'; export const RulesComponent = React.memo(() => { const [showImportModal, setShowImportModal] = useState(false); const [importCompleteToggle, setImportCompleteToggle] = useState(false); + const { + loading, + isSignalIndexExists, + isAuthenticated, + canUserCRUD, + hasManageApiKey, + } = useUserInfo(); + if ( + isSignalIndexExists != null && + isAuthenticated != null && + (!isSignalIndexExists || !isAuthenticated) + ) { + return ; + } + const userHasNoPermissions = + canUserCRUD != null && hasManageApiKey != null ? !canUserCRUD || !hasManageApiKey : false; const lastCompletedRun = undefined; return ( <> + {userHasNoPermissions && } setShowImportModal(false)} @@ -56,6 +76,7 @@ export const RulesComponent = React.memo(() => { { setShowImportModal(true); }} @@ -63,20 +84,23 @@ export const RulesComponent = React.memo(() => { {i18n.IMPORT_RULE} - {i18n.ADD_NEW_RULE} - - +
diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts index ecd6bef942bfb..8d4407b9f73e8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts @@ -207,15 +207,15 @@ export const COLUMN_ACTIVATE = i18n.translate( ); export const DEFINE_RULE = i18n.translate('xpack.siem.detectionEngine.rules.defineRuleTitle', { - defaultMessage: 'Define Rule', + defaultMessage: 'Define rule', }); export const ABOUT_RULE = i18n.translate('xpack.siem.detectionEngine.rules.aboutRuleTitle', { - defaultMessage: 'About Rule', + defaultMessage: 'About rule', }); export const SCHEDULE_RULE = i18n.translate('xpack.siem.detectionEngine.rules.scheduleRuleTitle', { - defaultMessage: 'Schedule Rule', + defaultMessage: 'Schedule rule', }); export const DEFINITION = i18n.translate('xpack.siem.detectionEngine.rules.stepDefinitionTitle', { 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 ec4206623bad9..541b058951be7 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 @@ -109,8 +109,8 @@ export interface AboutStepRuleJson { references: string[]; false_positives: string[]; tags: string[]; - timeline_id: string | null; - timeline_title: string | null; + timeline_id?: string; + timeline_title?: string; threats: IMitreEnterpriseAttack[]; } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.test.ts new file mode 100644 index 0000000000000..cb358c15e5fad --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.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 { getIndexExists } from './get_index_exists'; + +class StatusCode extends Error { + status: number = -1; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +describe('get_index_exists', () => { + test('it should return a true if you have _shards', async () => { + const callWithRequest = jest.fn().mockResolvedValue({ _shards: { total: 1 } }); + const indexExists = await getIndexExists(callWithRequest, 'some-index'); + expect(indexExists).toEqual(true); + }); + + test('it should return a false if you do NOT have _shards', async () => { + const callWithRequest = jest.fn().mockResolvedValue({ _shards: { total: 0 } }); + const indexExists = await getIndexExists(callWithRequest, 'some-index'); + expect(indexExists).toEqual(false); + }); + + test('it should return a false if it encounters a 404', async () => { + const callWithRequest = jest.fn().mockImplementation(() => { + throw new StatusCode(404, 'I am a 404 error'); + }); + const indexExists = await getIndexExists(callWithRequest, 'some-index'); + expect(indexExists).toEqual(false); + }); + + test('it should reject if it encounters a non 404', async () => { + const callWithRequest = jest.fn().mockImplementation(() => { + throw new StatusCode(500, 'I am a 500 error'); + }); + await expect(getIndexExists(callWithRequest, 'some-index')).rejects.toThrow('I am a 500 error'); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.ts index ff65caa59a866..705f542b50124 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/index/get_index_exists.ts @@ -4,15 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IndicesExistsParams } from 'elasticsearch'; -import { CallClusterOptions } from 'src/legacy/core_plugins/elasticsearch'; import { CallWithRequest } from '../types'; export const getIndexExists = async ( - callWithRequest: CallWithRequest, + callWithRequest: CallWithRequest< + { index: string; size: number; terminate_after: number; allow_no_indices: boolean }, + {}, + { _shards: { total: number } } + >, index: string ): Promise => { - return callWithRequest('indices.exists', { - index, - }); + try { + const response = await callWithRequest('search', { + index, + size: 0, + terminate_after: 1, + allow_no_indices: true, + }); + return response._shards.total > 0; + } catch (err) { + if (err.status === 404) { + return false; + } else { + throw err; + } + } }; 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 094449a5f61ac..10dc14f7ed610 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 @@ -28,7 +28,9 @@ describe('create_rules', () => { jest.resetAllMocks(); ({ server, alertsClient, actionsClient, elasticsearch } = createMockServer()); elasticsearch.getCluster = jest.fn().mockImplementation(() => ({ - callWithRequest: jest.fn().mockImplementation(() => true), + callWithRequest: jest + .fn() + .mockImplementation((endpoint, params) => ({ _shards: { total: 1 } })), })); createRulesRoute(server); diff --git a/x-pack/legacy/plugins/siem/server/plugin.ts b/x-pack/legacy/plugins/siem/server/plugin.ts index 8a47aa2a27082..90ae79ef19d5b 100644 --- a/x-pack/legacy/plugins/siem/server/plugin.ts +++ b/x-pack/legacy/plugins/siem/server/plugin.ts @@ -60,7 +60,7 @@ export class Plugin { ], read: ['config'], }, - ui: ['show'], + ui: ['show', 'crud'], }, read: { api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'], diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/home.helpers.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/home.helpers.ts index 79a4eeb6dc48b..777471e209adc 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/home.helpers.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/home.helpers.ts @@ -99,7 +99,7 @@ export const setup = async (): Promise => { const tabs = ['snapshots', 'repositories']; testBed - .find('tab') + .find(`${tab}_tab`) .at(tabs.indexOf(tab)) .simulate('click'); }; @@ -360,7 +360,10 @@ export type TestSubjects = | 'state' | 'state.title' | 'state.value' - | 'tab' + | 'repositories_tab' + | 'snapshots_tab' + | 'policies_tab' + | 'restore_status_tab' | 'tableHeaderCell_durationInMillis_3' | 'tableHeaderCell_durationInMillis_3.tableHeaderSortButton' | 'tableHeaderCell_indices_4' diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts index d9f2c1b510a14..cb2e94df75609 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/helpers/http_requests.ts @@ -95,6 +95,16 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { ]); }; + const setCleanupRepositoryResponse = (response?: HttpResponse, error?: any) => { + const status = error ? error.status || 503 : 200; + + server.respondWith('POST', `${API_BASE_PATH}repositories/:name/cleanup`, [ + status, + { 'Content-Type': 'application/json' }, + JSON.stringify(response), + ]); + }; + const setGetPolicyResponse = (response?: HttpResponse) => { server.respondWith('GET', `${API_BASE_PATH}policy/:name`, [ 200, @@ -113,6 +123,7 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { setLoadIndicesResponse, setAddPolicyResponse, setGetPolicyResponse, + setCleanupRepositoryResponse, }; }; diff --git a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts index aa659441043ae..517c7a0059a7e 100644 --- a/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts +++ b/x-pack/legacy/plugins/snapshot_restore/__jest__/client_integration/home.test.ts @@ -88,8 +88,15 @@ describe('', () => { test('should have 4 tabs', () => { const { find } = testBed; - expect(find('tab').length).toBe(4); - expect(find('tab').map(t => t.text())).toEqual([ + const tabs = [ + find('snapshots_tab'), + find('repositories_tab'), + find('policies_tab'), + find('restore_status_tab'), + ]; + + expect(tabs.length).toBe(4); + expect(tabs.map(t => t.text())).toEqual([ 'Snapshots', 'Repositories', 'Policies', diff --git a/x-pack/legacy/plugins/snapshot_restore/common/types/repository.ts b/x-pack/legacy/plugins/snapshot_restore/common/types/repository.ts index 5900d53afa0b4..b9b26c5590324 100644 --- a/x-pack/legacy/plugins/snapshot_restore/common/types/repository.ts +++ b/x-pack/legacy/plugins/snapshot_restore/common/types/repository.ts @@ -157,3 +157,15 @@ export interface InvalidRepositoryVerification { } export type RepositoryVerification = ValidRepositoryVerification | InvalidRepositoryVerification; + +export interface SuccessfulRepositoryCleanup { + cleaned: true; + response: object; +} + +export interface FailedRepositoryCleanup { + cleaned: false; + error: object; +} + +export type RepositoryCleanup = FailedRepositoryCleanup | SuccessfulRepositoryCleanup; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts index 844394deb4f8d..481516479df4e 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/constants/index.ts @@ -103,6 +103,7 @@ export const UIM_REPOSITORY_DELETE = 'repository_delete'; export const UIM_REPOSITORY_DELETE_MANY = 'repository_delete_many'; export const UIM_REPOSITORY_SHOW_DETAILS_CLICK = 'repository_show_details_click'; export const UIM_REPOSITORY_DETAIL_PANEL_VERIFY = 'repository_detail_panel_verify'; +export const UIM_REPOSITORY_DETAIL_PANEL_CLEANUP = 'repository_detail_panel_cleanup'; export const UIM_SNAPSHOT_LIST_LOAD = 'snapshot_list_load'; export const UIM_SNAPSHOT_SHOW_DETAILS_CLICK = 'snapshot_show_details_click'; export const UIM_SNAPSHOT_DETAIL_PANEL_SUMMARY_TAB = 'snapshot_detail_panel_summary_tab'; diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/home.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/home.tsx index 35d5c0b610b3c..f89aa869b3366 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/home.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/home.tsx @@ -150,7 +150,7 @@ export const SnapshotRestoreHome: React.FunctionComponent onSectionChange(tab.id)} isSelected={tab.id === section} key={tab.id} - data-test-subj="tab" + data-test-subj={tab.id.toLowerCase() + '_tab'} > {tab.name} diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_details/repository_details.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_details/repository_details.tsx index c03162bae8bd2..0a3fcfc2ec6e7 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_details/repository_details.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_details/repository_details.tsx @@ -8,7 +8,6 @@ import { EuiButton, EuiButtonEmpty, EuiCallOut, - EuiCodeEditor, EuiFlexGroup, EuiFlexItem, EuiFlyout, @@ -19,6 +18,8 @@ import { EuiLink, EuiSpacer, EuiTitle, + EuiCodeBlock, + EuiText, } from '@elastic/eui'; import 'brace/theme/textmate'; @@ -28,12 +29,17 @@ import { documentationLinksService } from '../../../../services/documentation'; import { useLoadRepository, verifyRepository as verifyRepositoryRequest, + cleanupRepository as cleanupRepositoryRequest, } from '../../../../services/http'; import { textService } from '../../../../services/text'; import { linkToSnapshots, linkToEditRepository } from '../../../../services/navigation'; import { REPOSITORY_TYPES } from '../../../../../../common/constants'; -import { Repository, RepositoryVerification } from '../../../../../../common/types'; +import { + Repository, + RepositoryVerification, + RepositoryCleanup, +} from '../../../../../../common/types'; import { RepositoryDeleteProvider, SectionError, @@ -61,7 +67,9 @@ export const RepositoryDetails: React.FunctionComponent = ({ const { FormattedMessage } = i18n; const { error, data: repositoryDetails } = useLoadRepository(repositoryName); const [verification, setVerification] = useState(undefined); + const [cleanup, setCleanup] = useState(undefined); const [isLoadingVerification, setIsLoadingVerification] = useState(false); + const [isLoadingCleanup, setIsLoadingCleanup] = useState(false); const verifyRepository = async () => { setIsLoadingVerification(true); @@ -70,11 +78,20 @@ export const RepositoryDetails: React.FunctionComponent = ({ setIsLoadingVerification(false); }; - // Reset verification state when repository name changes, either from adjust URL or clicking + const cleanupRepository = async () => { + setIsLoadingCleanup(true); + const { data } = await cleanupRepositoryRequest(repositoryName); + setCleanup(data.cleanup); + setIsLoadingCleanup(false); + }; + + // Reset verification state and cleanup when repository name changes, either from adjust URL or clicking // into a different repository in table list. useEffect(() => { setVerification(undefined); setIsLoadingVerification(false); + setCleanup(undefined); + setIsLoadingCleanup(false); }, [repositoryName]); const renderBody = () => { @@ -231,6 +248,8 @@ export const RepositoryDetails: React.FunctionComponent = ({ {renderVerification()} + + {renderCleanup()} ); }; @@ -260,36 +279,13 @@ export const RepositoryDetails: React.FunctionComponent = ({ {verification ? ( - + {JSON.stringify( verification.valid ? verification.response : verification.error, null, 2 )} - setOptions={{ - showLineNumbers: false, - tabSize: 2, - maxLines: Infinity, - }} - editorProps={{ - $blockScrolling: Infinity, - }} - showGutter={false} - minLines={6} - aria-label={ - - } - /> + ) : null} @@ -318,6 +314,78 @@ export const RepositoryDetails: React.FunctionComponent = ({ ); + const renderCleanup = () => ( + <> + +

+ +

+
+ + +

+ +

+
+ {cleanup ? ( + <> + + {cleanup.cleaned ? ( +
+ +

+ +

+
+ + {JSON.stringify(cleanup.response, null, 2)} + +
+ ) : ( + +

+ {cleanup.error + ? JSON.stringify(cleanup.error) + : i18n.translate('xpack.snapshotRestore.repositoryDetails.cleanupUnknownError', { + defaultMessage: '503: Unknown error', + })} +

+
+ )} + + ) : null} + + + + + + ); + const renderFooter = () => { return ( diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_table/repository_table.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_table/repository_table.tsx index 4b5270b44d593..1df06f67c35b1 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_table/repository_table.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/sections/home/repository_list/repository_table/repository_table.tsx @@ -96,6 +96,7 @@ export const RepositoryTable: React.FunctionComponent = ({ }, }, { + field: 'actions', name: i18n.translate('xpack.snapshotRestore.repositoryList.table.actionsColumnTitle', { defaultMessage: 'Actions', }), @@ -302,8 +303,8 @@ export const RepositoryTable: React.FunctionComponent = ({ rowProps={() => ({ 'data-test-subj': 'row', })} - cellProps={() => ({ - 'data-test-subj': 'cell', + cellProps={(item, field) => ({ + 'data-test-subj': `${field.name}_cell`, })} data-test-subj="repositoryTable" /> diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/repository_requests.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/repository_requests.ts index 171e949ccee75..b92f21ea6a9b6 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/repository_requests.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/http/repository_requests.ts @@ -11,6 +11,7 @@ import { UIM_REPOSITORY_DELETE, UIM_REPOSITORY_DELETE_MANY, UIM_REPOSITORY_DETAIL_PANEL_VERIFY, + UIM_REPOSITORY_DETAIL_PANEL_CLEANUP, } from '../../constants'; import { uiMetricService } from '../ui_metric'; import { httpService } from './http'; @@ -44,6 +45,20 @@ export const verifyRepository = async (name: Repository['name']) => { return result; }; +export const cleanupRepository = async (name: Repository['name']) => { + const result = await sendRequest({ + path: httpService.addBasePath( + `${API_BASE_PATH}repositories/${encodeURIComponent(name)}/cleanup` + ), + method: 'post', + body: undefined, + }); + + const { trackUiMetric } = uiMetricService; + trackUiMetric(UIM_REPOSITORY_DETAIL_PANEL_CLEANUP); + return result; +}; + export const useLoadRepositoryTypes = () => { return useRequest({ path: httpService.addBasePath(`${API_BASE_PATH}repository_types`), diff --git a/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts b/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_sr.ts similarity index 73% rename from x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts rename to x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_sr.ts index 82fe30aaa7d2e..794bf99c3d918 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_slm.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/client/elasticsearch_sr.ts @@ -7,10 +7,10 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) => { const ca = components.clientAction.factory; - Client.prototype.slm = components.clientAction.namespaceFactory(); - const slm = Client.prototype.slm.prototype; + Client.prototype.sr = components.clientAction.namespaceFactory(); + const sr = Client.prototype.sr.prototype; - slm.policies = ca({ + sr.policies = ca({ urls: [ { fmt: '/_slm/policy', @@ -19,7 +19,7 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'GET', }); - slm.policy = ca({ + sr.policy = ca({ urls: [ { fmt: '/_slm/policy/<%=name%>', @@ -33,7 +33,7 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'GET', }); - slm.deletePolicy = ca({ + sr.deletePolicy = ca({ urls: [ { fmt: '/_slm/policy/<%=name%>', @@ -47,7 +47,7 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'DELETE', }); - slm.executePolicy = ca({ + sr.executePolicy = ca({ urls: [ { fmt: '/_slm/policy/<%=name%>/_execute', @@ -61,7 +61,7 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'PUT', }); - slm.updatePolicy = ca({ + sr.updatePolicy = ca({ urls: [ { fmt: '/_slm/policy/<%=name%>', @@ -75,7 +75,7 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) method: 'PUT', }); - slm.executeRetention = ca({ + sr.executeRetention = ca({ urls: [ { fmt: '/_slm/_execute_retention', @@ -83,4 +83,18 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) ], method: 'POST', }); + + sr.cleanupRepository = ca({ + urls: [ + { + fmt: '/_snapshot/<%=name%>/_cleanup', + req: { + name: { + type: 'string', + }, + }, + }, + ], + method: 'POST', + }); }; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts index bbfc82b8a6de9..9f434ac10c16a 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/policy.ts @@ -40,7 +40,7 @@ export const getAllHandler: RouterRouteHandler = async ( // Get policies const policiesByName: { [key: string]: SlmPolicyEs; - } = await callWithRequest('slm.policies', { + } = await callWithRequest('sr.policies', { human: true, }); @@ -62,7 +62,7 @@ export const getOneHandler: RouterRouteHandler = async ( const { name } = req.params; const policiesByName: { [key: string]: SlmPolicyEs; - } = await callWithRequest('slm.policy', { + } = await callWithRequest('sr.policy', { name, human: true, }); @@ -82,7 +82,7 @@ export const getOneHandler: RouterRouteHandler = async ( export const executeHandler: RouterRouteHandler = async (req, callWithRequest) => { const { name } = req.params; - const { snapshot_name: snapshotName } = await callWithRequest('slm.executePolicy', { + const { snapshot_name: snapshotName } = await callWithRequest('sr.executePolicy', { name, }); return { snapshotName }; @@ -98,7 +98,7 @@ export const deleteHandler: RouterRouteHandler = async (req, callWithRequest) => await Promise.all( policyNames.map(name => { - return callWithRequest('slm.deletePolicy', { name }) + return callWithRequest('sr.deletePolicy', { name }) .then(() => response.itemsDeleted.push(name)) .catch(e => response.errors.push({ @@ -122,7 +122,7 @@ export const createHandler: RouterRouteHandler = async (req, callWithRequest) => // Check that policy with the same name doesn't already exist try { - const policyByName = await callWithRequest('slm.policy', { name }); + const policyByName = await callWithRequest('sr.policy', { name }); if (policyByName[name]) { throw conflictError; } @@ -134,7 +134,7 @@ export const createHandler: RouterRouteHandler = async (req, callWithRequest) => } // Otherwise create new policy - return await callWithRequest('slm.updatePolicy', { + return await callWithRequest('sr.updatePolicy', { name, body: serializePolicy(policy), }); @@ -146,10 +146,10 @@ export const updateHandler: RouterRouteHandler = async (req, callWithRequest) => // Check that policy with the given name exists // If it doesn't exist, 404 will be thrown by ES and will be returned - await callWithRequest('slm.policy', { name }); + await callWithRequest('sr.policy', { name }); // Otherwise update policy - return await callWithRequest('slm.updatePolicy', { + return await callWithRequest('sr.updatePolicy', { name, body: serializePolicy(policy), }); @@ -210,5 +210,5 @@ export const updateRetentionSettingsHandler: RouterRouteHandler = async (req, ca }; export const executeRetentionHandler: RouterRouteHandler = async (_req, callWithRequest) => { - return await callWithRequest('slm.executeRetention'); + return await callWithRequest('sr.executeRetention'); }; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/repositories.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/repositories.ts index f6ac946ab07d5..3d67494da4aad 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/repositories.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/repositories.ts @@ -15,6 +15,7 @@ import { RepositoryType, RepositoryVerification, SlmPolicyEs, + RepositoryCleanup, } from '../../../common/types'; import { Plugins } from '../../shim'; @@ -34,6 +35,7 @@ export function registerRepositoriesRoutes(router: Router, plugins: Plugins) { router.get('repositories', getAllHandler); router.get('repositories/{name}', getOneHandler); router.get('repositories/{name}/verify', getVerificationHandler); + router.post('repositories/{name}/cleanup', getCleanupHandler); router.put('repositories', createHandler); router.put('repositories/{name}', updateHandler); router.delete('repositories/{names}', deleteHandler); @@ -74,7 +76,7 @@ export const getAllHandler: RouterRouteHandler = async ( try { const policiesByName: { [key: string]: SlmPolicyEs; - } = await callWithRequest('slm.policies', { + } = await callWithRequest('sr.policies', { human: true, }); const managedRepositoryPolicy = Object.entries(policiesByName) @@ -172,6 +174,31 @@ export const getVerificationHandler: RouterRouteHandler = async ( }; }; +export const getCleanupHandler: RouterRouteHandler = async ( + req, + callWithRequest +): Promise<{ + cleanup: RepositoryCleanup | {}; +}> => { + const { name } = req.params; + + const cleanupResults = await callWithRequest('sr.cleanupRepository', { + name, + }).catch(e => ({ + cleaned: false, + error: e.response ? JSON.parse(e.response) : e, + })); + + return { + cleanup: cleanupResults.error + ? cleanupResults + : { + cleaned: true, + response: cleanupResults, + }, + }; +}; + export const getTypesHandler: RouterRouteHandler = async () => { // In ECE/ESS, do not enable the default types const types: RepositoryType[] = isCloudEnabled ? [] : [...DEFAULT_REPOSITORY_TYPES]; diff --git a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.ts b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.ts index 042a2dfeaf6b5..0d34d6a6b1b31 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/routes/api/snapshots.ts @@ -38,7 +38,7 @@ export const getAllHandler: RouterRouteHandler = async ( // Attempt to retrieve policies // This could fail if user doesn't have access to read SLM policies try { - const policiesByName = await callWithRequest('slm.policies'); + const policiesByName = await callWithRequest('sr.policies'); policies = Object.keys(policiesByName); } catch (e) { // Silently swallow error as policy names aren't required in UI diff --git a/x-pack/legacy/plugins/snapshot_restore/server/shim.ts b/x-pack/legacy/plugins/snapshot_restore/server/shim.ts index 84c9ddf8e0bea..d64f35c64f11e 100644 --- a/x-pack/legacy/plugins/snapshot_restore/server/shim.ts +++ b/x-pack/legacy/plugins/snapshot_restore/server/shim.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { Legacy } from 'kibana'; import { createRouter, Router } from '../../../server/lib/create_router'; import { registerLicenseChecker } from '../../../server/lib/register_license_checker'; -import { elasticsearchJsPlugin } from './client/elasticsearch_slm'; +import { elasticsearchJsPlugin } from './client/elasticsearch_sr'; import { CloudSetup } from '../../../../plugins/cloud/server'; export interface Core { http: { diff --git a/x-pack/package.json b/x-pack/package.json index ffa593f5728ee..1e20157831ba5 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -192,6 +192,7 @@ "@scant/router": "^0.1.0", "@slack/webhook": "^5.0.0", "@turf/boolean-contains": "6.0.1", + "angular": "^1.7.9", "angular-resource": "1.7.8", "angular-sanitize": "1.7.8", "angular-ui-ace": "0.2.3", @@ -342,7 +343,7 @@ "uuid": "3.3.2", "venn.js": "0.2.20", "vscode-languageserver": "^5.2.1", - "webpack": "4.33.0", + "webpack": "4.41.0", "wellknown": "^0.5.0", "xml2js": "^0.4.22", "xregexp": "4.2.4" diff --git a/x-pack/plugins/graph/server/routes/explore.ts b/x-pack/plugins/graph/server/routes/explore.ts index 0a5b9f62f12a1..125378891151b 100644 --- a/x-pack/plugins/graph/server/routes/explore.ts +++ b/x-pack/plugins/graph/server/routes/explore.ts @@ -71,7 +71,11 @@ export function registerExploreRoute({ throw Boom.badRequest(relevantCause.reason); } - throw Boom.boomify(error); + return response.internalError({ + body: { + message: error.message, + }, + }); } } ) diff --git a/x-pack/plugins/graph/server/routes/search.ts b/x-pack/plugins/graph/server/routes/search.ts index 400cdc4e82b6e..91b404dc7cb91 100644 --- a/x-pack/plugins/graph/server/routes/search.ts +++ b/x-pack/plugins/graph/server/routes/search.ts @@ -6,7 +6,6 @@ import { IRouter } from 'kibana/server'; import { schema } from '@kbn/config-schema'; -import Boom from 'boom'; import { LicenseState, verifyApiAccess } from '../lib/license_state'; export function registerSearchRoute({ @@ -53,7 +52,12 @@ export function registerSearchRoute({ }, }); } catch (error) { - throw Boom.boomify(error, { statusCode: error.statusCode || 500 }); + return response.customError({ + statusCode: error.statusCode || 500, + body: { + message: error.message, + }, + }); } } ) diff --git a/x-pack/plugins/security/common/licensing/license_features.ts b/x-pack/plugins/security/common/licensing/license_features.ts index 6b6c86d48c21e..33f8370a1b43e 100644 --- a/x-pack/plugins/security/common/licensing/license_features.ts +++ b/x-pack/plugins/security/common/licensing/license_features.ts @@ -23,6 +23,11 @@ export interface SecurityLicenseFeatures { */ readonly showLinks: boolean; + /** + * Indicates whether we show the Role Mappings UI. + */ + readonly showRoleMappingsManagement: boolean; + /** * Indicates whether we allow users to define document level security in roles. */ diff --git a/x-pack/plugins/security/common/licensing/license_service.test.ts b/x-pack/plugins/security/common/licensing/license_service.test.ts index f4fa5e00e2387..df2d66a036039 100644 --- a/x-pack/plugins/security/common/licensing/license_service.test.ts +++ b/x-pack/plugins/security/common/licensing/license_service.test.ts @@ -17,6 +17,7 @@ describe('license features', function() { showLogin: true, allowLogin: false, showLinks: false, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, layout: 'error-es-unavailable', @@ -34,6 +35,7 @@ describe('license features', function() { showLogin: true, allowLogin: false, showLinks: false, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, layout: 'error-xpack-unavailable', @@ -63,6 +65,7 @@ describe('license features', function() { "layout": "error-xpack-unavailable", "showLinks": false, "showLogin": true, + "showRoleMappingsManagement": false, }, ] `); @@ -79,6 +82,7 @@ describe('license features', function() { "linksMessage": "Access is denied because Security is disabled in Elasticsearch.", "showLinks": false, "showLogin": false, + "showRoleMappingsManagement": false, }, ] `); @@ -87,10 +91,12 @@ describe('license features', function() { } }); - it('should show login page and other security elements, allow RBAC but forbid document level security if license is not platinum or trial.', () => { - const mockRawLicense = licensingMock.createLicenseMock(); - mockRawLicense.hasAtLeast.mockReturnValue(false); - mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true }); + it('should show login page and other security elements, allow RBAC but forbid role mappings and document level security if license is basic.', () => { + const mockRawLicense = licensingMock.createLicense({ + features: { security: { isEnabled: true, isAvailable: true } }, + }); + + const getFeatureSpy = jest.spyOn(mockRawLicense, 'getFeature'); const serviceSetup = new SecurityLicenseService().setup({ license$: of(mockRawLicense), @@ -99,18 +105,19 @@ describe('license features', function() { showLogin: true, allowLogin: true, showLinks: true, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: true, }); - expect(mockRawLicense.getFeature).toHaveBeenCalledTimes(1); - expect(mockRawLicense.getFeature).toHaveBeenCalledWith('security'); + expect(getFeatureSpy).toHaveBeenCalledTimes(1); + expect(getFeatureSpy).toHaveBeenCalledWith('security'); }); it('should not show login page or other security elements if security is disabled in Elasticsearch.', () => { - const mockRawLicense = licensingMock.createLicenseMock(); - mockRawLicense.hasAtLeast.mockReturnValue(false); - mockRawLicense.getFeature.mockReturnValue({ isEnabled: false, isAvailable: true }); + const mockRawLicense = licensingMock.createLicense({ + features: { security: { isEnabled: false, isAvailable: true } }, + }); const serviceSetup = new SecurityLicenseService().setup({ license$: of(mockRawLicense), @@ -119,6 +126,7 @@ describe('license features', function() { showLogin: false, allowLogin: false, showLinks: false, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, @@ -126,12 +134,31 @@ describe('license features', function() { }); }); - it('should allow to login, allow RBAC and document level security if license >= platinum', () => { - const mockRawLicense = licensingMock.createLicenseMock(); - mockRawLicense.hasAtLeast.mockImplementation(license => { - return license === 'trial' || license === 'platinum' || license === 'enterprise'; + it('should allow role mappings, but not DLS/FLS if license = gold', () => { + const mockRawLicense = licensingMock.createLicense({ + license: { mode: 'gold', type: 'gold' }, + features: { security: { isEnabled: true, isAvailable: true } }, + }); + + const serviceSetup = new SecurityLicenseService().setup({ + license$: of(mockRawLicense), + }); + expect(serviceSetup.license.getFeatures()).toEqual({ + showLogin: true, + allowLogin: true, + showLinks: true, + showRoleMappingsManagement: true, + allowRoleDocumentLevelSecurity: false, + allowRoleFieldLevelSecurity: false, + allowRbac: true, + }); + }); + + it('should allow to login, allow RBAC, allow role mappings, and document level security if license >= platinum', () => { + const mockRawLicense = licensingMock.createLicense({ + license: { mode: 'platinum', type: 'platinum' }, + features: { security: { isEnabled: true, isAvailable: true } }, }); - mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true }); const serviceSetup = new SecurityLicenseService().setup({ license$: of(mockRawLicense), @@ -140,6 +167,7 @@ describe('license features', function() { showLogin: true, allowLogin: true, showLinks: true, + showRoleMappingsManagement: true, allowRoleDocumentLevelSecurity: true, allowRoleFieldLevelSecurity: true, allowRbac: true, diff --git a/x-pack/plugins/security/common/licensing/license_service.ts b/x-pack/plugins/security/common/licensing/license_service.ts index 0f9da03f9f6ec..e6d2eff49ed0d 100644 --- a/x-pack/plugins/security/common/licensing/license_service.ts +++ b/x-pack/plugins/security/common/licensing/license_service.ts @@ -70,6 +70,7 @@ export class SecurityLicenseService { showLogin: true, allowLogin: false, showLinks: false, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, @@ -85,6 +86,7 @@ export class SecurityLicenseService { showLogin: false, allowLogin: false, showLinks: false, + showRoleMappingsManagement: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, @@ -92,11 +94,13 @@ export class SecurityLicenseService { }; } + const showRoleMappingsManagement = rawLicense.hasAtLeast('gold'); const isLicensePlatinumOrBetter = rawLicense.hasAtLeast('platinum'); return { showLogin: true, allowLogin: true, showLinks: true, + showRoleMappingsManagement, // 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 226ea3b70afe2..f3c65ed7e3cf1 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -12,3 +12,10 @@ export { FeaturesPrivileges } from './features_privileges'; export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges'; export { Role, RoleIndexPrivilege, RoleKibanaPrivilege } from './role'; export { KibanaPrivileges } from './kibana_privileges'; +export { + InlineRoleTemplate, + StoredRoleTemplate, + InvalidRoleTemplate, + RoleTemplate, + RoleMapping, +} from './role_mapping'; diff --git a/x-pack/plugins/security/common/model/role_mapping.ts b/x-pack/plugins/security/common/model/role_mapping.ts new file mode 100644 index 0000000000000..99de183f648f7 --- /dev/null +++ b/x-pack/plugins/security/common/model/role_mapping.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. + */ + +interface RoleMappingAnyRule { + any: RoleMappingRule[]; +} + +interface RoleMappingAllRule { + all: RoleMappingRule[]; +} + +interface RoleMappingFieldRule { + field: Record; +} + +interface RoleMappingExceptRule { + except: RoleMappingRule; +} + +type RoleMappingRule = + | RoleMappingAnyRule + | RoleMappingAllRule + | RoleMappingFieldRule + | RoleMappingExceptRule; + +type RoleTemplateFormat = 'string' | 'json'; + +export interface InlineRoleTemplate { + template: { source: string }; + format?: RoleTemplateFormat; +} + +export interface StoredRoleTemplate { + template: { id: string }; + format?: RoleTemplateFormat; +} + +export interface InvalidRoleTemplate { + template: string; + format?: RoleTemplateFormat; +} + +export type RoleTemplate = InlineRoleTemplate | StoredRoleTemplate | InvalidRoleTemplate; + +export interface RoleMapping { + name: string; + enabled: boolean; + roles?: string[]; + role_templates?: RoleTemplate[]; + rules: RoleMappingRule | {}; + metadata: Record; +} diff --git a/x-pack/plugins/security/server/elasticsearch_client_plugin.ts b/x-pack/plugins/security/server/elasticsearch_client_plugin.ts index 60d947bd65863..996dcb685f29b 100644 --- a/x-pack/plugins/security/server/elasticsearch_client_plugin.ts +++ b/x-pack/plugins/security/server/elasticsearch_client_plugin.ts @@ -573,4 +573,64 @@ export function elasticsearchClientPlugin(Client: any, config: unknown, componen fmt: '/_security/delegate_pki', }, }); + + /** + * Retrieves all configured role mappings. + * + * @returns {{ [roleMappingName]: { enabled: boolean; roles: string[]; rules: Record} }} + */ + shield.getRoleMappings = ca({ + method: 'GET', + urls: [ + { + fmt: '/_security/role_mapping', + }, + { + fmt: '/_security/role_mapping/<%=name%>', + req: { + name: { + type: 'string', + required: true, + }, + }, + }, + ], + }); + + /** + * Saves the specified role mapping. + */ + shield.saveRoleMapping = ca({ + method: 'POST', + needBody: true, + urls: [ + { + fmt: '/_security/role_mapping/<%=name%>', + req: { + name: { + type: 'string', + required: true, + }, + }, + }, + ], + }); + + /** + * Deletes the specified role mapping. + */ + shield.deleteRoleMapping = ca({ + method: 'DELETE', + urls: [ + { + fmt: '/_security/role_mapping/<%=name%>', + req: { + name: { + type: 'string', + required: true, + }, + }, + }, + ], + }); } diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index ade840e7ca495..01df67cacb800 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -14,6 +14,7 @@ import { defineAuthorizationRoutes } from './authorization'; import { defineApiKeysRoutes } from './api_keys'; import { defineIndicesRoutes } from './indices'; import { defineUsersRoutes } from './users'; +import { defineRoleMappingRoutes } from './role_mapping'; /** * Describes parameters used to define HTTP routes. @@ -35,4 +36,5 @@ export function defineRoutes(params: RouteDefinitionParams) { defineApiKeysRoutes(params); defineIndicesRoutes(params); defineUsersRoutes(params); + defineRoleMappingRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/role_mapping/delete.test.ts b/x-pack/plugins/security/server/routes/role_mapping/delete.test.ts new file mode 100644 index 0000000000000..e8a8a7216330b --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/delete.test.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 { routeDefinitionParamsMock } from '../index.mock'; +import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; +import { defineRoleMappingDeleteRoutes } from './delete'; + +describe('DELETE role mappings', () => { + it('allows a role mapping to be deleted', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({ acknowledged: true }); + + defineRoleMappingDeleteRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.delete.mock.calls; + + const name = 'mapping1'; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'delete', + path: `/internal/security/role_mapping/${name}`, + params: { name }, + headers, + }); + const mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ state: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(200); + expect(response.payload).toEqual({ acknowledged: true }); + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect( + mockScopedClusterClient.callAsCurrentUser + ).toHaveBeenCalledWith('shield.deleteRoleMapping', { name }); + }); + + describe('failure', () => { + it('returns result of license check', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + defineRoleMappingDeleteRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.delete.mock.calls; + + const name = 'mapping1'; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'delete', + path: `/internal/security/role_mapping/${name}`, + params: { name }, + headers, + }); + const mockContext = ({ + licensing: { + license: { + check: jest.fn().mockReturnValue({ + state: LICENSE_CHECK_STATE.Invalid, + message: 'test forbidden message', + }), + }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(403); + expect(response.payload).toEqual({ message: 'test forbidden message' }); + expect(mockRouteDefinitionParams.clusterClient.asScoped).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/role_mapping/delete.ts b/x-pack/plugins/security/server/routes/role_mapping/delete.ts new file mode 100644 index 0000000000000..dc11bcd914b35 --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/delete.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { wrapError } from '../../errors'; +import { RouteDefinitionParams } from '..'; + +export function defineRoleMappingDeleteRoutes(params: RouteDefinitionParams) { + const { clusterClient, router } = params; + + router.delete( + { + path: '/internal/security/role_mapping/{name}', + validate: { + params: schema.object({ + name: schema.string(), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + const deleteResponse = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.deleteRoleMapping', { + name: request.params.name, + }); + return response.ok({ body: deleteResponse }); + } catch (error) { + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); + } + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/role_mapping/feature_check.test.ts b/x-pack/plugins/security/server/routes/role_mapping/feature_check.test.ts new file mode 100644 index 0000000000000..f2c48fd370434 --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/feature_check.test.ts @@ -0,0 +1,248 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { routeDefinitionParamsMock } from '../index.mock'; +import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { + kibanaResponseFactory, + RequestHandlerContext, + IClusterClient, +} from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE, LicenseCheck } from '../../../../licensing/server'; +import { defineRoleMappingFeatureCheckRoute } from './feature_check'; + +interface TestOptions { + licenseCheckResult?: LicenseCheck; + canManageRoleMappings?: boolean; + nodeSettingsResponse?: Record; + xpackUsageResponse?: Record; + internalUserClusterClientImpl?: IClusterClient['callAsInternalUser']; + asserts: { statusCode: number; result?: Record }; +} + +const defaultXpackUsageResponse = { + security: { + realms: { + native: { + available: true, + enabled: true, + }, + pki: { + available: true, + enabled: true, + }, + }, + }, +}; + +const getDefaultInternalUserClusterClientImpl = ( + nodeSettingsResponse: TestOptions['nodeSettingsResponse'], + xpackUsageResponse: TestOptions['xpackUsageResponse'] +) => + ((async (endpoint: string, clientParams: Record) => { + if (!clientParams) throw new TypeError('expected clientParams'); + + if (endpoint === 'nodes.info') { + return nodeSettingsResponse; + } + + if (endpoint === 'transport.request') { + if (clientParams.path === '/_xpack/usage') { + return xpackUsageResponse; + } + } + + throw new Error(`unexpected endpoint: ${endpoint}`); + }) as unknown) as TestOptions['internalUserClusterClientImpl']; + +describe('GET role mappings feature check', () => { + const getFeatureCheckTest = ( + description: string, + { + licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid }, + canManageRoleMappings = true, + nodeSettingsResponse = {}, + xpackUsageResponse = defaultXpackUsageResponse, + internalUserClusterClientImpl = getDefaultInternalUserClusterClientImpl( + nodeSettingsResponse, + xpackUsageResponse + ), + asserts, + }: TestOptions + ) => { + test(description, async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockRouteDefinitionParams.clusterClient.callAsInternalUser.mockImplementation( + internalUserClusterClientImpl + ); + + mockScopedClusterClient.callAsCurrentUser.mockImplementation(async (method, payload) => { + if (method === 'shield.hasPrivileges') { + return { + has_all_requested: canManageRoleMappings, + }; + } + }); + + defineRoleMappingFeatureCheckRoute(mockRouteDefinitionParams); + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/internal/security/_check_role_mapping_features`, + headers, + }); + const mockContext = ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(asserts.statusCode); + expect(response.payload).toEqual(asserts.result); + + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); + }); + }; + + getFeatureCheckTest('allows both script types with the default settings', { + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + hasCompatibleRealms: true, + }, + }, + }); + + getFeatureCheckTest('allows both script types when explicitly enabled', { + nodeSettingsResponse: { + nodes: { + someNodeId: { + settings: { + script: { + allowed_types: ['stored', 'inline'], + }, + }, + }, + }, + }, + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + hasCompatibleRealms: true, + }, + }, + }); + + getFeatureCheckTest('disallows stored scripts when disabled', { + nodeSettingsResponse: { + nodes: { + someNodeId: { + settings: { + script: { + allowed_types: ['inline'], + }, + }, + }, + }, + }, + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: true, + canUseStoredScripts: false, + hasCompatibleRealms: true, + }, + }, + }); + + getFeatureCheckTest('disallows inline scripts when disabled', { + nodeSettingsResponse: { + nodes: { + someNodeId: { + settings: { + script: { + allowed_types: ['stored'], + }, + }, + }, + }, + }, + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: false, + canUseStoredScripts: true, + hasCompatibleRealms: true, + }, + }, + }); + + getFeatureCheckTest('indicates incompatible realms when only native and file are enabled', { + xpackUsageResponse: { + security: { + realms: { + native: { + available: true, + enabled: true, + }, + file: { + available: true, + enabled: true, + }, + }, + }, + }, + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + hasCompatibleRealms: false, + }, + }, + }); + + getFeatureCheckTest('indicates canManageRoleMappings=false for users without `manage_security`', { + canManageRoleMappings: false, + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: false, + }, + }, + }); + + getFeatureCheckTest( + 'falls back to allowing both script types if there is an error retrieving node settings', + { + internalUserClusterClientImpl: (() => { + return Promise.reject(new Error('something bad happened')); + }) as TestOptions['internalUserClusterClientImpl'], + asserts: { + statusCode: 200, + result: { + canManageRoleMappings: true, + canUseInlineScripts: true, + canUseStoredScripts: true, + hasCompatibleRealms: false, + }, + }, + } + ); +}); diff --git a/x-pack/plugins/security/server/routes/role_mapping/feature_check.ts b/x-pack/plugins/security/server/routes/role_mapping/feature_check.ts new file mode 100644 index 0000000000000..2be4f4cd89177 --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/feature_check.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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, IClusterClient } from 'src/core/server'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { RouteDefinitionParams } from '..'; + +interface NodeSettingsResponse { + nodes: { + [nodeId: string]: { + settings: { + script: { + allowed_types?: string[]; + allowed_contexts?: string[]; + }; + }; + }; + }; +} + +interface XPackUsageResponse { + security: { + realms: { + [realmName: string]: { + available: boolean; + enabled: boolean; + }; + }; + }; +} + +const INCOMPATIBLE_REALMS = ['file', 'native']; + +export function defineRoleMappingFeatureCheckRoute({ + router, + clusterClient, + logger, +}: RouteDefinitionParams) { + router.get( + { + path: '/internal/security/_check_role_mapping_features', + validate: false, + }, + createLicensedRouteHandler(async (context, request, response) => { + const { has_all_requested: canManageRoleMappings } = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.hasPrivileges', { + body: { + cluster: ['manage_security'], + }, + }); + + if (!canManageRoleMappings) { + return response.ok({ + body: { + canManageRoleMappings, + }, + }); + } + + const enabledFeatures = await getEnabledRoleMappingsFeatures(clusterClient, logger); + + return response.ok({ + body: { + ...enabledFeatures, + canManageRoleMappings, + }, + }); + }) + ); +} + +async function getEnabledRoleMappingsFeatures(clusterClient: IClusterClient, logger: Logger) { + logger.debug(`Retrieving role mappings features`); + + const nodeScriptSettingsPromise: Promise = clusterClient + .callAsInternalUser('nodes.info', { + filterPath: 'nodes.*.settings.script', + }) + .catch(error => { + // fall back to assuming that node settings are unset/at their default values. + // this will allow the role mappings UI to permit both role template script types, + // even if ES will disallow it at mapping evaluation time. + logger.error(`Error retrieving node settings for role mappings: ${error}`); + return {}; + }); + + const xpackUsagePromise: Promise = clusterClient + // `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', + }) + .catch(error => { + // fall back to no external realms configured. + // this will cause a warning in the UI about no compatible realms being enabled, but will otherwise allow + // the mappings screen to function correctly. + logger.error(`Error retrieving XPack usage info for role mappings: ${error}`); + return { + security: { + realms: {}, + }, + } as XPackUsageResponse; + }); + + const [nodeScriptSettings, xpackUsage] = await Promise.all([ + nodeScriptSettingsPromise, + xpackUsagePromise, + ]); + + let canUseStoredScripts = true; + let canUseInlineScripts = true; + if (usesCustomScriptSettings(nodeScriptSettings)) { + canUseStoredScripts = Object.values(nodeScriptSettings.nodes).some(node => { + const allowedTypes = node.settings.script.allowed_types; + return !allowedTypes || allowedTypes.includes('stored'); + }); + + canUseInlineScripts = Object.values(nodeScriptSettings.nodes).some(node => { + const allowedTypes = node.settings.script.allowed_types; + return !allowedTypes || allowedTypes.includes('inline'); + }); + } + + const hasCompatibleRealms = Object.entries(xpackUsage.security.realms).some( + ([realmName, realm]) => { + return !INCOMPATIBLE_REALMS.includes(realmName) && realm.available && realm.enabled; + } + ); + + return { + hasCompatibleRealms, + canUseStoredScripts, + canUseInlineScripts, + }; +} + +function usesCustomScriptSettings( + nodeResponse: NodeSettingsResponse | {} +): nodeResponse is NodeSettingsResponse { + return nodeResponse.hasOwnProperty('nodes'); +} diff --git a/x-pack/plugins/security/server/routes/role_mapping/get.test.ts b/x-pack/plugins/security/server/routes/role_mapping/get.test.ts new file mode 100644 index 0000000000000..c60d5518097ba --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/get.test.ts @@ -0,0 +1,253 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 Boom from 'boom'; +import { routeDefinitionParamsMock } from '../index.mock'; +import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { defineRoleMappingGetRoutes } from './get'; +import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; + +const mockRoleMappingResponse = { + mapping1: { + enabled: true, + roles: ['foo', 'bar'], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + mapping2: { + enabled: true, + role_templates: [{ template: JSON.stringify({ source: 'foo_{{username}}' }) }], + rules: { + any: [ + { + field: { + dn: 'CN=admin,OU=example,O=com', + }, + }, + { + field: { + username: 'admin_*', + }, + }, + ], + }, + }, + mapping3: { + enabled: true, + role_templates: [{ template: 'template with invalid json' }], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, +}; + +describe('GET role mappings', () => { + it('returns all role mappings', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(mockRoleMappingResponse); + + defineRoleMappingGetRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/internal/security/role_mapping`, + headers, + }); + const mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ state: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(200); + expect(response.payload).toEqual([ + { + name: 'mapping1', + enabled: true, + roles: ['foo', 'bar'], + role_templates: [], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + { + name: 'mapping2', + enabled: true, + role_templates: [{ template: { source: 'foo_{{username}}' } }], + rules: { + any: [ + { + field: { + dn: 'CN=admin,OU=example,O=com', + }, + }, + { + field: { + username: 'admin_*', + }, + }, + ], + }, + }, + { + name: 'mapping3', + enabled: true, + role_templates: [{ template: 'template with invalid json' }], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + ]); + + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.getRoleMappings', + { name: undefined } + ); + }); + + it('returns role mapping by name', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({ + mapping1: { + enabled: true, + roles: ['foo', 'bar'], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + }); + + defineRoleMappingGetRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const name = 'mapping1'; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/internal/security/role_mapping/${name}`, + params: { name }, + headers, + }); + const mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ state: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(200); + expect(response.payload).toEqual({ + name: 'mapping1', + enabled: true, + roles: ['foo', 'bar'], + role_templates: [], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }); + + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.getRoleMappings', + { name } + ); + }); + + describe('failure', () => { + it('returns result of license check', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + defineRoleMappingGetRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/internal/security/role_mapping`, + headers, + }); + const mockContext = ({ + licensing: { + license: { + check: jest.fn().mockReturnValue({ + state: LICENSE_CHECK_STATE.Invalid, + message: 'test forbidden message', + }), + }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(403); + expect(response.payload).toEqual({ message: 'test forbidden message' }); + expect(mockRouteDefinitionParams.clusterClient.asScoped).not.toHaveBeenCalled(); + }); + + it('returns a 404 when the role mapping is not found', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + Boom.notFound('role mapping not found!') + ); + + defineRoleMappingGetRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const name = 'mapping1'; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/internal/security/role_mapping/${name}`, + params: { name }, + headers, + }); + const mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ state: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(404); + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect( + mockScopedClusterClient.callAsCurrentUser + ).toHaveBeenCalledWith('shield.getRoleMappings', { name }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/role_mapping/get.ts b/x-pack/plugins/security/server/routes/role_mapping/get.ts new file mode 100644 index 0000000000000..9cd5cf83092e1 --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/get.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { RoleMapping } from '../../../../../legacy/plugins/security/common/model'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { wrapError } from '../../errors'; +import { RouteDefinitionParams } from '..'; + +interface RoleMappingsResponse { + [roleMappingName: string]: Omit; +} + +export function defineRoleMappingGetRoutes(params: RouteDefinitionParams) { + const { clusterClient, logger, router } = params; + + router.get( + { + path: '/internal/security/role_mapping/{name?}', + validate: { + params: schema.object({ + name: schema.maybe(schema.string()), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const expectSingleEntity = typeof request.params.name === 'string'; + + try { + const roleMappingsResponse: RoleMappingsResponse = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.getRoleMappings', { + name: request.params.name, + }); + + const mappings = Object.entries(roleMappingsResponse).map(([name, mapping]) => { + return { + name, + ...mapping, + role_templates: (mapping.role_templates || []).map(entry => { + return { + ...entry, + template: tryParseRoleTemplate(entry.template as string), + }; + }), + } as RoleMapping; + }); + + if (expectSingleEntity) { + return response.ok({ body: mappings[0] }); + } + return response.ok({ body: mappings }); + } catch (error) { + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); + } + }) + ); + + /** + * While role templates are normally persisted as objects via the create API, they are stored internally as strings. + * As a result, the ES APIs to retrieve role mappings represent the templates as strings, so we have to attempt + * to parse them back out. ES allows for invalid JSON to be stored, so we have to account for that as well. + * + * @param roleTemplate the string-based template to parse + */ + function tryParseRoleTemplate(roleTemplate: string) { + try { + return JSON.parse(roleTemplate); + } catch (e) { + logger.debug(`Role template is not valid JSON: ${e}`); + return roleTemplate; + } + } +} diff --git a/x-pack/plugins/security/server/routes/role_mapping/index.ts b/x-pack/plugins/security/server/routes/role_mapping/index.ts new file mode 100644 index 0000000000000..1bd90e8c1fae3 --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/index.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 { defineRoleMappingFeatureCheckRoute } from './feature_check'; +import { defineRoleMappingGetRoutes } from './get'; +import { defineRoleMappingPostRoutes } from './post'; +import { defineRoleMappingDeleteRoutes } from './delete'; +import { RouteDefinitionParams } from '..'; + +export function defineRoleMappingRoutes(params: RouteDefinitionParams) { + defineRoleMappingFeatureCheckRoute(params); + defineRoleMappingGetRoutes(params); + defineRoleMappingPostRoutes(params); + defineRoleMappingDeleteRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/role_mapping/post.test.ts b/x-pack/plugins/security/server/routes/role_mapping/post.test.ts new file mode 100644 index 0000000000000..7d820d668a6da --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/post.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { routeDefinitionParamsMock } from '../index.mock'; +import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks'; +import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../src/core/server'; +import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; +import { defineRoleMappingPostRoutes } from './post'; + +describe('POST role mappings', () => { + it('allows a role mapping to be created', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({ created: true }); + + defineRoleMappingPostRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.post.mock.calls; + + const name = 'mapping1'; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'post', + path: `/internal/security/role_mapping/${name}`, + params: { name }, + body: { + enabled: true, + roles: ['foo', 'bar'], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + headers, + }); + const mockContext = ({ + licensing: { + license: { check: jest.fn().mockReturnValue({ state: LICENSE_CHECK_STATE.Valid }) }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(200); + expect(response.payload).toEqual({ created: true }); + + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( + 'shield.saveRoleMapping', + { + name, + body: { + enabled: true, + roles: ['foo', 'bar'], + rules: { + field: { + dn: 'CN=bob,OU=example,O=com', + }, + }, + }, + } + ); + }); + + describe('failure', () => { + it('returns result of license check', async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + defineRoleMappingPostRoutes(mockRouteDefinitionParams); + + const [[, handler]] = mockRouteDefinitionParams.router.post.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'post', + path: `/internal/security/role_mapping`, + headers, + }); + const mockContext = ({ + licensing: { + license: { + check: jest.fn().mockReturnValue({ + state: LICENSE_CHECK_STATE.Invalid, + message: 'test forbidden message', + }), + }, + }, + } as unknown) as RequestHandlerContext; + + const response = await handler(mockContext, mockRequest, kibanaResponseFactory); + expect(response.status).toBe(403); + expect(response.payload).toEqual({ message: 'test forbidden message' }); + + expect(mockRouteDefinitionParams.clusterClient.asScoped).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/role_mapping/post.ts b/x-pack/plugins/security/server/routes/role_mapping/post.ts new file mode 100644 index 0000000000000..bf9112be4ad3f --- /dev/null +++ b/x-pack/plugins/security/server/routes/role_mapping/post.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { createLicensedRouteHandler } from '../licensed_route_handler'; +import { wrapError } from '../../errors'; +import { RouteDefinitionParams } from '..'; + +export function defineRoleMappingPostRoutes(params: RouteDefinitionParams) { + const { clusterClient, router } = params; + + router.post( + { + path: '/internal/security/role_mapping/{name}', + validate: { + params: schema.object({ + name: schema.string(), + }), + body: schema.object({ + roles: schema.arrayOf(schema.string(), { defaultValue: [] }), + role_templates: schema.arrayOf( + schema.object({ + // Not validating `template` because the ES API currently accepts invalid payloads here. + // We allow this as well so that existing mappings can be updated via our Role Management UI + template: schema.any(), + format: schema.maybe( + schema.oneOf([schema.literal('string'), schema.literal('json')]) + ), + }), + { defaultValue: [] } + ), + enabled: schema.boolean(), + // Also lax on validation here because the real rules get quite complex, + // and keeping this in sync (and testable!) with ES could prove problematic. + // We do not interpret any of these rules within this route handler; + // they are simply passed to ES for processing. + rules: schema.object({}, { allowUnknowns: true }), + metadata: schema.object({}, { allowUnknowns: true }), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + const saveResponse = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.saveRoleMapping', { + name: request.params.name, + body: request.body, + }); + return response.ok({ body: saveResponse }); + } catch (error) { + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); + } + }) + ); +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 366a5e4cc9412..43e7f321e516f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10965,7 +10965,6 @@ "xpack.snapshotRestore.repositoryDetails.typeS3.serverSideEncryptionLabel": "サーバー側エコシステム", "xpack.snapshotRestore.repositoryDetails.typeS3.storageClassLabel": "ストレージクラス", "xpack.snapshotRestore.repositoryDetails.typeTitle": "レポジトリタイプ", - "xpack.snapshotRestore.repositoryDetails.verificationDetails": "認証情報レポジトリ「{name}」", "xpack.snapshotRestore.repositoryDetails.verificationDetailsTitle": "詳細", "xpack.snapshotRestore.repositoryDetails.verificationTitle": "認証ステータス", "xpack.snapshotRestore.repositoryDetails.verifyButtonLabel": "レポジトリを検証", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e4d256429e022..461078f339640 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11054,7 +11054,6 @@ "xpack.snapshotRestore.repositoryDetails.typeS3.serverSideEncryptionLabel": "服务器端加密", "xpack.snapshotRestore.repositoryDetails.typeS3.storageClassLabel": "存储类", "xpack.snapshotRestore.repositoryDetails.typeTitle": "存储库类型", - "xpack.snapshotRestore.repositoryDetails.verificationDetails": "验证详情存储库“{name}”", "xpack.snapshotRestore.repositoryDetails.verificationDetailsTitle": "详情", "xpack.snapshotRestore.repositoryDetails.verificationTitle": "验证状态", "xpack.snapshotRestore.repositoryDetails.verifyButtonLabel": "验证存储库", diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 86db39823ba91..bda5b51623d05 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -9,6 +9,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/reporting/configs/chromium_api.js'), require.resolve('../test/reporting/configs/chromium_functional.js'), require.resolve('../test/reporting/configs/generate_api.js'), + require.resolve('../test/functional/config_security_basic.js'), require.resolve('../test/api_integration/config_security_basic.js'), require.resolve('../test/api_integration/config.js'), require.resolve('../test/alerting_api_integration/spaces_only/config.ts'), diff --git a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts index f148d62421ff8..ad4f81777e780 100644 --- a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts +++ b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_security.ts @@ -67,9 +67,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Management']); }); @@ -125,9 +123,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows Management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Management']); }); @@ -178,9 +174,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows Management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Discover', 'Management']); }); diff --git a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_spaces.ts b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_spaces.ts index 9ac6d4fdef19f..ee58be76928b3 100644 --- a/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_spaces.ts +++ b/x-pack/test/functional/apps/advanced_settings/feature_controls/advanced_settings_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Management'); }); diff --git a/x-pack/test/functional/apps/apm/feature_controls/apm_security.ts b/x-pack/test/functional/apps/apm/feature_controls/apm_security.ts index 9190e0b4886ce..e2d5efac4644c 100644 --- a/x-pack/test/functional/apps/apm/feature_controls/apm_security.ts +++ b/x-pack/test/functional/apps/apm/feature_controls/apm_security.ts @@ -60,10 +60,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows apm navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'APM', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['APM', 'Management']); }); it('can navigate to APM app', async () => { @@ -111,9 +108,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows apm navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['APM', 'Management']); }); @@ -166,9 +161,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show APM navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('APM'); }); diff --git a/x-pack/test/functional/apps/apm/feature_controls/apm_spaces.ts b/x-pack/test/functional/apps/apm/feature_controls/apm_spaces.ts index 191ba5c4d1e25..1ac1784e0e05d 100644 --- a/x-pack/test/functional/apps/apm/feature_controls/apm_spaces.ts +++ b/x-pack/test/functional/apps/apm/feature_controls/apm_spaces.ts @@ -30,9 +30,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('APM'); }); @@ -61,9 +59,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('APM'); }); diff --git a/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts b/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts index a58eb61ec4ca2..d0e37ec8e3f35 100644 --- a/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts +++ b/x-pack/test/functional/apps/canvas/feature_controls/canvas_security.ts @@ -65,9 +65,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows canvas navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Canvas', 'Management']); }); @@ -143,9 +141,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows canvas navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Canvas', 'Management']); }); diff --git a/x-pack/test/functional/apps/canvas/feature_controls/canvas_spaces.ts b/x-pack/test/functional/apps/canvas/feature_controls/canvas_spaces.ts index 5a6857901536f..28b572401892b 100644 --- a/x-pack/test/functional/apps/canvas/feature_controls/canvas_spaces.ts +++ b/x-pack/test/functional/apps/canvas/feature_controls/canvas_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Canvas'); }); @@ -98,9 +96,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Canvas'); }); 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 aa6860b35763f..d25fae3c4894c 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 @@ -75,10 +75,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows dashboard navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'Dashboard', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['Dashboard', 'Management']); }); it(`landing page shows "Create new Dashboard" button`, async () => { @@ -255,9 +252,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows dashboard navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Dashboard', 'Management']); }); diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts index c1197fa7023c5..ebe08a60c2563 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_spaces.ts @@ -43,9 +43,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Dashboard'); }); @@ -107,9 +105,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Dashboard'); }); diff --git a/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts b/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts index fd7739e6930d0..494fd71ea6f34 100644 --- a/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts +++ b/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_security.ts @@ -63,10 +63,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows Dev Tools navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'Dev Tools', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['Dev Tools', 'Management']); }); describe('console', () => { @@ -146,9 +143,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`shows 'Dev Tools' navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Dev Tools', 'Management']); }); diff --git a/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_spaces.ts b/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_spaces.ts index e3bc3a1c6ce11..4184d223a9686 100644 --- a/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_spaces.ts +++ b/x-pack/test/functional/apps/dev_tools/feature_controls/dev_tools_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Dev Tools'); }); @@ -79,9 +77,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Dev Tools'); }); 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 553ce459ebb18..1912b16d96f36 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 @@ -81,10 +81,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows discover navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'Discover', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['Discover', 'Management']); }); it('shows save button', async () => { @@ -170,9 +167,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows discover navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Discover', 'Management']); }); diff --git a/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts b/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts index 3e5dcd7b0c987..e6b6f28f8b92f 100644 --- a/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts +++ b/x-pack/test/functional/apps/discover/feature_controls/discover_spaces.ts @@ -49,9 +49,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Discover'); }); @@ -93,9 +91,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Discover'); }); diff --git a/x-pack/test/functional/apps/endpoint/feature_controls/endpoint_spaces.ts b/x-pack/test/functional/apps/endpoint/feature_controls/endpoint_spaces.ts index 1d1fb566eb075..d8eb969b99b3b 100644 --- a/x-pack/test/functional/apps/endpoint/feature_controls/endpoint_spaces.ts +++ b/x-pack/test/functional/apps/endpoint/feature_controls/endpoint_spaces.ts @@ -30,9 +30,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('EEndpoint'); }); @@ -70,9 +68,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('EEndpoint'); }); }); diff --git a/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts b/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts index acc8943033a1a..a2b062e6ef84f 100644 --- a/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts +++ b/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts @@ -64,10 +64,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows graph navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'Graph', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['Graph', 'Management']); }); it('landing page shows "Create new graph" button', async () => { @@ -129,9 +126,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows graph navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Graph', 'Management']); }); @@ -183,9 +178,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show graph navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Graph'); }); diff --git a/x-pack/test/functional/apps/graph/feature_controls/graph_spaces.ts b/x-pack/test/functional/apps/graph/feature_controls/graph_spaces.ts index 0945b35ba0930..a0b0d5bef9668 100644 --- a/x-pack/test/functional/apps/graph/feature_controls/graph_spaces.ts +++ b/x-pack/test/functional/apps/graph/feature_controls/graph_spaces.ts @@ -34,9 +34,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Graph'); }); @@ -75,9 +73,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Graph'); }); diff --git a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts index 4929bb52c170c..30cdc95b38e62 100644 --- a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts +++ b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_security.ts @@ -69,9 +69,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Management']); }); @@ -125,9 +123,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Management']); }); @@ -179,9 +175,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows Management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Discover', 'Management']); }); diff --git a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_spaces.ts b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_spaces.ts index bc8542288410c..6a2b77de17f45 100644 --- a/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_spaces.ts +++ b/x-pack/test/functional/apps/index_patterns/feature_controls/index_patterns_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Management'); }); diff --git a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts index 4d61e0996419c..5062f094061c0 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_security.ts @@ -60,9 +60,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows metrics navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Metrics', 'Management']); }); @@ -175,9 +173,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows metrics navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Metrics', 'Management']); }); @@ -417,9 +413,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show metrics navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.not.contain(['Metrics']); }); diff --git a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_spaces.ts b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_spaces.ts index 300b22e5bcbc3..7c2a11a542d66 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/infrastructure_spaces.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/infrastructure_spaces.ts @@ -48,9 +48,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Metrics'); }); @@ -101,9 +99,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Metrics'); }); diff --git a/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts b/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts index d092e6736656e..b9634c29dac1c 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/logs_security.ts @@ -57,9 +57,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows logs navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Logs', 'Management']); }); @@ -122,9 +120,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows logs navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Logs', 'Management']); }); @@ -187,9 +183,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show logs navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.not.contain('Logs'); }); diff --git a/x-pack/test/functional/apps/infra/feature_controls/logs_spaces.ts b/x-pack/test/functional/apps/infra/feature_controls/logs_spaces.ts index 8230b25efbbf9..6b078d2cfa71a 100644 --- a/x-pack/test/functional/apps/infra/feature_controls/logs_spaces.ts +++ b/x-pack/test/functional/apps/infra/feature_controls/logs_spaces.ts @@ -36,9 +36,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Logs'); }); @@ -77,9 +75,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.not.contain('Logs'); }); diff --git a/x-pack/test/functional/apps/machine_learning/feature_controls/ml_security.ts b/x-pack/test/functional/apps/machine_learning/feature_controls/ml_security.ts index 8b2df502dc100..8fb6f21c778d3 100644 --- a/x-pack/test/functional/apps/machine_learning/feature_controls/ml_security.ts +++ b/x-pack/test/functional/apps/machine_learning/feature_controls/ml_security.ts @@ -80,9 +80,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show ml navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Machine Learning'); }); }); @@ -103,9 +101,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows ML navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Machine Learning'); }); }); diff --git a/x-pack/test/functional/apps/machine_learning/feature_controls/ml_spaces.ts b/x-pack/test/functional/apps/machine_learning/feature_controls/ml_spaces.ts index 13036737218bc..fc94688e98811 100644 --- a/x-pack/test/functional/apps/machine_learning/feature_controls/ml_spaces.ts +++ b/x-pack/test/functional/apps/machine_learning/feature_controls/ml_spaces.ts @@ -39,9 +39,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Machine Learning'); }); @@ -71,9 +69,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Machine Learning'); }); diff --git a/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts b/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts index cf31f445a96f3..804ad5725edfd 100644 --- a/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts +++ b/x-pack/test/functional/apps/maps/feature_controls/maps_security.ts @@ -65,9 +65,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows maps navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Maps', 'Management']); }); @@ -154,9 +152,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows Maps navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Maps', 'Management']); }); @@ -251,9 +247,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('does not show Maps navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Discover', 'Management']); }); diff --git a/x-pack/test/functional/apps/maps/feature_controls/maps_spaces.ts b/x-pack/test/functional/apps/maps/feature_controls/maps_spaces.ts index 0c86b47b373e6..e157586aecead 100644 --- a/x-pack/test/functional/apps/maps/feature_controls/maps_spaces.ts +++ b/x-pack/test/functional/apps/maps/feature_controls/maps_spaces.ts @@ -42,9 +42,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Maps'); }); diff --git a/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_security.ts b/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_security.ts index 8848df83d36d6..d985da42ab5ed 100644 --- a/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_security.ts +++ b/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_security.ts @@ -76,9 +76,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show monitoring navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Stack Monitoring'); }); }); @@ -99,9 +97,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows monitoring navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Stack Monitoring'); }); }); diff --git a/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_spaces.ts b/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_spaces.ts index 80f33ff6175c5..7459b53ca4a32 100644 --- a/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_spaces.ts +++ b/x-pack/test/functional/apps/monitoring/feature_controls/monitoring_spaces.ts @@ -41,9 +41,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Stack Monitoring'); }); @@ -74,9 +72,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Stack Monitoring'); }); diff --git a/x-pack/test/functional/apps/security/basic_license/index.ts b/x-pack/test/functional/apps/security/basic_license/index.ts new file mode 100644 index 0000000000000..0dbbd3988f8dd --- /dev/null +++ b/x-pack/test/functional/apps/security/basic_license/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function({ loadTestFile }: FtrProviderContext) { + describe('security app - basic license', function() { + this.tags('ciGroup4'); + + loadTestFile(require.resolve('./role_mappings')); + }); +} diff --git a/x-pack/test/functional/apps/security/basic_license/role_mappings.ts b/x-pack/test/functional/apps/security/basic_license/role_mappings.ts new file mode 100644 index 0000000000000..45b325d57bee0 --- /dev/null +++ b/x-pack/test/functional/apps/security/basic_license/role_mappings.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. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const pageObjects = getPageObjects(['common', 'roleMappings']); + const testSubjects = getService('testSubjects'); + + describe('Role Mappings', function() { + before(async () => { + await pageObjects.common.navigateToApp('settings'); + }); + + it('does not render the Role Mappings UI under the basic license', async () => { + await testSubjects.missingOrFail('roleMappings'); + }); + }); +}; diff --git a/x-pack/test/functional/apps/security/index.js b/x-pack/test/functional/apps/security/index.js index b5d9b5f14be97..827a702b92d85 100644 --- a/x-pack/test/functional/apps/security/index.js +++ b/x-pack/test/functional/apps/security/index.js @@ -16,5 +16,6 @@ export default function({ loadTestFile }) { loadTestFile(require.resolve('./field_level_security')); loadTestFile(require.resolve('./rbac_phase1')); loadTestFile(require.resolve('./user_email')); + loadTestFile(require.resolve('./role_mappings')); }); } diff --git a/x-pack/test/functional/apps/security/role_mappings.ts b/x-pack/test/functional/apps/security/role_mappings.ts new file mode 100644 index 0000000000000..5fed56ee79e3d --- /dev/null +++ b/x-pack/test/functional/apps/security/role_mappings.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { parse } from 'url'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const pageObjects = getPageObjects(['common', 'roleMappings']); + const security = getService('security'); + const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + const aceEditor = getService('aceEditor'); + + describe('Role Mappings', function() { + before(async () => { + await pageObjects.common.navigateToApp('roleMappings'); + }); + + it('displays a message when no role mappings exist', async () => { + await testSubjects.existOrFail('roleMappingsEmptyPrompt'); + await testSubjects.existOrFail('createRoleMappingButton'); + }); + + it('allows a role mapping to be created', async () => { + await testSubjects.click('createRoleMappingButton'); + await testSubjects.setValue('roleMappingFormNameInput', 'new_role_mapping'); + await testSubjects.setValue('roleMappingFormRoleComboBox', 'superuser'); + await browser.pressKeys(browser.keys.ENTER); + + await testSubjects.click('roleMappingsAddRuleButton'); + + await testSubjects.click('roleMappingsJSONRuleEditorButton'); + + await aceEditor.setValue( + 'roleMappingsJSONEditor', + JSON.stringify({ + all: [ + { + field: { + username: '*', + }, + }, + { + field: { + 'metadata.foo.bar': 'baz', + }, + }, + { + except: { + any: [ + { + field: { + dn: 'foo', + }, + }, + { + field: { + dn: 'bar', + }, + }, + ], + }, + }, + ], + }) + ); + + await testSubjects.click('roleMappingsVisualRuleEditorButton'); + + await testSubjects.click('saveRoleMappingButton'); + + await testSubjects.existOrFail('savedRoleMappingSuccessToast'); + }); + + it('allows a role mapping to be deleted', async () => { + await testSubjects.click(`deleteRoleMappingButton-new_role_mapping`); + await testSubjects.click('confirmModalConfirmButton'); + await testSubjects.existOrFail('deletedRoleMappingSuccessToast'); + }); + + it('displays an error and returns to the listing page when navigating to a role mapping which does not exist', async () => { + await pageObjects.common.navigateToActualUrl( + 'kibana', + '#/management/security/role_mappings/edit/i-do-not-exist', + { ensureCurrentUrl: false } + ); + + await testSubjects.existOrFail('errorLoadingRoleMappingEditorToast'); + + const url = parse(await browser.getCurrentUrl()); + + expect(url.hash).to.eql('#/management/security/role_mappings?_g=()'); + }); + + describe('with role mappings', () => { + const mappings = [ + { + name: 'a_enabled_role_mapping', + enabled: true, + roles: ['superuser'], + rules: { + field: { + username: '*', + }, + }, + metadata: {}, + }, + { + name: 'b_disabled_role_mapping', + enabled: false, + role_templates: [{ template: { source: 'superuser' } }], + rules: { + field: { + username: '*', + }, + }, + metadata: {}, + }, + ]; + + before(async () => { + await Promise.all( + mappings.map(mapping => { + const { name, ...payload } = mapping; + return security.roleMappings.create(name, payload); + }) + ); + + await pageObjects.common.navigateToApp('roleMappings'); + }); + + after(async () => { + await Promise.all(mappings.map(mapping => security.roleMappings.delete(mapping.name))); + }); + + it('displays a table of all role mappings', async () => { + const rows = await testSubjects.findAll('roleMappingRow'); + expect(rows.length).to.eql(mappings.length); + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const mapping = mappings[i]; + + const name = await ( + await testSubjects.findDescendant('roleMappingName', row) + ).getVisibleText(); + + const enabled = + (await ( + await testSubjects.findDescendant('roleMappingEnabled', row) + ).getVisibleText()) === 'Enabled'; + + expect(name).to.eql(mapping.name); + expect(enabled).to.eql(mapping.enabled); + } + }); + + it('allows a role mapping to be edited', async () => { + await testSubjects.click('roleMappingName'); + await testSubjects.click('saveRoleMappingButton'); + await testSubjects.existOrFail('savedRoleMappingSuccessToast'); + }); + }); + }); +}; diff --git a/x-pack/test/functional/apps/snapshot_restore/home_page.ts b/x-pack/test/functional/apps/snapshot_restore/home_page.ts index 99d3ea7834e6b..608c7f321a08f 100644 --- a/x-pack/test/functional/apps/snapshot_restore/home_page.ts +++ b/x-pack/test/functional/apps/snapshot_restore/home_page.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const pageObjects = getPageObjects(['common', 'snapshotRestore']); const log = getService('log'); + const es = getService('legacyEs'); describe('Home page', function() { this.tags('smoke'); @@ -26,5 +27,37 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const repositoriesButton = await pageObjects.snapshotRestore.registerRepositoryButton(); expect(await repositoriesButton.isDisplayed()).to.be(true); }); + + describe('Repositories Tab', async () => { + before(async () => { + await es.snapshot.createRepository({ + repository: 'my-repository', + body: { + type: 'fs', + settings: { + location: '/tmp/es-backups/', + compress: true, + }, + }, + verify: true, + }); + await pageObjects.snapshotRestore.navToRepositories(); + }); + + it('cleanup repository', async () => { + await pageObjects.snapshotRestore.viewRepositoryDetails('my-repository'); + await pageObjects.common.sleep(25000); + const cleanupResponse = await pageObjects.snapshotRestore.performRepositoryCleanup(); + await pageObjects.common.sleep(25000); + expect(cleanupResponse).to.contain('results'); + expect(cleanupResponse).to.contain('deleted_bytes'); + expect(cleanupResponse).to.contain('deleted_blobs'); + }); + after(async () => { + await es.snapshot.deleteRepository({ + repository: 'my-repository', + }); + }); + }); }); }; diff --git a/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts b/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts index 46f0be1e6f6d6..1e79c76bf83e5 100644 --- a/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts +++ b/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts @@ -55,9 +55,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Management'); }); @@ -131,9 +129,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows management navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Management'); }); diff --git a/x-pack/test/functional/apps/timelion/feature_controls/timelion_security.ts b/x-pack/test/functional/apps/timelion/feature_controls/timelion_security.ts index 64fb218a62c80..dea45f161e451 100644 --- a/x-pack/test/functional/apps/timelion/feature_controls/timelion_security.ts +++ b/x-pack/test/functional/apps/timelion/feature_controls/timelion_security.ts @@ -59,9 +59,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows timelion navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Timelion', 'Management']); }); @@ -113,9 +111,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows timelion navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Timelion', 'Management']); }); diff --git a/x-pack/test/functional/apps/timelion/feature_controls/timelion_spaces.ts b/x-pack/test/functional/apps/timelion/feature_controls/timelion_spaces.ts index ea5e255071dad..fb203a23359bd 100644 --- a/x-pack/test/functional/apps/timelion/feature_controls/timelion_spaces.ts +++ b/x-pack/test/functional/apps/timelion/feature_controls/timelion_spaces.ts @@ -38,9 +38,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Timelion'); }); @@ -71,9 +69,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Timelion'); }); diff --git a/x-pack/test/functional/apps/uptime/feature_controls/uptime_security.ts b/x-pack/test/functional/apps/uptime/feature_controls/uptime_security.ts index c5a597cdaffb0..a004f8db66823 100644 --- a/x-pack/test/functional/apps/uptime/feature_controls/uptime_security.ts +++ b/x-pack/test/functional/apps/uptime/feature_controls/uptime_security.ts @@ -64,10 +64,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { it('shows uptime navlink', async () => { const navLinks = await appsMenu.readLinks(); - expect(navLinks.map((link: Record) => link.text)).to.eql([ - 'Uptime', - 'Management', - ]); + expect(navLinks.map(link => link.text)).to.eql(['Uptime', 'Management']); }); it('can navigate to Uptime app', async () => { @@ -117,9 +114,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows uptime navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Uptime', 'Management']); }); @@ -170,9 +165,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it(`doesn't show uptime navlink`, async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Uptime'); }); diff --git a/x-pack/test/functional/apps/uptime/feature_controls/uptime_spaces.ts b/x-pack/test/functional/apps/uptime/feature_controls/uptime_spaces.ts index 96bc3c5f74f59..77c5b323340bf 100644 --- a/x-pack/test/functional/apps/uptime/feature_controls/uptime_spaces.ts +++ b/x-pack/test/functional/apps/uptime/feature_controls/uptime_spaces.ts @@ -30,9 +30,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Uptime'); }); @@ -59,9 +57,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Uptime'); }); 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 86fe606ecafad..d55076cb0ab43 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 @@ -74,9 +74,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows visualize navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Visualize', 'Management']); }); @@ -190,9 +188,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { }); it('shows visualize navlink', async () => { - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.eql(['Visualize', 'Management']); }); diff --git a/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts b/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts index d0fdc7c95ea38..9193862d2ba9e 100644 --- a/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts +++ b/x-pack/test/functional/apps/visualize/feature_controls/visualize_spaces.ts @@ -40,9 +40,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).to.contain('Visualize'); }); @@ -81,9 +79,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.common.navigateToApp('home', { basePath: '/s/custom_space', }); - const navLinks = (await appsMenu.readLinks()).map( - (link: Record) => link.text - ); + const navLinks = (await appsMenu.readLinks()).map(link => link.text); expect(navLinks).not.to.contain('Visualize'); }); diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 17235c61c7d8c..664bfdf8d2a74 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -69,7 +69,7 @@ export default async function({ readConfigFile }) { esTestCluster: { license: 'trial', from: 'snapshot', - serverArgs: [], + serverArgs: ['path.repo=/tmp/'], }, kbnTestServer: { @@ -160,6 +160,10 @@ export default async function({ readConfigFile }) { ml: { pathname: '/app/ml', }, + roleMappings: { + pathname: '/app/kibana', + hash: '/management/security/role_mappings', + }, rollupJob: { pathname: '/app/kibana', hash: '/management/elasticsearch/rollup_jobs/', diff --git a/x-pack/test/functional/config_security_basic.js b/x-pack/test/functional/config_security_basic.js new file mode 100644 index 0000000000000..12d94e922a97c --- /dev/null +++ b/x-pack/test/functional/config_security_basic.js @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable import/no-default-export */ + +import { resolve } from 'path'; + +import { services } from './services'; +import { pageObjects } from './page_objects'; + +// the default export of config files must be a config provider +// that returns an object with the projects config values +export default async function({ readConfigFile }) { + const kibanaCommonConfig = await readConfigFile( + require.resolve('../../../test/common/config.js') + ); + const kibanaFunctionalConfig = await readConfigFile( + require.resolve('../../../test/functional/config.js') + ); + + return { + // list paths to the files that contain your plugins tests + testFiles: [resolve(__dirname, './apps/security/basic_license')], + + services, + pageObjects, + + servers: kibanaFunctionalConfig.get('servers'), + + esTestCluster: { + license: 'basic', + from: 'snapshot', + serverArgs: [], + }, + + kbnTestServer: { + ...kibanaCommonConfig.get('kbnTestServer'), + serverArgs: [ + ...kibanaCommonConfig.get('kbnTestServer.serverArgs'), + '--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d', + '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', // server restarts should not invalidate active sessions + '--telemetry.banner=false', + ], + }, + uiSettings: { + defaults: { + 'accessibility:disableAnimations': true, + 'dateFormat:tz': 'UTC', + }, + }, + // the apps section defines the urls that + // `PageObjects.common.navigateTo(appKey)` will use. + // Merge urls for your plugin with the urls defined in + // Kibana's config in order to use this helper + apps: { + ...kibanaFunctionalConfig.get('apps'), + }, + + // choose where esArchiver should load archives from + esArchiver: { + directory: resolve(__dirname, 'es_archives'), + }, + + // choose where screenshots should be saved + screenshots: { + directory: resolve(__dirname, 'screenshots'), + }, + + junit: { + reportName: 'Chrome X-Pack UI Functional Tests', + }, + }; +} diff --git a/x-pack/test/functional/page_objects/index.ts b/x-pack/test/functional/page_objects/index.ts index 82011c48d4460..18ea515a73147 100644 --- a/x-pack/test/functional/page_objects/index.ts +++ b/x-pack/test/functional/page_objects/index.ts @@ -46,6 +46,7 @@ import { RemoteClustersPageProvider } from './remote_clusters_page'; import { CopySavedObjectsToSpacePageProvider } from './copy_saved_objects_to_space_page'; import { LensPageProvider } from './lens_page'; import { InfraMetricExplorerProvider } from './infra_metric_explorer'; +import { RoleMappingsPageProvider } from './role_mappings_page'; // just like services, PageObjects are defined as a map of // names to Providers. Merge in Kibana's or pick specific ones @@ -78,4 +79,5 @@ export const pageObjects = { remoteClusters: RemoteClustersPageProvider, copySavedObjectsToSpace: CopySavedObjectsToSpacePageProvider, lens: LensPageProvider, + roleMappings: RoleMappingsPageProvider, }; diff --git a/x-pack/test/functional/page_objects/role_mappings_page.ts b/x-pack/test/functional/page_objects/role_mappings_page.ts new file mode 100644 index 0000000000000..b1adfb00af739 --- /dev/null +++ b/x-pack/test/functional/page_objects/role_mappings_page.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 { FtrProviderContext } from '../ftr_provider_context'; + +export function RoleMappingsPageProvider({ getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + + return { + async appTitleText() { + return await testSubjects.getVisibleText('appTitle'); + }, + }; +} diff --git a/x-pack/test/functional/page_objects/snapshot_restore_page.ts b/x-pack/test/functional/page_objects/snapshot_restore_page.ts index 25bdfc7075727..1c8ba9f633111 100644 --- a/x-pack/test/functional/page_objects/snapshot_restore_page.ts +++ b/x-pack/test/functional/page_objects/snapshot_restore_page.ts @@ -3,11 +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 { FtrProviderContext } from '../ftr_provider_context'; export function SnapshotRestorePageProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); + const retry = getService('retry'); return { async appTitleText() { @@ -16,5 +16,50 @@ export function SnapshotRestorePageProvider({ getService }: FtrProviderContext) async registerRepositoryButton() { return await testSubjects.find('registerRepositoryButton'); }, + async navToRepositories() { + await testSubjects.click('repositories_tab'); + await retry.waitForWithTimeout( + 'Wait for register repository button to be on page', + 10000, + async () => { + return await testSubjects.isDisplayed('registerRepositoryButton'); + } + ); + }, + async getRepoList() { + const table = await testSubjects.find('repositoryTable'); + const rows = await table.findAllByCssSelector('[data-test-subj="row"]'); + return await Promise.all( + rows.map(async row => { + return { + repoName: await ( + await row.findByCssSelector('[data-test-subj="Name_cell"]') + ).getVisibleText(), + repoLink: await ( + await row.findByCssSelector('[data-test-subj="Name_cell"]') + ).findByCssSelector('a'), + repoType: await ( + await row.findByCssSelector('[data-test-subj="Type_cell"]') + ).getVisibleText(), + repoEdit: await row.findByCssSelector('[data-test-subj="editRepositoryButton"]'), + repoDelete: await row.findByCssSelector('[data-test-subj="deleteRepositoryButton"]'), + }; + }) + ); + }, + async viewRepositoryDetails(name: string) { + const repos = await this.getRepoList(); + if (repos.length === 1) { + const repoToView = repos.filter(r => (r.repoName = name))[0]; + await repoToView.repoLink.click(); + } + await retry.waitForWithTimeout(`Repo title should be ${name}`, 10000, async () => { + return (await testSubjects.getVisibleText('title')) === name; + }); + }, + async performRepositoryCleanup() { + await testSubjects.click('cleanupRepositoryButton'); + return await testSubjects.getVisibleText('cleanupCodeBlock'); + }, }; } diff --git a/x-pack/test_utils/stub_web_worker.ts b/x-pack/test_utils/stub_web_worker.ts new file mode 100644 index 0000000000000..2e7d5cf2098c8 --- /dev/null +++ b/x-pack/test_utils/stub_web_worker.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +if (!window.Worker) { + // @ts-ignore we aren't honoring the real Worker spec here + window.Worker = function Worker() { + this.postMessage = jest.fn(); + + // @ts-ignore TypeScript doesn't think this exists on the Worker interface + // https://developer.mozilla.org/en-US/docs/Web/API/Worker/terminate + this.terminate = jest.fn(); + }; +} diff --git a/yarn.lock b/yarn.lock index 69f9d08fdcade..ff098b7b9c891 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3439,6 +3439,11 @@ resolved "https://registry.yarnpkg.com/@types/dedent/-/dedent-0.7.0.tgz#155f339ca404e6dd90b9ce46a3f78fd69ca9b050" integrity sha512-EGlKlgMhnLt/cM4DbUSafFdrkeJoC9Mvnj0PUCU7tFmTjMjNRT957kXCx0wYm3JuEq4o4ZsS5vG+NlkM2DMd2A== +"@types/deep-freeze-strict@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@types/deep-freeze-strict/-/deep-freeze-strict-1.1.0.tgz#447a6a2576191344aa42310131dd3df5c41492c4" + integrity sha1-RHpqJXYZE0SqQjEBMd099cQUksQ= + "@types/delete-empty@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/delete-empty/-/delete-empty-2.0.0.tgz#1647ae9e68f708a6ba778531af667ec55bc61964" @@ -4759,11 +4764,6 @@ accepts@~1.3.7: mime-types "~2.1.24" negotiator "0.6.2" -acorn-dynamic-import@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-4.0.0.tgz#482210140582a36b83c3e342e1cfebcaa9240948" - integrity sha512-d3OEjQV4ROpoflsnUA8HozoIR504TFxNivYEUi6uwz0IYhBkTDXGuWlNdMtybRt3nqVx/L6XqMt0FxkXuWKZhw== - acorn-globals@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-3.1.0.tgz#fd8270f71fbb4996b004fa880ee5d46573a731bf" @@ -4838,7 +4838,7 @@ acorn@^6.0.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.1.tgz#7d25ae05bb8ad1f9b699108e1094ecd7884adc1f" integrity sha512-jPTiwtOxaHNaAPg/dmrJ/beuzLRnXtB0kQPQ8JpotKJgTB6rX6c8mlf315941pyjBSaPg8NHXS9fhP4u17DpGA== -acorn@^6.0.5, acorn@^6.2.1: +acorn@^6.2.1: version "6.3.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.3.0.tgz#0087509119ffa4fc0a0041d1e93a417e68cb856e" integrity sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA== @@ -7984,7 +7984,7 @@ chroma-js@^1.4.1: resolved "https://registry.yarnpkg.com/chroma-js/-/chroma-js-1.4.1.tgz#eb2d9c4d1ff24616be84b35119f4d26f8205f134" integrity sha512-jTwQiT859RTFN/vIf7s+Vl/Z2LcMrvMv3WUFmd/4u76AdlFC0NTNgqEEFPcRiHmAswPsMiQEDZLM8vX8qXpZNQ== -chrome-trace-event@^1.0.0, chrome-trace-event@^1.0.2: +chrome-trace-event@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz#234090ee97c7d4ad1a2c4beae27505deffc608a4" integrity sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ== @@ -10039,6 +10039,11 @@ deep-extend@^0.6.0: resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== +deep-freeze-strict@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/deep-freeze-strict/-/deep-freeze-strict-1.1.1.tgz#77d0583ca24a69be4bbd9ac2fae415d55523e5b0" + integrity sha1-d9BYPKJKab5LvZrC+uQV1VUj5bA= + deep-is@^0.1.3, deep-is@~0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" @@ -10920,9 +10925,9 @@ element-resize-detector@^1.1.15: batch-processor "^1.0.0" elliptic@^6.0.0: - version "6.4.0" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.0.tgz#cac9af8762c85836187003c8dfe193e5e2eae5df" - integrity sha1-ysmvh2LIWDYYcAPI3+GT5eLq5d8= + version "6.5.2" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.2.tgz#05c5678d7173c049d8ca433552224a495d0e3762" + integrity sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw== dependencies: bn.js "^4.4.0" brorand "^1.0.1" @@ -11608,14 +11613,6 @@ eslint-rule-composer@^0.3.0: resolved "https://registry.yarnpkg.com/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz#79320c927b0c5c0d3d3d2b76c8b4a488f25bbaf9" integrity sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg== -eslint-scope@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.0.tgz#50bf3071e9338bcdc43331794a0cb533f0136172" - integrity sha512-1G6UTDi7Jc1ELFwnR58HV4fK9OQK4S6N985f166xqXxpjU6plxFISJa2Ba9KCQuFa8RCnj/lSFJbHo7UFDBnUA== - dependencies: - esrecurse "^4.1.0" - estraverse "^4.1.1" - eslint-scope@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" @@ -18292,16 +18289,11 @@ load-source-map@^1.0.0: semver "^5.3.0" source-map "^0.5.6" -loader-runner@^2.3.0, loader-runner@^2.4.0: +loader-runner@^2.3.1, loader-runner@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357" integrity sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw== -loader-runner@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.1.tgz#026f12fe7c3115992896ac02ba022ba92971b979" - integrity sha512-By6ZFY7ETWOc9RFaAIb23IjJVcM4dvJC/N57nmdz9RSkMXvAXGI7SyVlAw3v8vjtDRlqThgVDVmTnr9fqMlxkw== - loader-utils@1.2.3, loader-utils@^1.0.4, loader-utils@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7" @@ -19223,7 +19215,7 @@ memory-fs@^0.2.0: resolved "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz#f2bb25368bc121e391c2520de92969caee0a0290" integrity sha1-8rslNovBIeORwlIN6Slpyu4KApA= -memory-fs@^0.4.0, memory-fs@^0.4.1, memory-fs@~0.4.1: +memory-fs@^0.4.0, memory-fs@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" integrity sha1-OpoguEYlI+RHz7x+i7gO1me/xVI= @@ -19327,7 +19319,7 @@ microevent.ts@~0.1.1: resolved "https://registry.yarnpkg.com/microevent.ts/-/microevent.ts-0.1.1.tgz#70b09b83f43df5172d0205a63025bce0f7357fa0" integrity sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g== -micromatch@3.1.10, micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.8: +micromatch@3.1.10, micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4: version "3.1.10" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== @@ -20304,7 +20296,7 @@ node-jose@1.1.0: util "^0.11.0" vm-browserify "0.0.4" -node-libs-browser@^2.0.0, node-libs-browser@^2.2.1: +node-libs-browser@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425" integrity sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q== @@ -27374,7 +27366,7 @@ term-size@^1.2.0: dependencies: execa "^0.7.0" -terser-webpack-plugin@^1.1.0, terser-webpack-plugin@^1.2.4, terser-webpack-plugin@^1.4.1: +terser-webpack-plugin@^1.2.4, terser-webpack-plugin@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.1.tgz#61b18e40eaee5be97e771cdbb10ed1280888c2b4" integrity sha512-ZXmmfiwtCLfz8WKZyYUuuHf3dMYEjg8NrjHMb0JqHVHVOSkzp3cW2/XG1fP3tRhqEqSzMwzzRQGtAPbs4Cncxg== @@ -27403,35 +27395,16 @@ terser-webpack-plugin@^2.1.2: terser "^4.3.4" webpack-sources "^1.4.3" -terser@^4.1.2: - version "4.2.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-4.2.0.tgz#4b1b5f4424b426a7a47e80d6aae45e0d7979aef0" - integrity sha512-6lPt7lZdZ/13icQJp8XasFOwZjFJkxFFIb/N1fhYEQNoNI3Ilo3KABZ9OocZvZoB39r6SiIk/0+v/bt8nZoSeA== - dependencies: - commander "^2.20.0" - source-map "~0.6.1" - source-map-support "~0.5.12" - -terser@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/terser/-/terser-4.3.4.tgz#ad91bade95619e3434685d69efa621a5af5f877d" - integrity sha512-Kcrn3RiW8NtHBP0ssOAzwa2MsIRQ8lJWiBG/K7JgqPlomA3mtb2DEmp4/hrUA+Jujx+WZ02zqd7GYD+QRBB/2Q== +terser@^4.1.2, terser@^4.3.4: + version "4.6.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-4.6.1.tgz#913e35e0d38a75285a7913ba01d753c4089ebdbd" + integrity sha512-w0f2OWFD7ka3zwetgVAhNMeyzEbj39ht2Tb0qKflw9PmW9Qbo5tjTh01QJLkhO9t9RDDQYvk+WXqpECI2C6i2A== dependencies: commander "^2.20.0" source-map "~0.6.1" source-map-support "~0.5.12" -test-exclude@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-5.1.0.tgz#6ba6b25179d2d38724824661323b73e03c0c1de1" - integrity sha512-gwf0S2fFsANC55fSeSqpb8BYk6w3FDvwZxfNjeF6FRgvFa43r+7wRiA/Q0IxoRU37wB/LE8IQ4221BsNucTaCA== - dependencies: - arrify "^1.0.1" - minimatch "^3.0.4" - read-pkg-up "^4.0.0" - require-main-filename "^1.0.1" - -test-exclude@^5.2.3: +test-exclude@^5.0.0, test-exclude@^5.2.3: version "5.2.3" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-5.2.3.tgz#c3d3e1e311eb7ee405e092dac10aefd09091eac0" integrity sha512-M+oxtseCFO3EDtAaGH7iiej3CBkzXqFMbzqYAACdzKui4eZA+pq3tZEwChvOdNfa7xxy8BfbmgJSIr43cC/+2g== @@ -29950,7 +29923,7 @@ warning@^4.0.2: dependencies: loose-envify "^1.0.0" -watchpack@^1.5.0, watchpack@^1.6.0: +watchpack@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00" integrity sha512-i6dHe3EyLjMmDlU1/bGQpEw25XSjkJULPuAVKCbNRefQVq48yXKUpwg538F7AZTf9kyr57zj++pQFltUa5H7yA== @@ -30110,7 +30083,7 @@ webpack-sources@^1.1.0: source-list-map "^2.0.0" source-map "~0.6.1" -webpack-sources@^1.3.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: +webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== @@ -30118,37 +30091,7 @@ webpack-sources@^1.3.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack- source-list-map "^2.0.0" source-map "~0.6.1" -webpack@4.33.0: - version "4.33.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.33.0.tgz#c30fc4307db432e5c5e3333aaa7c16a15a3b277e" - integrity sha512-ggWMb0B2QUuYso6FPZKUohOgfm+Z0sVFs8WwWuSH1IAvkWs428VDNmOlAxvHGTB9Dm/qOB/qtE5cRx5y01clxw== - 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" - acorn "^6.0.5" - acorn-dynamic-import "^4.0.0" - ajv "^6.1.0" - ajv-keywords "^3.1.0" - chrome-trace-event "^1.0.0" - enhanced-resolve "^4.1.0" - eslint-scope "^4.0.0" - json-parse-better-errors "^1.0.2" - loader-runner "^2.3.0" - loader-utils "^1.1.0" - memory-fs "~0.4.1" - micromatch "^3.1.8" - mkdirp "~0.5.0" - neo-async "^2.5.0" - node-libs-browser "^2.0.0" - schema-utils "^1.0.0" - tapable "^1.1.0" - terser-webpack-plugin "^1.1.0" - watchpack "^1.5.0" - webpack-sources "^1.3.0" - -webpack@4.41.0, webpack@^4.41.0: +webpack@4.41.0, webpack@^4.33.0, webpack@^4.38.0, webpack@^4.41.0: version "4.41.0" resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.41.0.tgz#db6a254bde671769f7c14e90a1a55e73602fc70b" integrity sha512-yNV98U4r7wX1VJAj5kyMsu36T8RPPQntcb5fJLOsMz/pt/WrKC0Vp1bAlqPLkA1LegSwQwf6P+kAbyhRKVQ72g== @@ -30177,35 +30120,6 @@ webpack@4.41.0, webpack@^4.41.0: watchpack "^1.6.0" webpack-sources "^1.4.1" -webpack@^4.33.0, webpack@^4.38.0: - version "4.39.3" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.39.3.tgz#a02179d1032156b713b6ec2da7e0df9d037def50" - integrity sha512-BXSI9M211JyCVc3JxHWDpze85CvjC842EvpRsVTc/d15YJGlox7GIDd38kJgWrb3ZluyvIjgenbLDMBQPDcxYQ== - 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" - acorn "^6.2.1" - ajv "^6.10.2" - ajv-keywords "^3.4.1" - chrome-trace-event "^1.0.2" - enhanced-resolve "^4.1.0" - eslint-scope "^4.0.3" - json-parse-better-errors "^1.0.2" - loader-runner "^2.4.0" - loader-utils "^1.2.3" - memory-fs "^0.4.1" - micromatch "^3.1.10" - mkdirp "^0.5.1" - neo-async "^2.6.1" - node-libs-browser "^2.2.1" - schema-utils "^1.0.0" - tapable "^1.1.3" - terser-webpack-plugin "^1.4.1" - watchpack "^1.6.0" - webpack-sources "^1.4.1" - websocket-driver@>=0.5.1: version "0.7.0" resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.0.tgz#0caf9d2d755d93aee049d4bdd0d3fe2cca2a24eb"