diff --git a/.ci/Jenkinsfile_security_cypress b/.ci/Jenkinsfile_security_cypress index bdfef18024b78..d7f702a56563f 100644 --- a/.ci/Jenkinsfile_security_cypress +++ b/.ci/Jenkinsfile_security_cypress @@ -9,13 +9,20 @@ kibanaPipeline(timeoutMinutes: 180) { channel: '#security-solution-slack-testing' ) { catchError { - workers.base(size: 's', ramDisk: false) { - kibanaPipeline.bash('test/scripts/jenkins_security_solution_cypress.sh', 'Execute Security Solution Cypress Tests') + withEnv([ + 'CI_PARALLEL_PROCESS_NUMBER=1' + ]) { + def job = 'xpack-securityCypress' + + workers.ci(name: job, size: 'l', ramDisk: true) { + kibanaPipeline.bash('test/scripts/jenkins_xpack_build_kibana.sh', 'Build Default Distributable') + kibanaPipeline.functionalTestProcess(job, 'test/scripts/jenkins_security_solution_cypress.sh')() + } } } } if (params.NOTIFY_ON_FAILURE) { - kibanaPipeline.sendMail(to: 'gloria.delatorre@elastic.co') + kibanaPipeline.sendMail(to: 'siem_dev_team@elastic.co') } } diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1f076e3c84001..7e34c931c5feb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -174,6 +174,17 @@ /x-pack/plugins/security/ @elastic/kibana-security /x-pack/plugins/security/**/*.scss @elastic/kibana-core-ui-designers /x-pack/test/api_integration/apis/security/ @elastic/kibana-security +/x-pack/test/encrypted_saved_objects_api_integration/ @elastic/kibana-security +/x-pack/test/functional/apps/security/ @elastic/kibana-security +/x-pack/test/kerberos_api_integration/ @elastic/kibana-security +/x-pack/test/login_selector_api_integration/ @elastic/kibana-security +/x-pack/test/oidc_api_integration/ @elastic/kibana-security +/x-pack/test/pki_api_integration/ @elastic/kibana-security +/x-pack/test/saml_api_integration/ @elastic/kibana-security +/x-pack/test/security_api_integration/ @elastic/kibana-security +/x-pack/test/security_functional/ @elastic/kibana-security +/x-pack/test/spaces_api_integration/ @elastic/kibana-security +/x-pack/test/token_api_integration/ @elastic/kibana-security # Kibana Localization /src/dev/i18n/ @elastic/kibana-localization diff --git a/docs/developer/architecture/code-exploration.asciidoc b/docs/developer/architecture/code-exploration.asciidoc index d9502e4cb47ee..6e814921d3f32 100644 --- a/docs/developer/architecture/code-exploration.asciidoc +++ b/docs/developer/architecture/code-exploration.asciidoc @@ -313,9 +313,10 @@ To access an elasticsearch instance that has live data you have two options: WARNING: Missing README. -- {kib-repo}blob/{branch}/x-pack/plugins/beats_management[beats_management] +- {kib-repo}blob/{branch}/x-pack/plugins/beats_management/readme.md[beatsManagement] -WARNING: Missing README. +Notes: +Failure to have auth enabled in Kibana will make for a broken UI. UI-based errors not yet in place - {kib-repo}blob/{branch}/x-pack/plugins/canvas/README.md[canvas] diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index 842f90b7047c8..85e1da08b00af 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -96,5 +96,6 @@ readonly links: { readonly dateMath: string; }; readonly management: Record; + readonly visualize: Record; }; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md index 8f739950d249b..fa2d9090e3159 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.md @@ -17,5 +17,5 @@ export interface DocLinksStart | --- | --- | --- | | [DOC\_LINK\_VERSION](./kibana-plugin-core-public.doclinksstart.doc_link_version.md) | string | | | [ELASTIC\_WEBSITE\_URL](./kibana-plugin-core-public.doclinksstart.elastic_website_url.md) | string | | -| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly drilldowns: string;
};
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessSyntax: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly kibana: string;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
} | | +| [links](./kibana-plugin-core-public.doclinksstart.links.md) | {
readonly dashboard: {
readonly drilldowns: string;
};
readonly filebeat: {
readonly base: string;
readonly installation: string;
readonly configuration: string;
readonly elasticsearchOutput: string;
readonly startup: string;
readonly exportedFields: string;
};
readonly auditbeat: {
readonly base: string;
};
readonly metricbeat: {
readonly base: string;
};
readonly heartbeat: {
readonly base: string;
};
readonly logstash: {
readonly base: string;
};
readonly functionbeat: {
readonly base: string;
};
readonly winlogbeat: {
readonly base: string;
};
readonly aggs: {
readonly date_histogram: string;
readonly date_range: string;
readonly filter: string;
readonly filters: string;
readonly geohash_grid: string;
readonly histogram: string;
readonly ip_range: string;
readonly range: string;
readonly significant_terms: string;
readonly terms: string;
readonly avg: string;
readonly avg_bucket: string;
readonly max_bucket: string;
readonly min_bucket: string;
readonly sum_bucket: string;
readonly cardinality: string;
readonly count: string;
readonly cumulative_sum: string;
readonly derivative: string;
readonly geo_bounds: string;
readonly geo_centroid: string;
readonly max: string;
readonly median: string;
readonly min: string;
readonly moving_avg: string;
readonly percentile_ranks: string;
readonly serial_diff: string;
readonly std_dev: string;
readonly sum: string;
readonly top_hits: string;
};
readonly scriptedFields: {
readonly scriptFields: string;
readonly scriptAggs: string;
readonly painless: string;
readonly painlessApi: string;
readonly painlessSyntax: string;
readonly luceneExpressions: string;
};
readonly indexPatterns: {
readonly loadingData: string;
readonly introduction: string;
};
readonly addData: string;
readonly kibana: string;
readonly siem: {
readonly guide: string;
readonly gettingStarted: string;
};
readonly query: {
readonly luceneQuerySyntax: string;
readonly queryDsl: string;
readonly kueryQuerySyntax: string;
};
readonly date: {
readonly dateMath: string;
};
readonly management: Record<string, string>;
} | | diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.legacy.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.legacy.md index c683f0ba33189..abcbbf18a8f9c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.legacy.md +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.legacy.md @@ -13,6 +13,7 @@ ```typescript legacy: { + readonly config$: Observable; readonly createClient: (type: string, clientConfig?: Partial) => ILegacyCustomClusterClient; readonly client: ILegacyClusterClient; }; diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.md index 0dd41a6154a1e..ca6134cd5ed65 100644 --- a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicesetup.md @@ -15,5 +15,5 @@ export interface ElasticsearchServiceSetup | Property | Type | Description | | --- | --- | --- | -| [legacy](./kibana-plugin-core-server.elasticsearchservicesetup.legacy.md) | {
readonly createClient: (type: string, clientConfig?: Partial<LegacyElasticsearchClientConfig>) => ILegacyCustomClusterClient;
readonly client: ILegacyClusterClient;
} | | +| [legacy](./kibana-plugin-core-server.elasticsearchservicesetup.legacy.md) | {
readonly config$: Observable<ElasticsearchConfig>;
readonly createClient: (type: string, clientConfig?: Partial<LegacyElasticsearchClientConfig>) => ILegacyCustomClusterClient;
readonly client: ILegacyClusterClient;
} | | diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.legacy.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.legacy.md index 5f346d7887c2a..4026483894aa1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.legacy.md +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.legacy.md @@ -13,6 +13,7 @@ ```typescript legacy: { + readonly config$: Observable; readonly createClient: (type: string, clientConfig?: Partial) => ILegacyCustomClusterClient; readonly client: ILegacyClusterClient; }; diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.md index 860867d654435..8d9cd1be148cf 100644 --- a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.md +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicestart.md @@ -17,5 +17,5 @@ export interface ElasticsearchServiceStart | --- | --- | --- | | [client](./kibana-plugin-core-server.elasticsearchservicestart.client.md) | IClusterClient | A pre-configured [Elasticsearch client](./kibana-plugin-core-server.iclusterclient.md) | | [createClient](./kibana-plugin-core-server.elasticsearchservicestart.createclient.md) | (type: string, clientConfig?: Partial<ElasticsearchClientConfig>) => ICustomClusterClient | Create application specific Elasticsearch cluster API client with customized config. See [IClusterClient](./kibana-plugin-core-server.iclusterclient.md). | -| [legacy](./kibana-plugin-core-server.elasticsearchservicestart.legacy.md) | {
readonly createClient: (type: string, clientConfig?: Partial<LegacyElasticsearchClientConfig>) => ILegacyCustomClusterClient;
readonly client: ILegacyClusterClient;
} | | +| [legacy](./kibana-plugin-core-server.elasticsearchservicestart.legacy.md) | {
readonly config$: Observable<ElasticsearchConfig>;
readonly createClient: (type: string, clientConfig?: Partial<LegacyElasticsearchClientConfig>) => ILegacyCustomClusterClient;
readonly client: ILegacyClusterClient;
} | | diff --git a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.md b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.md index fee6124f8d866..17fafe2af0de1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.md @@ -19,6 +19,6 @@ export interface RouteConfigOptions | [authRequired](./kibana-plugin-core-server.routeconfigoptions.authrequired.md) | boolean | 'optional' | Defines authentication mode for a route: - true. A user has to have valid credentials to access a resource - false. A user can access a resource without any credentials. - 'optional'. A user can access a resource if has valid credentials or no credentials at all. Can be useful when we grant access to a resource but want to identify a user if possible.Defaults to true if an auth mechanism is registered. | | [body](./kibana-plugin-core-server.routeconfigoptions.body.md) | Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody | Additional body options [RouteConfigOptionsBody](./kibana-plugin-core-server.routeconfigoptionsbody.md). | | [tags](./kibana-plugin-core-server.routeconfigoptions.tags.md) | readonly string[] | Additional metadata tag strings to attach to the route. | -| [timeout](./kibana-plugin-core-server.routeconfigoptions.timeout.md) | number | Timeouts for processing durations. Response timeout is in milliseconds. Default value: 2 minutes | +| [timeout](./kibana-plugin-core-server.routeconfigoptions.timeout.md) | {
payload?: Method extends 'get' | 'options' ? undefined : number;
idleSocket?: number;
} | Defines per-route timeouts. | | [xsrfRequired](./kibana-plugin-core-server.routeconfigoptions.xsrfrequired.md) | Method extends 'get' ? never : boolean | Defines xsrf protection requirements for a route: - true. Requires an incoming POST/PUT/DELETE request to contain kbn-xsrf header. - false. Disables xsrf protection.Set to true by default | diff --git a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.timeout.md b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.timeout.md index 479fcf883ec4d..f602a8913964f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.timeout.md +++ b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.timeout.md @@ -4,10 +4,13 @@ ## RouteConfigOptions.timeout property -Timeouts for processing durations. Response timeout is in milliseconds. Default value: 2 minutes +Defines per-route timeouts. Signature: ```typescript -timeout?: number; +timeout?: { + payload?: Method extends 'get' | 'options' ? undefined : number; + idleSocket?: number; + }; ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esdslexpressionfunctiondefinition.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esdslexpressionfunctiondefinition.md new file mode 100644 index 0000000000000..0bd00e937eaaa --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esdslexpressionfunctiondefinition.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [EsdslExpressionFunctionDefinition](./kibana-plugin-plugins-data-public.esdslexpressionfunctiondefinition.md) + +## EsdslExpressionFunctionDefinition type + +Signature: + +```typescript +export declare type EsdslExpressionFunctionDefinition = ExpressionFunctionDefinition; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esrawresponseexpressiontypedefinition.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esrawresponseexpressiontypedefinition.md new file mode 100644 index 0000000000000..b95ae3c69bf20 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.esrawresponseexpressiontypedefinition.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [EsRawResponseExpressionTypeDefinition](./kibana-plugin-plugins-data-public.esrawresponseexpressiontypedefinition.md) + +## EsRawResponseExpressionTypeDefinition type + +Signature: + +```typescript +export declare type EsRawResponseExpressionTypeDefinition = ExpressionTypeDefinition; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index 53c30b52cb985..dc83cfb930d7d 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -126,7 +126,9 @@ | [AggParam](./kibana-plugin-plugins-data-public.aggparam.md) | | | [CustomFilter](./kibana-plugin-plugins-data-public.customfilter.md) | | | [EsaggsExpressionFunctionDefinition](./kibana-plugin-plugins-data-public.esaggsexpressionfunctiondefinition.md) | | +| [EsdslExpressionFunctionDefinition](./kibana-plugin-plugins-data-public.esdslexpressionfunctiondefinition.md) | | | [EsQuerySortValue](./kibana-plugin-plugins-data-public.esquerysortvalue.md) | | +| [EsRawResponseExpressionTypeDefinition](./kibana-plugin-plugins-data-public.esrawresponseexpressiontypedefinition.md) | | | [ExistsFilter](./kibana-plugin-plugins-data-public.existsfilter.md) | | | [FieldFormatId](./kibana-plugin-plugins-data-public.fieldformatid.md) | id type is needed for creating custom converters. | | [FieldFormatsContentType](./kibana-plugin-plugins-data-public.fieldformatscontenttype.md) | \* | diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 7dc360fd721f4..9f13c152b4cbe 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -222,19 +222,19 @@ might increase the search time. This setting is off by default. Users must opt-i [float] [[kibana-siem-settings]] -==== SIEM +==== Security Solution [horizontal] -`siem:defaultAnomalyScore`:: The threshold above which Machine Learning job anomalies are displayed in the SIEM app. -`siem:defaultIndex`:: A comma-delimited list of Elasticsearch indices from which the SIEM app collects events. -`siem:ipReputationLinks`:: A JSON array containing links for verifying the reputation of an IP address. The links are displayed on -{security-guide}/siem-ui-overview.html#network-ui[IP detail] pages. -`siem:enableNewsFeed`:: Enables the security news feed on the SIEM *Overview* +`securitySolution:defaultAnomalyScore`:: The threshold above which Machine Learning job anomalies are displayed in the Security app. +`securitySolution:defaultIndex`:: A comma-delimited list of Elasticsearch indices from which the Security app collects events. +`securitySolution:ipReputationLinks`:: A JSON array containing links for verifying the reputation of an IP address. The links are displayed on +{security-guide}/network-page-overview.html[IP detail] pages. +`securitySolution:enableNewsFeed`:: Enables the security news feed on the Security *Overview* page. -`siem:newsFeedUrl`:: The URL from which the security news feed content is +`securitySolution:newsFeedUrl`:: The URL from which the security news feed content is retrieved. -`siem:refreshIntervalDefaults`:: The default refresh interval for the SIEM time filter, in milliseconds. -`siem:timeDefaults`:: The default period of time in the SIEM time filter. +`securitySolution:refreshIntervalDefaults`:: The default refresh interval for the Security time filter, in milliseconds. +`securitySolution:timeDefaults`:: The default period of time in the Security time filter. [float] [[kibana-timelion-settings]] diff --git a/docs/settings/reporting-settings.asciidoc b/docs/settings/reporting-settings.asciidoc index b31ae76d28052..0b6f94e86a39f 100644 --- a/docs/settings/reporting-settings.asciidoc +++ b/docs/settings/reporting-settings.asciidoc @@ -129,7 +129,7 @@ control the capturing process. | Specify how long to allow the Reporting browser to wait for the "Loading..." screen to dismiss and find the initial data for the Kibana page. If the time is exceeded, a page screenshot is captured showing the current state, and the download link shows a warning message. - Defaults to `30000` (30 seconds). + Defaults to `60000` (1 minute). | `xpack.reporting.capture.timeouts.waitForElements` | Specify how long to allow the Reporting browser to wait for all visualization diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index dc7f585f3e4c3..a0995cab984d4 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -96,16 +96,13 @@ The valid settings in the `xpack.security.authc.providers` namespace vary depend `..showInSelector` | Flag that indicates if the provider should have an entry on the Login Selector UI. Setting this to `false` doesn't remove the provider from the authentication chain. -|=== - +2+a| +[TIP] [NOTE] ============ You are unable to set this setting to `false` for `basic` and `token` authentication providers. ============ -[cols="2*<"] -|=== - | `xpack.security.authc.providers.` `..accessAgreement.message` | Access agreement text in Markdown format. For more information, refer to <>. @@ -125,8 +122,8 @@ In addition to <.maxRedirectURLSize` -| The maximum size of the URL that {kib} is allowed to store during the authentication SAML handshake. For more information, refer to <>. +`saml..useRelayStateDeepLink` +| Determines if the provider should treat the `RelayState` parameter as a deep link in {kib} during Identity Provider initiated log in. By default, this setting is set to `false`. The link specified in `RelayState` should be a relative, URL-encoded {kib} URL. For example, the `/app/dashboards#/list` link in `RelayState` parameter would look like this: `RelayState=%2Fapp%2Fdashboards%23%2Flist`. |=== @@ -164,19 +161,27 @@ There is a very limited set of cases when you'd want to change these settings. F |=== [float] -[[login-selector-settings]] -===== Login Selector UI settings +[[login-ui-settings]] +===== Login user interface settings + +You can configure the following settings in the `kibana.yml` file. [cols="2*<"] |=== +| `xpack.security.loginAssistanceMessage` +| Adds a message to the login UI. Useful for displaying information about maintenance windows, links to corporate sign up pages, and so on. + +| `xpack.security.loginHelp` +| Adds a message accessible at the login UI with additional help information for the login process. + | `xpack.security.authc.selector.enabled` -| Determines if the Login Selector UI should be enabled. By default, this setting is set to `true` if more than one authentication provider is configured. +| Determines if the login selector UI should be enabled. By default, this setting is set to `true` if more than one authentication provider is configured. |=== [float] -[[security-ui-settings]] -==== User interface security settings +[[security-session-and-cookie-settings]] +==== Session and cookie security settings You can configure the following settings in the `kibana.yml` file. @@ -186,8 +191,7 @@ You can configure the following settings in the `kibana.yml` file. | Sets the name of the cookie used for the session. The default value is `"sid"`. | `xpack.security.encryptionKey` - | An arbitrary string of 32 characters or more that is used to encrypt credentials - in a cookie. It is crucial that this key is not exposed to users of {kib}. By + | An arbitrary string of 32 characters or more that is used to encrypt session information. Do **not** expose this key to users of {kib}. By default, a value is automatically generated in memory. If you use that default behavior, all sessions are invalidated when {kib} restarts. In addition, high-availability deployments of {kib} will behave unexpectedly @@ -205,42 +209,33 @@ You can configure the following settings in the `kibana.yml` file. This is *not set* by default, which modern browsers will treat as `Lax`. If you use Kibana embedded in an iframe in modern browsers, you might need to set it to `None`. Setting this value to `None` requires cookies to be sent over a secure connection by setting `xpack.security.secureCookies: true`. | `xpack.security.session.idleTimeout` - | Sets the session duration. By default, sessions stay active until the - browser is closed. When this is set to an explicit idle timeout, closing the - browser still requires the user to log back in to {kib}. - -|=== + | Ensures that user sessions will expire after a period of inactivity. This and `xpack.security.session.lifespan` are both +highly recommended. By default, this setting is not set. +2+a| [TIP] ============ -The format is a string of `[ms|s|m|h|d|w|M|Y]` -(e.g. '70ms', '5s', '3d', '1Y'). +The format is a string of `[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', '7d', '1w'). ============ -[cols="2*<"] -|=== - | `xpack.security.session.lifespan` - | Sets the maximum duration, also known as "absolute timeout". By default, - a session can be renewed indefinitely. When this value is set, a session will end - once its lifespan is exceeded, even if the user is not idle. NOTE: if `idleTimeout` - is not set, this setting will still cause sessions to expire. - -|=== + | Ensures that user sessions will expire after the defined time period. This behavior also known as an "absolute timeout". If +this is _not_ set, user sessions could stay active indefinitely. This and `xpack.security.session.idleTimeout` are both highly +recommended. By default, this setting is not set. +2+a| [TIP] ============ -The format is a -string of `[ms|s|m|h|d|w|M|Y]` (e.g. '70ms', '5s', '3d', '1Y'). +The format is a string of `[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', '7d', '1w'). ============ -[cols="2*<"] -|=== +| `xpack.security.session.cleanupInterval` +| Sets the interval at which {kib} tries to remove expired and invalid sessions from the session index. By default, this value is 1 hour. The minimum value is 10 seconds. -| `xpack.security.loginAssistanceMessage` - | Adds a message to the login screen. Useful for displaying information about maintenance windows, links to corporate sign up pages etc. - -| `xpack.security.loginHelp` - | Adds a message accessible at the Login Selector UI with additional help information for the login process. +2+a| +[TIP] +============ +The format is a string of `[ms\|s\|m\|h\|d\|w\|M\|Y]` (e.g. '20m', '24h', '7d', '1w'). +============ |=== diff --git a/docs/setup/production.asciidoc b/docs/setup/production.asciidoc index 23dbb3346b7ef..3075220e3a47c 100644 --- a/docs/setup/production.asciidoc +++ b/docs/setup/production.asciidoc @@ -132,7 +132,7 @@ server.port Settings that must be the same: -------- -xpack.security.encryptionKey //decrypting session cookies +xpack.security.encryptionKey //decrypting session information xpack.reporting.encryptionKey //decrypting reports xpack.encryptedSavedObjects.encryptionKey // decrypting saved objects -------- diff --git a/docs/siem/images/cases-ui.png b/docs/siem/images/cases-ui.png index d7b125b87a004..cb6361581d19e 100644 Binary files a/docs/siem/images/cases-ui.png and b/docs/siem/images/cases-ui.png differ diff --git a/docs/siem/images/detections-ui.png b/docs/siem/images/detections-ui.png index b698ac5d08469..b3fd7b5b51b8b 100644 Binary files a/docs/siem/images/detections-ui.png and b/docs/siem/images/detections-ui.png differ diff --git a/docs/siem/images/hosts-ui.png b/docs/siem/images/hosts-ui.png index 77cdb227e1d0b..57b09e340355e 100644 Binary files a/docs/siem/images/hosts-ui.png and b/docs/siem/images/hosts-ui.png differ diff --git a/docs/siem/images/ml-ui.png b/docs/siem/images/ml-ui.png index 568ae324dadd7..e301f6e28a45f 100644 Binary files a/docs/siem/images/ml-ui.png and b/docs/siem/images/ml-ui.png differ diff --git a/docs/siem/images/network-ui.png b/docs/siem/images/network-ui.png index 52caa7835d51a..a33040c41ddd3 100644 Binary files a/docs/siem/images/network-ui.png and b/docs/siem/images/network-ui.png differ diff --git a/docs/siem/images/overview-ui.png b/docs/siem/images/overview-ui.png index 09128775a5097..cf5475c89952e 100644 Binary files a/docs/siem/images/overview-ui.png and b/docs/siem/images/overview-ui.png differ diff --git a/docs/siem/images/timeline-ui.png b/docs/siem/images/timeline-ui.png index fbf5843fc445c..ad1794c4b93c9 100644 Binary files a/docs/siem/images/timeline-ui.png and b/docs/siem/images/timeline-ui.png differ diff --git a/docs/siem/index.asciidoc b/docs/siem/index.asciidoc index ceb4ac2bf1f34..18895f0533fd7 100644 --- a/docs/siem/index.asciidoc +++ b/docs/siem/index.asciidoc @@ -1,19 +1,22 @@ [role="xpack"] [[xpack-siem]] -= SIEM += Elastic Security [partintro] -- -The SIEM app in Kibana provides an interactive workspace for security teams to -triage events and perform initial investigations. It enables analysis of -host-related and network-related security events as part of alert investigations -or interactive threat hunting. +Elastic Security combines SIEM threat detection features with endpoint +prevention and response capabilities in one solution, including: +* A detection engine to identify attacks and system misconfiguration +* A workspace for event triage and investigations +* Interactive visualizations to investigate process relationships +* Embedded case management and automated actions +* Detection of signatureless attacks with prebuilt {ml} anomaly jobs and +detection rules [role="screenshot"] -image::siem/images/overview-ui.png[SIEM Overview in Kibana] - +image::siem/images/overview-ui.png[Elastic Security in Kibana] [float] == Add data @@ -31,15 +34,14 @@ https://www.elastic.co/products/beats/winlogbeat[{winlogbeat}], and https://www.elastic.co/products/beats/packetbeat[{packetbeat}] send security events and other data to Elasticsearch. -The default index patterns for SIEM events are `auditbeat-*`, `winlogbeat-*`, -`filebeat-*`, `packetbeat-*`, `endgame-*`, and `apm-*-transaction*`. You can -change the default index patterns in -*Kibana > Management > Advanced Settings > siem:defaultIndex*. +The default index patterns for Elastic Security events are `auditbeat-*`, `winlogbeat-*`, +`filebeat-*`, `packetbeat-*`, `endgame-*`, `logs-*`, and `apm-*-transaction*`. To change the default pattern patterns, go to *Stack Management > Advanced Settings > securitySolution:defaultIndex*. [float] -=== Elastic Endpoint Sensor Management Platform +=== Elastic Security endpoint agent -The Elastic Endpoint Sensor Management Platform (SMP) ships host and network events directly to the SIEM application, and is fully ECS compliant. +The agent detects and protects against malware, and ships host and network +events directly to Elastic Security. [float] === Elastic Common Schema (ECS) for normalizing data @@ -49,7 +51,7 @@ used for storing event data in Elasticsearch. ECS helps users normalize their event data to better analyze, visualize, and correlate the data represented in their events. -SIEM can ingest and normalize events from ECS-compatible data sources. +Elastic Security can ingest and normalize events from ECS-compatible data sources. -- diff --git a/docs/siem/machine-learning.asciidoc b/docs/siem/machine-learning.asciidoc index baaa789cccd7e..c56332f1183ce 100644 --- a/docs/siem/machine-learning.asciidoc +++ b/docs/siem/machine-learning.asciidoc @@ -3,14 +3,12 @@ == Anomaly Detection with Machine Learning For *{ess-trial}[Free Trial]* -and *https://www.elastic.co/subscriptions[Platinum License]* deployments, -Machine Learning functionality is available throughout the SIEM app. You can -view the details of detected anomalies within the `Anomalies` table widget -shown on the Hosts, Network and associated Details pages, or even narrow to -the specific daterange of an anomaly from the `Max Anomaly Score` details in -the overview of the Host and IP Details pages. Each of these interfaces also -offer the ability to drag and drop details of the anomaly to Timeline, such -as the `Entity` itself, or any of the associated `Influencers`. +and *https://www.elastic.co/subscriptions[Platinum subscription]* deployments, +Machine Learning functionality is available throughout Elastic Security. You can +view the details of detected anomalies in the `Anomalies` table +shown on the Hosts, Network and associated details pages. You can drag and drop +anomaly details to Timeline, such as the `Entity` itself, or any of the +associated `Influencers`. [role="screenshot"] image::siem/images/ml-ui.png[Machine Learning - Max Anomaly Score] diff --git a/docs/siem/siem-ui.asciidoc b/docs/siem/siem-ui.asciidoc index 1caa13dc6c903..98f8bc218aa76 100644 --- a/docs/siem/siem-ui.asciidoc +++ b/docs/siem/siem-ui.asciidoc @@ -1,20 +1,20 @@ [role="xpack"] [[siem-ui]] -== Using the SIEM UI +== Using Elastic Security -The SIEM app is a highly interactive workspace for security analysts. It is -designed to be discoverable, clickable, draggable and droppable, expandable and -collapsible, resizable, moveable, and so forth. You start with an overview. Then -you can use the interactive UI to drill down into areas of interest. +Elastic Security is a highly interactive workspace designed for security +analysts. It provides a clear overview of events and alerts from your +environment, and you can use the interactive UI to drill down into areas of +interest. [float] [[hosts-ui]] === Hosts -The Hosts view provides key metrics regarding host-related security events, and -data tables and widgets that let you interact with the Timeline Event Viewer. +The Hosts page provides key metrics regarding host-related security events, and +data tables and histograms that let you interact with the Timeline Event Viewer. You can drill down for deeper insights, and drag and drop items of interest from -the Hosts view tables to Timeline for further investigation. +the Hosts page to Timeline for further investigation. [role="screenshot"] image::siem/images/hosts-ui.png[] @@ -24,11 +24,8 @@ image::siem/images/hosts-ui.png[] [[network-ui]] === Network -The Network view provides key network activity metrics, facilitates -investigation time enrichment, and provides network event tables that enable -interaction with the Timeline. You can drill down for deeper insights, and drag -and drop items of interest from the Network view to Timeline for further -investigation. +The Network page displays key network activity metrics in an interactive map, +and provides network event tables that enable interaction with Timeline. [role="screenshot"] image::siem/images/network-ui.png[] @@ -38,14 +35,13 @@ image::siem/images/network-ui.png[] === Detections (beta) The Detections feature automatically searches for threats and creates -signals when they are detected. Signal detection rules define the conditions -for creating signals. The SIEM app comes with prebuilt rules that search for -suspicious activity on your network and hosts. Additionally, you can +alerts when they are detected. Detection rules define the conditions +for when alerts are created. Elastic Security comes with prebuilt rules that +search for suspicious activity on your network and hosts. Additionally, you can create your own rules. -See {security-guide}/detection-engine-overview.html[Detections] in the SIEM -Guide for information on managing detection rules and signals via the UI -or the Detections API. +See {security-guide}/detection-engine-overview.html[Detections] for information +on managing detection rules and alerts. [role="screenshot"] image::siem/images/detections-ui.png[] @@ -54,14 +50,14 @@ image::siem/images/detections-ui.png[] [[cases-ui]] === Cases (beta) -Cases are used to open and track security issues directly in SIEM. +Cases are used to open and track security issues directly in Elastic Security. Cases list the original reporter and all users who contribute to a case (`participants`). Case comments support Markdown syntax, and allow linking to saved Timelines. Additionally, you can send cases to external systems from -within SIEM (currently ServiceNow and Jira). +within Elastic Security. For information about opening, updating, and closing cases, see -{security-guide}/cases-overview.html[Cases] in the SIEM Guide. +{security-guide}/cases-overview.html[Cases] in the Elastic Security Guide. [role="screenshot"] image::siem/images/cases-ui.png[] @@ -73,31 +69,31 @@ image::siem/images/cases-ui.png[] Timeline is your workspace for threat hunting and alert investigations. [role="screenshot"] -image::siem/images/timeline-ui.png[SIEM Timeline] +image::siem/images/timeline-ui.png[Elastic Security Timeline] You can drag objects of interest into the Timeline Event Viewer to create exactly the query filter you need. You can drag items from table widgets within Hosts and Network pages, or even from within Timeline itself. -A timeline is responsive and persists as you move through the SIEM app +A timeline is responsive and persists as you move through Elastic Security collecting data. -See the {security-guide}[Security Guide] for more details on data sources and an -overview of UI elements and capabilities. +For detailed information about Timeline, see +{security-guide}/timelines-ui.html[Investigating events in Timeline]. [float] [[sample-workflow]] === Sample workflow An analyst notices a suspicious user ID that warrants further investigation, and -clicks a url that links to the SIEM app. +clicks a URL that links to Elastic Security. -The analyst uses the tables, widgets, and filtering and search capabilities in -the SIEM app to get to the bottom of the alert. The analyst can drag items of -interest to the timeline for further analysis. +The analyst uses the tables, histograms, and filtering and search capabilities in +Elastic Security to get to the bottom of the alert. The analyst can drag items of +interest to Timeline for further analysis. -Within the timeline, the analyst can investigate further--drilling down, -searching, and filtering--and add notes and pin items of interest. +Within Timeline, the analyst can investigate further - drilling down, +searching, and filtering - and add notes and pin items of interest. The analyst can name the timeline, write summary notes, and share it with others if appropriate. diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index fe93e38151b82..bd37351e9b60a 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -59,8 +59,6 @@ For more information, refer to <>. -[float] -[[security-saml-and-long-urls]] -===== SAML and long URLs - -At the beginning of the SAML handshake, {kib} stores the initial URL in the session cookie, so it can redirect the user back to that URL after successful SAML authentication. -If the URL is long, the session cookie might exceed the maximum size supported by the browser--typically 4KB for all cookies per domain. When this happens, the session cookie is truncated, -or dropped completely, and you might experience sporadic failures during SAML authentication. - -To remedy this issue, you can decrease the maximum -size of the URL that {kib} is allowed to store during the SAML handshake. The default value is 2KB. - -[source,yaml] --------------------------------------------------------------------------------- -xpack.security.authc.providers: - saml.saml1: - order: 0 - realm: saml1 - maxRedirectURLSize: 1kb --------------------------------------------------------------------------------- - [[oidc]] ==== OpenID Connect single sign-on @@ -263,11 +239,11 @@ The following sections apply both to <> and <> ===== Access and refresh tokens Once the user logs in to {kib} Single Sign-On, either using SAML or OpenID Connect, {es} issues access and refresh tokens -that {kib} encrypts and stores them in its own session cookie. This way, the user isn't redirected to the Identity Provider -for every request that requires authentication. It also means that the {kib} session depends on the <> settings, and the user is automatically logged -out if the session expires. An access token that is stored in the session cookie can expire, in which case {kib} will -automatically renew it with a one-time-use refresh token and store it in the same cookie. +out if the session expires. An access token that is stored in the session can expire, in which case {kib} will +automatically renew it with a one-time-use refresh token and store it in the same session. {kib} can only determine if an access token has expired if it receives a request that requires authentication. If both access and refresh tokens have already expired (for example, after 24 hours of inactivity), {kib} initiates a new "handshake" and @@ -280,8 +256,7 @@ indicates that both access and refresh tokens are expired. Reloading the current [float] ===== Local and global logout -During logout, both the {kib} session cookie and access/refresh token pair are invalidated. Even if the cookie has been -leaked, it can't be re-used after logout. This is known as "local" logout. +During logout, both the {kib} session and {es} access/refresh token pair are invalidated. This is known as "local" logout. {kib} can also initiate a "global" logout or _Single Logout_ if it's supported by the external authentication provider and not explicitly disabled by {es}. In this case, the user is redirected to the external authentication provider for log out of diff --git a/docs/user/security/securing-kibana.asciidoc b/docs/user/security/securing-kibana.asciidoc index 0177ac94bd402..0f02279eaf1f3 100644 --- a/docs/user/security/securing-kibana.asciidoc +++ b/docs/user/security/securing-kibana.asciidoc @@ -56,34 +56,16 @@ xpack.security.encryptionKey: "something_at_least_32_characters" For more information, see <>. -- -. Optional: Set a timeout to expire idle sessions. By default, a session stays -active until the browser is closed. To define a sliding session expiration, set -the `xpack.security.session.idleTimeout` property in the `kibana.yml` -configuration file. The idle timeout is formatted as a duration of -`[ms|s|m|h|d|w|M|Y]` (e.g. '70ms', '5s', '3d', '1Y'). For example, set -the idle timeout to expire idle sessions after 10 minutes: +. Configure {kib}'s session expiration settings. Set both the idle timeout and lifespan settings: + -- [source,yaml] -------------------------------------------------------------------------------- -xpack.security.session.idleTimeout: "10m" +xpack.security.session.idleTimeout: "1h" +xpack.security.session.lifespan: "30d" -------------------------------------------------------------------------------- --- -. Optional: Change the maximum session duration or "lifespan" -- also known as -the "absolute timeout". By default, a session stays active until the browser is -closed. If an idle timeout is defined, a session can still be extended -indefinitely. To define a maximum session lifespan, set the -`xpack.security.session.lifespan` property in the `kibana.yml` configuration -file. The lifespan is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` -(e.g. '70ms', '5s', '3d', '1Y'). For example, set the lifespan to expire -sessions after 8 hours: -+ --- -[source,yaml] --------------------------------------------------------------------------------- -xpack.security.session.lifespan: "8h" --------------------------------------------------------------------------------- +For more information, see <>. -- . Optional: <>. @@ -146,3 +128,4 @@ include::securing-communications/index.asciidoc[] include::securing-communications/elasticsearch-mutual-tls.asciidoc[] include::audit-logging.asciidoc[] include::access-agreement.asciidoc[] +include::session-management.asciidoc[] diff --git a/docs/user/security/session-management.asciidoc b/docs/user/security/session-management.asciidoc new file mode 100644 index 0000000000000..0df5b3b31a203 --- /dev/null +++ b/docs/user/security/session-management.asciidoc @@ -0,0 +1,49 @@ +[role="xpack"] +[[xpack-security-session-management]] +=== Session management + +When you log in, {kib} creates a session that is used to authenticate subsequent requests to {kib}. A session consists of two components: an encrypted cookie that is stored in your browser, and an encrypted document in a dedicated {es} hidden index. By default, the name of that index is `.kibana_security_session_1`, where the prefix is derived from the primary `.kibana` index. If either of these components are missing, the session is no longer valid. + +When your session expires, or you log out, {kib} will invalidate your cookie and remove session information from the index. {kib} also periodically invalidates and removes any expired sessions that weren't explicitly invalidated. + +[[session-idle-timeout]] +==== Session idle timeout + +You can use `xpack.security.session.idleTimeout` to expire sessions after a period of inactivity. This and `xpack.security.session.lifespan` are both highly recommended. +By default, sessions don't expire because of inactivity. To define a sliding session expiration, set the property in the `kibana.yml` configuration file. The idle timeout is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). For example, set the idle timeout to expire sessions after 1 hour of inactivity: + +-- +[source,yaml] +-------------------------------------------------------------------------------- +xpack.security.session.idleTimeout: "1h" +-------------------------------------------------------------------------------- +-- + +[[session-lifespan]] +==== Session lifespan + +You can use `xpack.security.session.lifespan` to configure the maximum session duration or "lifespan" -- also known as the "absolute timeout". This and `xpack.security.session.idleTimeout` are both highly recommended. By default, sessions don't have a fixed lifespan, and if an idle timeout is defined, a session can still be extended indefinitely. To define a maximum session lifespan, set the property in the `kibana.yml` configuration file. The lifespan is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). For example, set the lifespan to expire sessions after 30 days: + +-- +[source,yaml] +-------------------------------------------------------------------------------- +xpack.security.session.lifespan: "30d" +-------------------------------------------------------------------------------- +-- + +[[session-cleanup-interval]] +==== Session cleanup interval + +[IMPORTANT] +============================================================================ +If you specify neither session idle timeout nor lifespan, then {kib} will not automatically remove session information from the index unless you explicitly log out. This might lead to an infinitely growing session index. Configure the idle timeout and lifespan settings for the {kib} sessions so that they can be cleaned up even if you don't explicitly log out. +============================================================================ + +You can configure the interval at which {kib} tries to remove expired and invalid sessions from the session index. By default, this value is 1 hour and cannot be less than 10 seconds. To define another interval, set the `xpack.security.session.cleanupInterval` property in the `kibana.yml` configuration file. The interval is formatted as a duration of `[ms|s|m|h|d|w|M|Y]` (e.g. '20m', '24h', '7d', '1w'). For example, schedule the session index cleanup to perform once a day: + +-- +[source,yaml] +-------------------------------------------------------------------------------- +xpack.security.session.cleanupInterval: "1d" +-------------------------------------------------------------------------------- +-- diff --git a/examples/embeddable_examples/public/book/add_book_to_library_action.tsx b/examples/embeddable_examples/public/book/add_book_to_library_action.tsx index b74a1d5642982..4ae3a545df0d0 100644 --- a/examples/embeddable_examples/public/book/add_book_to_library_action.tsx +++ b/examples/embeddable_examples/public/book/add_book_to_library_action.tsx @@ -21,6 +21,7 @@ import { i18n } from '@kbn/i18n'; import { createAction, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public'; import { BookEmbeddable, BOOK_EMBEDDABLE } from './book_embeddable'; import { ViewMode, isReferenceOrValueEmbeddable } from '../../../../src/plugins/embeddable/public'; +import { DASHBOARD_CONTAINER_TYPE } from '../../../../src/plugins/dashboard/public'; interface ActionContext { embeddable: BookEmbeddable; @@ -41,6 +42,8 @@ export const createAddBookToLibraryAction = () => return ( embeddable.type === BOOK_EMBEDDABLE && embeddable.getInput().viewMode === ViewMode.EDIT && + embeddable.getRoot().isContainer && + embeddable.getRoot().type !== DASHBOARD_CONTAINER_TYPE && isReferenceOrValueEmbeddable(embeddable) && !embeddable.inputIsRefType(embeddable.getInput()) ); diff --git a/examples/embeddable_examples/public/book/book_embeddable.tsx b/examples/embeddable_examples/public/book/book_embeddable.tsx index dd9418c0e8596..73b1629d985b7 100644 --- a/examples/embeddable_examples/public/book/book_embeddable.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable.tsx @@ -26,6 +26,7 @@ import { EmbeddableOutput, SavedObjectEmbeddableInput, ReferenceOrValueEmbeddable, + Container, } from '../../../../src/plugins/embeddable/public'; import { BookSavedObjectAttributes } from '../../common'; import { BookEmbeddableComponent } from './book_component'; @@ -103,7 +104,12 @@ export class BookEmbeddable extends Embeddable => { - return this.attributeService.getInputAsValueType(this.input); + const input = + this.getRoot() && (this.getRoot() as Container).getInput().panels[this.id].explicitInput + ? ((this.getRoot() as Container).getInput().panels[this.id] + .explicitInput as BookEmbeddableInput) + : this.input; + return this.attributeService.getInputAsValueType(input); }; getInputAsRefType = async (): Promise => { diff --git a/examples/embeddable_examples/public/book/unlink_book_from_library_action.tsx b/examples/embeddable_examples/public/book/unlink_book_from_library_action.tsx index cef77092a642a..ebab2c483c625 100644 --- a/examples/embeddable_examples/public/book/unlink_book_from_library_action.tsx +++ b/examples/embeddable_examples/public/book/unlink_book_from_library_action.tsx @@ -21,6 +21,7 @@ import { i18n } from '@kbn/i18n'; import { createAction, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public'; import { BookEmbeddable, BOOK_EMBEDDABLE } from './book_embeddable'; import { ViewMode, isReferenceOrValueEmbeddable } from '../../../../src/plugins/embeddable/public'; +import { DASHBOARD_CONTAINER_TYPE } from '../../../../src/plugins/dashboard/public'; interface ActionContext { embeddable: BookEmbeddable; @@ -41,6 +42,8 @@ export const createUnlinkBookFromLibraryAction = () => return ( embeddable.type === BOOK_EMBEDDABLE && embeddable.getInput().viewMode === ViewMode.EDIT && + embeddable.getRoot().isContainer && + embeddable.getRoot().type !== DASHBOARD_CONTAINER_TYPE && isReferenceOrValueEmbeddable(embeddable) && embeddable.inputIsRefType(embeddable.getInput()) ); diff --git a/package.json b/package.json index c79c2a2f3e33a..941cad4f0fd02 100644 --- a/package.json +++ b/package.json @@ -84,12 +84,14 @@ "**/@types/angular": "^1.6.56", "**/@types/hoist-non-react-statics": "^3.3.1", "**/@types/chai": "^4.2.11", - "**/cypress/@types/lodash": "^4.14.155", + "**/cypress/@types/lodash": "^4.14.159", + "**/cypress/lodash": "^4.17.20", "**/typescript": "3.9.5", "**/graphql-toolkit/lodash": "^4.17.15", "**/hoist-non-react-statics": "^3.3.2", "**/isomorphic-git/**/base64-js": "^1.2.1", "**/image-diff/gm/debug": "^2.6.9", + "**/load-grunt-config/lodash": "^4.17.20", "**/react-dom": "^16.12.0", "**/react": "^16.12.0", "**/react-test-renderer": "^16.12.0", @@ -205,7 +207,7 @@ "leaflet-vega": "^0.8.6", "leaflet.heat": "0.2.0", "less": "npm:@elastic/less@2.7.3-kibana", - "lodash": "^4.17.15", + "lodash": "^4.17.20", "lru-cache": "4.1.5", "markdown-it": "^10.0.0", "minimatch": "^3.0.4", @@ -332,7 +334,7 @@ "@types/json5": "^0.0.30", "@types/license-checker": "15.0.0", "@types/listr": "^0.14.0", - "@types/lodash": "^4.14.155", + "@types/lodash": "^4.14.159", "@types/lru-cache": "^5.1.0", "@types/markdown-it": "^0.0.7", "@types/minimatch": "^2.0.29", diff --git a/packages/kbn-monaco/src/xjson/grammar.ts b/packages/kbn-monaco/src/xjson/grammar.ts index fbd7b3d319c1d..e6599837dbb2e 100644 --- a/packages/kbn-monaco/src/xjson/grammar.ts +++ b/packages/kbn-monaco/src/xjson/grammar.ts @@ -22,6 +22,19 @@ export enum AnnoTypes { warning = 'warning', } +export type Parser = ReturnType; + +export interface Annotation { + name?: string; + type: AnnoTypes; + text: string; + at: number; +} + +export interface ParseResult { + annotations: Annotation[]; +} + /* eslint-disable */ export const createParser = () => { diff --git a/packages/kbn-monaco/src/xjson/index.ts b/packages/kbn-monaco/src/xjson/index.ts index 35fd35887bc56..8a4644a3792d2 100644 --- a/packages/kbn-monaco/src/xjson/index.ts +++ b/packages/kbn-monaco/src/xjson/index.ts @@ -17,8 +17,10 @@ * under the License. */ -import { registerGrammarChecker } from './language'; - +/** + * This import registers the XJSON monaco language contribution + */ +import './language'; import { ID } from './constants'; -export const XJsonLang = { registerGrammarChecker, ID }; +export const XJsonLang = { ID }; diff --git a/packages/kbn-monaco/src/xjson/language.ts b/packages/kbn-monaco/src/xjson/language.ts index 54b7004fecd8e..4ae7f2402ed2f 100644 --- a/packages/kbn-monaco/src/xjson/language.ts +++ b/packages/kbn-monaco/src/xjson/language.ts @@ -32,13 +32,16 @@ const wps = new WorkerProxyService(); registerLexerRules(monaco); // In future we will need to make this map languages to workers using "id" and/or "label" values -// that get passed in. +// that get passed in. Also this should not live inside the "xjson" dir directly. We can update this +// once we have another worker. // @ts-ignore window.MonacoEnvironment = { - getWorker: (id: any, label: any) => { - // In kibana we will probably build this once and then load with raw-loader - const blob = new Blob([workerSrc], { type: 'application/javascript' }); - return new Worker(URL.createObjectURL(blob)); + getWorker: (module: string, languageId: string) => { + if (languageId === ID) { + // In kibana we will probably build this once and then load with raw-loader + const blob = new Blob([workerSrc], { type: 'application/javascript' }); + return new Worker(URL.createObjectURL(blob)); + } }, }; @@ -47,15 +50,19 @@ monaco.languages.onLanguage(ID, async () => { }); const OWNER = 'XJSON_GRAMMAR_CHECKER'; -export const registerGrammarChecker = (editor: monaco.editor.IEditor) => { + +export const registerGrammarChecker = () => { const allDisposables: monaco.IDisposable[] = []; - const updateAnnos = async () => { - const { annotations } = await wps.getAnnos(); - const model = editor.getModel() as monaco.editor.ITextModel | null; - if (!model) { + const updateAnnotations = async (model: monaco.editor.IModel): Promise => { + if (model.isDisposed()) { return; } + const parseResult = await wps.getAnnos(model.uri); + if (!parseResult) { + return; + } + const { annotations } = parseResult; monaco.editor.setModelMarkers( model, OWNER, @@ -74,19 +81,21 @@ export const registerGrammarChecker = (editor: monaco.editor.IEditor) => { }; const onModelAdd = (model: monaco.editor.IModel) => { - allDisposables.push( - model.onDidChangeContent(async () => { - updateAnnos(); - }) - ); + if (model.getModeId() === ID) { + allDisposables.push( + model.onDidChangeContent(async () => { + updateAnnotations(model); + }) + ); - updateAnnos(); + updateAnnotations(model); + } }; - allDisposables.push(monaco.editor.onDidCreateModel(onModelAdd)); - monaco.editor.getModels().forEach(onModelAdd); return () => { wps.stop(); allDisposables.forEach((d) => d.dispose()); }; }; + +registerGrammarChecker(); diff --git a/packages/kbn-monaco/src/xjson/worker/xjson_worker.ts b/packages/kbn-monaco/src/xjson/worker/xjson_worker.ts index 501adcacb6990..c99f033d793a8 100644 --- a/packages/kbn-monaco/src/xjson/worker/xjson_worker.ts +++ b/packages/kbn-monaco/src/xjson/worker/xjson_worker.ts @@ -19,17 +19,19 @@ /* eslint-disable-next-line @kbn/eslint/module_migration */ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; -import { createParser } from '../grammar'; +import { createParser, Parser, ParseResult } from '../grammar'; export class XJsonWorker { constructor(private ctx: monaco.worker.IWorkerContext) {} - private parser: any; + private parser: Parser | undefined; - async parse() { + async parse(modelUri: string): Promise { if (!this.parser) { this.parser = createParser(); } - const [model] = this.ctx.getMirrorModels(); - return this.parser(model.getValue()); + const model = this.ctx.getMirrorModels().find((m) => m.uri.toString() === modelUri); + if (model) { + return this.parser(model.getValue()); + } } } diff --git a/packages/kbn-monaco/src/xjson/worker_proxy_service.ts b/packages/kbn-monaco/src/xjson/worker_proxy_service.ts index 17d6d56e51e59..548a413a483d9 100644 --- a/packages/kbn-monaco/src/xjson/worker_proxy_service.ts +++ b/packages/kbn-monaco/src/xjson/worker_proxy_service.ts @@ -17,32 +17,21 @@ * under the License. */ -import { AnnoTypes } from './grammar'; +import { ParseResult } from './grammar'; import { monaco } from '../monaco'; import { XJsonWorker } from './worker'; import { ID } from './constants'; -export interface Annotation { - name?: string; - type: AnnoTypes; - text: string; - at: number; -} - -export interface AnnotationsResponse { - annotations: Annotation[]; -} - export class WorkerProxyService { private worker: monaco.editor.MonacoWebWorker | undefined; - public async getAnnos(): Promise { + public async getAnnos(modelUri: monaco.Uri): Promise { if (!this.worker) { throw new Error('Worker Proxy Service has not been setup!'); } - await this.worker.withSyncedResources(monaco.editor.getModels().map(({ uri }) => uri)); + await this.worker.withSyncedResources([modelUri]); const proxy = await this.worker.getProxy(); - return proxy.parse(); + return proxy.parse(modelUri.toString()); } public setup() { diff --git a/packages/kbn-pm/package.json b/packages/kbn-pm/package.json index 78fa48979c1b5..6b36f14df95e9 100644 --- a/packages/kbn-pm/package.json +++ b/packages/kbn-pm/package.json @@ -22,7 +22,7 @@ "@types/glob": "^5.0.35", "@types/globby": "^6.1.0", "@types/has-ansi": "^3.0.0", - "@types/lodash": "^4.14.155", + "@types/lodash": "^4.14.159", "@types/log-symbols": "^2.0.0", "@types/ncp": "^2.0.1", "@types/node": ">=10.17.17 <10.20.0", diff --git a/packages/kbn-test/package.json b/packages/kbn-test/package.json index f86bcfd2bb7b2..24655f8e57026 100644 --- a/packages/kbn-test/package.json +++ b/packages/kbn-test/package.json @@ -14,7 +14,7 @@ "@kbn/babel-preset": "1.0.0", "@kbn/dev-utils": "1.0.0", "@types/joi": "^13.4.2", - "@types/lodash": "^4.14.155", + "@types/lodash": "^4.14.159", "@types/parse-link-header": "^1.0.0", "@types/strip-ansi": "^5.2.1", "@types/xml2js": "^0.4.5", diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index 50e47bdf71772..d0c2ac111eb1f 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -117,7 +117,7 @@ describe('#setup()', () => { expect.objectContaining({ id: 'app1', legacy: false, - navLinkStatus: AppNavLinkStatus.default, + navLinkStatus: AppNavLinkStatus.visible, status: AppStatus.accessible, }) ); @@ -125,7 +125,7 @@ describe('#setup()', () => { expect.objectContaining({ id: 'app2', legacy: false, - navLinkStatus: AppNavLinkStatus.default, + navLinkStatus: AppNavLinkStatus.visible, status: AppStatus.accessible, }) ); @@ -142,7 +142,7 @@ describe('#setup()', () => { expect.objectContaining({ id: 'app1', legacy: false, - navLinkStatus: AppNavLinkStatus.default, + navLinkStatus: AppNavLinkStatus.hidden, status: AppStatus.inaccessible, defaultPath: 'foo/bar', tooltip: 'App inaccessible due to reason', @@ -152,7 +152,7 @@ describe('#setup()', () => { expect.objectContaining({ id: 'app2', legacy: false, - navLinkStatus: AppNavLinkStatus.default, + navLinkStatus: AppNavLinkStatus.visible, status: AppStatus.accessible, }) ); @@ -268,7 +268,7 @@ describe('#setup()', () => { expect.objectContaining({ id: 'app2', legacy: false, - navLinkStatus: AppNavLinkStatus.default, + navLinkStatus: AppNavLinkStatus.visible, status: AppStatus.accessible, tooltip: 'App accessible', }) @@ -523,7 +523,7 @@ describe('#start()', () => { appRoute: '/app/app1', id: 'app1', legacy: false, - navLinkStatus: AppNavLinkStatus.default, + navLinkStatus: AppNavLinkStatus.visible, status: AppStatus.accessible, }) ); @@ -532,7 +532,7 @@ describe('#start()', () => { appUrl: '/my-url', id: 'app2', legacy: true, - navLinkStatus: AppNavLinkStatus.default, + navLinkStatus: AppNavLinkStatus.visible, status: AppStatus.accessible, }) ); diff --git a/src/core/public/application/utils.test.ts b/src/core/public/application/utils.test.ts index b41945aa43682..4663ca2db21e7 100644 --- a/src/core/public/application/utils.test.ts +++ b/src/core/public/application/utils.test.ts @@ -18,15 +18,15 @@ */ import { of } from 'rxjs'; -import { LegacyApp, App, AppStatus, AppNavLinkStatus } from './types'; +import { App, AppNavLinkStatus, AppStatus, LegacyApp } from './types'; import { BasePath } from '../http/base_path'; import { - removeSlashes, appendAppPath, + getAppInfo, isLegacyApp, - relativeToAbsolute, parseAppUrl, - getAppInfo, + relativeToAbsolute, + removeSlashes, } from './utils'; describe('removeSlashes', () => { @@ -494,7 +494,7 @@ describe('getAppInfo', () => { id: 'some-id', title: 'some-title', status: AppStatus.accessible, - navLinkStatus: AppNavLinkStatus.default, + navLinkStatus: AppNavLinkStatus.visible, appRoute: `/app/some-id`, legacy: false, }); @@ -509,8 +509,35 @@ describe('getAppInfo', () => { id: 'some-id', title: 'some-title', status: AppStatus.accessible, - navLinkStatus: AppNavLinkStatus.default, + navLinkStatus: AppNavLinkStatus.visible, legacy: true, }); }); + + it('computes the navLinkStatus depending on the app status', () => { + expect( + getAppInfo( + createApp({ + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.inaccessible, + }) + ) + ).toEqual( + expect.objectContaining({ + navLinkStatus: AppNavLinkStatus.hidden, + }) + ); + expect( + getAppInfo( + createApp({ + navLinkStatus: AppNavLinkStatus.default, + status: AppStatus.accessible, + }) + ) + ).toEqual( + expect.objectContaining({ + navLinkStatus: AppNavLinkStatus.visible, + }) + ); + }); }); diff --git a/src/core/public/application/utils.ts b/src/core/public/application/utils.ts index 92d25fa468c4a..c5ed7b659f3ae 100644 --- a/src/core/public/application/utils.ts +++ b/src/core/public/application/utils.ts @@ -18,7 +18,15 @@ */ import { IBasePath } from '../http'; -import { App, LegacyApp, PublicAppInfo, PublicLegacyAppInfo, ParsedAppUrl } from './types'; +import { + App, + AppNavLinkStatus, + AppStatus, + LegacyApp, + ParsedAppUrl, + PublicAppInfo, + PublicLegacyAppInfo, +} from './types'; /** * Utility to remove trailing, leading or duplicate slashes. @@ -116,12 +124,18 @@ const removeBasePath = (url: string, basePath: IBasePath, origin: string): strin }; export function getAppInfo(app: App | LegacyApp): PublicAppInfo | PublicLegacyAppInfo { + const navLinkStatus = + app.navLinkStatus === AppNavLinkStatus.default + ? app.status === AppStatus.inaccessible + ? AppNavLinkStatus.hidden + : AppNavLinkStatus.visible + : app.navLinkStatus!; if (isLegacyApp(app)) { const { updater$, ...infos } = app; return { ...infos, status: app.status!, - navLinkStatus: app.navLinkStatus!, + navLinkStatus, legacy: true, }; } else { @@ -129,7 +143,7 @@ export function getAppInfo(app: App | LegacyApp): PublicAppInfo | Publi return { ...infos, status: app.status!, - navLinkStatus: app.navLinkStatus!, + navLinkStatus, appRoute: app.appRoute!, legacy: false, }; diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 8853d95181994..bd279baa78d98 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -127,6 +127,10 @@ export class DocLinksService { kibanaSearchSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/advanced-options.html#kibana-search-settings`, dashboardSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/advanced-options.html#kibana-dashboard-settings`, }, + visualize: { + guide: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/visualize.html`, + timelionDeprecation: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/timelion.html#timelion-deprecation`, + }, }, }); } @@ -225,5 +229,6 @@ export interface DocLinksStart { readonly dateMath: string; }; readonly management: Record; + readonly visualize: Record; }; } diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 0e879d16b4637..17626418cbeeb 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -591,6 +591,7 @@ export interface DocLinksStart { readonly dateMath: string; }; readonly management: Record; + readonly visualize: Record; }; } diff --git a/src/core/server/elasticsearch/elasticsearch_service.mock.ts b/src/core/server/elasticsearch/elasticsearch_service.mock.ts index 501ab619316c2..26186efc286bf 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.mock.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.mock.ts @@ -34,6 +34,7 @@ import { ServiceStatus, ServiceStatusLevels } from '../status'; interface MockedElasticSearchServiceSetup { legacy: { + config$: BehaviorSubject; createClient: jest.Mock; client: jest.Mocked; }; @@ -49,6 +50,7 @@ type MockedElasticSearchServiceStart = MockedElasticSearchServiceSetup & { const createSetupContractMock = () => { const setupContract: MockedElasticSearchServiceSetup = { legacy: { + config$: new BehaviorSubject({} as ElasticsearchConfig), createClient: jest.fn(), client: legacyClientMock.createClusterClient(), }, @@ -65,6 +67,7 @@ const createStartContractMock = () => { client: elasticsearchClientMock.createClusterClient(), createClient: jest.fn(), legacy: { + config$: new BehaviorSubject({} as ElasticsearchConfig), createClient: jest.fn(), client: legacyClientMock.createClusterClient(), }, @@ -99,7 +102,6 @@ const createInternalSetupContractMock = () => { summary: 'Elasticsearch is available', }), legacy: { - config$: new BehaviorSubject({} as ElasticsearchConfig), ...createSetupContractMock().legacy, }, }; diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index 69bf593dd5862..2cc065aaaaeb1 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -123,6 +123,7 @@ export class ElasticsearchService client: this.client!, createClient, legacy: { + config$: this.config$, client: this.legacyClient, createClient: this.createLegacyCustomClient, }, diff --git a/src/core/server/elasticsearch/types.ts b/src/core/server/elasticsearch/types.ts index 88094af8047e7..55b5549a56a23 100644 --- a/src/core/server/elasticsearch/types.ts +++ b/src/core/server/elasticsearch/types.ts @@ -37,9 +37,14 @@ export interface ElasticsearchServiceSetup { /** * @deprecated * Use {@link ElasticsearchServiceStart.legacy} instead. - * - * */ + */ legacy: { + /** + * Provide direct access to the current elasticsearch configuration. + * + * @deprecated this will be removed in a later version. + */ + readonly config$: Observable; /** * @deprecated * Use {@link ElasticsearchServiceStart.legacy | ElasticsearchServiceStart.legacy.createClient} instead. @@ -82,11 +87,7 @@ export interface ElasticsearchServiceSetup { } /** @internal */ -export interface InternalElasticsearchServiceSetup { - // Required for the BWC with the legacy Kibana only. - readonly legacy: ElasticsearchServiceSetup['legacy'] & { - readonly config$: Observable; - }; +export interface InternalElasticsearchServiceSetup extends ElasticsearchServiceSetup { esNodesCompatibility$: Observable; status$: Observable>; } @@ -132,6 +133,12 @@ export interface ElasticsearchServiceStart { * Switch to the new elasticsearch client as soon as https://github.com/elastic/kibana/issues/35508 done. * */ legacy: { + /** + * Provide direct access to the current elasticsearch configuration. + * + * @deprecated this will be removed in a later version. + */ + readonly config$: Observable; /** * Create application specific Elasticsearch cluster API client with customized config. See {@link ILegacyClusterClient}. * diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 007d75a69b955..abe70e003732b 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -799,6 +799,7 @@ test('exposes route details of incoming request to a route handler', async () => authRequired: true, xsrfRequired: false, tags: [], + timeout: {}, }, }); }); @@ -906,6 +907,9 @@ test('exposes route details of incoming request to a route handler (POST + paylo authRequired: true, xsrfRequired: true, tags: [], + timeout: { + payload: 10000, + }, body: { parse: true, // hapi populates the default maxBytes: 1024, // hapi populates the default @@ -993,129 +997,249 @@ describe('body options', () => { }); describe('timeout options', () => { - test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a POST', async () => { - const { registerRouter, server: innerServer } = await server.setup(config); + describe('payload timeout', () => { + test('POST routes set the payload timeout', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.post( + { + path: '/', + validate: false, + options: { + timeout: { + payload: 300000, + }, + }, + }, + (context, req, res) => { + try { + return res.ok({ + body: { + timeout: req.route.options.timeout, + }, + }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener) + .post('/') + .send({ test: 1 }) + .expect(200, { + timeout: { + payload: 300000, + }, + }); + }); - const router = new Router('', logger, enhanceWithContext); - router.post( - { - path: '/', - validate: false, - options: { timeout: 300000 }, - }, - (context, req, res) => { - try { - return res.ok({ body: { timeout: req.route.options.timeout } }); - } catch (err) { - return res.internalError({ body: err.message }); + test('DELETE routes set the payload timeout', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.delete( + { + path: '/', + validate: false, + options: { + timeout: { + payload: 300000, + }, + }, + }, + (context, req, res) => { + try { + return res.ok({ + body: { + timeout: req.route.options.timeout, + }, + }); + } catch (err) { + return res.internalError({ body: err.message }); + } } - } - ); - registerRouter(router); - await server.start(); - await supertest(innerServer.listener).post('/').send({ test: 1 }).expect(200, { - timeout: 300000, + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener) + .delete('/') + .expect(200, { + timeout: { + payload: 300000, + }, + }); }); - }); - test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a GET', async () => { - const { registerRouter, server: innerServer } = await server.setup(config); + test('PUT routes set the payload timeout and automatically adjusts the idle socket timeout', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.put( + { + path: '/', + validate: false, + options: { + timeout: { + payload: 300000, + }, + }, + }, + (context, req, res) => { + try { + return res.ok({ + body: { + timeout: req.route.options.timeout, + }, + }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener) + .put('/') + .expect(200, { + timeout: { + payload: 300000, + }, + }); + }); - const router = new Router('', logger, enhanceWithContext); - router.get( - { - path: '/', - validate: false, - options: { timeout: 300000 }, - }, - (context, req, res) => { - try { - return res.ok({ body: { timeout: req.route.options.timeout } }); - } catch (err) { - return res.internalError({ body: err.message }); + test('PATCH routes set the payload timeout and automatically adjusts the idle socket timeout', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.patch( + { + path: '/', + validate: false, + options: { + timeout: { + payload: 300000, + }, + }, + }, + (context, req, res) => { + try { + return res.ok({ + body: { + timeout: req.route.options.timeout, + }, + }); + } catch (err) { + return res.internalError({ body: err.message }); + } } - } - ); - registerRouter(router); - await server.start(); - await supertest(innerServer.listener).get('/').expect(200, { - timeout: 300000, + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener) + .patch('/') + .expect(200, { + timeout: { + payload: 300000, + }, + }); }); }); - test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a DELETE', async () => { - const { registerRouter, server: innerServer } = await server.setup(config); + describe('idleSocket timeout', () => { + test('uses server socket timeout when not specified in the route', async () => { + const { registerRouter, server: innerServer } = await server.setup({ + ...config, + socketTimeout: 11000, + }); - const router = new Router('', logger, enhanceWithContext); - router.delete( - { - path: '/', - validate: false, - options: { timeout: 300000 }, - }, - (context, req, res) => { - try { - return res.ok({ body: { timeout: req.route.options.timeout } }); - } catch (err) { - return res.internalError({ body: err.message }); + const router = new Router('', logger, enhanceWithContext); + router.get( + { + path: '/', + validate: { body: schema.any() }, + }, + (context, req, res) => { + return res.ok({ + body: { + timeout: req.route.options.timeout, + }, + }); } - } - ); - registerRouter(router); - await server.start(); - await supertest(innerServer.listener).delete('/').expect(200, { - timeout: 300000, + ); + registerRouter(router); + + await server.start(); + await supertest(innerServer.listener) + .get('/') + .send() + .expect(200, { + timeout: { + idleSocket: 11000, + }, + }); }); - }); - test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a PUT', async () => { - const { registerRouter, server: innerServer } = await server.setup(config); + test('sets the socket timeout when specified in the route', async () => { + const { registerRouter, server: innerServer } = await server.setup({ + ...config, + socketTimeout: 11000, + }); - const router = new Router('', logger, enhanceWithContext); - router.put( - { - path: '/', - validate: false, - options: { timeout: 300000 }, - }, - (context, req, res) => { - try { - return res.ok({ body: { timeout: req.route.options.timeout } }); - } catch (err) { - return res.internalError({ body: err.message }); + const router = new Router('', logger, enhanceWithContext); + router.get( + { + path: '/', + validate: { body: schema.any() }, + options: { timeout: { idleSocket: 12000 } }, + }, + (context, req, res) => { + return res.ok({ + body: { + timeout: req.route.options.timeout, + }, + }); } - } - ); - registerRouter(router); - await server.start(); - await supertest(innerServer.listener).put('/').expect(200, { - timeout: 300000, + ); + registerRouter(router); + + await server.start(); + await supertest(innerServer.listener) + .get('/') + .send() + .expect(200, { + timeout: { + idleSocket: 12000, + }, + }); }); }); - test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a PATCH', async () => { - const { registerRouter, server: innerServer } = await server.setup(config); + test(`idleSocket timeout can be smaller than the payload timeout`, async () => { + const { registerRouter } = await server.setup(config); const router = new Router('', logger, enhanceWithContext); - router.patch( + router.post( { path: '/', - validate: false, - options: { timeout: 300000 }, + validate: { body: schema.any() }, + options: { + timeout: { + payload: 1000, + idleSocket: 10, + }, + }, }, (context, req, res) => { - try { - return res.ok({ body: { timeout: req.route.options.timeout } }); - } catch (err) { - return res.internalError({ body: err.message }); - } + return res.ok({ body: { timeout: req.route.options.timeout } }); } ); + registerRouter(router); + await server.start(); - await supertest(innerServer.listener).patch('/').expect(200, { - timeout: 300000, - }); }); }); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 4b70f58deba99..99ab0ef16c2f9 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -163,13 +163,17 @@ export class HttpServer { const validate = isSafeMethod(route.method) ? undefined : { payload: true }; const { authRequired, tags, body = {}, timeout } = route.options; const { accepts: allow, maxBytes, output, parse } = body; - // Hapi does not allow timeouts on payloads to be specified for 'head' or 'get' requests - const payloadTimeout = isSafeMethod(route.method) || timeout == null ? undefined : timeout; const kibanaRouteState: KibanaRouteState = { xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method), }; + // To work around https://github.com/hapijs/hapi/issues/4122 until v20, set the socket + // timeout on the route to a fake timeout only when the payload timeout is specified. + // Within the onPreAuth lifecycle of the route itself, we'll override the timeout with the + // real socket timeout. + const fakeSocketTimeout = timeout?.payload ? timeout.payload + 1 : undefined; + this.server.route({ handler: route.handler, method: route.method, @@ -177,13 +181,29 @@ export class HttpServer { options: { auth: this.getAuthOption(authRequired), app: kibanaRouteState, + ext: { + onPreAuth: { + method: (request, h) => { + // At this point, the socket timeout has only been set to work-around the HapiJS bug. + // We need to either set the real per-route timeout or use the default idle socket timeout + if (timeout?.idleSocket) { + request.raw.req.socket.setTimeout(timeout.idleSocket); + } else if (fakeSocketTimeout) { + // NodeJS uses a socket timeout of `0` to denote "no timeout" + request.raw.req.socket.setTimeout(this.config!.socketTimeout ?? 0); + } + + return h.continue; + }, + }, + }, tags: tags ? Array.from(tags) : undefined, // TODO: This 'validate' section can be removed once the legacy platform is completely removed. // We are telling Hapi that NP routes can accept any payload, so that it can bypass the default // validation applied in ./http_tools#getServerOptions // (All NP routes are already required to specify their own validation in order to access the payload) validate, - payload: [allow, maxBytes, output, parse, payloadTimeout].some( + payload: [allow, maxBytes, output, parse, timeout?.payload].some( (v) => typeof v !== 'undefined' ) ? { @@ -191,15 +211,12 @@ export class HttpServer { maxBytes, output, parse, - timeout: payloadTimeout, + timeout: timeout?.payload, } : undefined, - timeout: - timeout != null - ? { - socket: timeout + 1, // Hapi server requires the socket to be greater than payload settings so we add 1 millisecond - } - : undefined, + timeout: { + socket: fakeSocketTimeout, + }, }, }); } diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index 434e22e3cf6f5..e19c348511f1a 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -304,126 +304,204 @@ describe('Options', () => { }); describe('timeout', () => { - it('should timeout if configured with a small timeout value for a POST', async () => { - const { server: innerServer, createRouter } = await server.setup(setupDeps); - const router = createRouter('/'); + const writeBodyCharAtATime = (request: supertest.Test, body: string, interval: number) => { + return new Promise((resolve, reject) => { + let i = 0; + const intervalId = setInterval(() => { + if (i < body.length) { + request.write(body[i++]); + } else { + clearInterval(intervalId); + request.end((err, res) => { + resolve(res); + }); + } + }, interval); + request.on('error', (err) => { + clearInterval(intervalId); + reject(err); + }); + }); + }; - router.post( - { path: '/a', validate: false, options: { timeout: 1000 } }, - async (context, req, res) => { - await new Promise((resolve) => setTimeout(resolve, 2000)); - return res.ok({}); - } - ); - router.post({ path: '/b', validate: false }, (context, req, res) => res.ok({})); - await server.start(); - expect(supertest(innerServer.listener).post('/a')).rejects.toThrow('socket hang up'); - await supertest(innerServer.listener).post('/b').expect(200, {}); - }); + describe('payload', () => { + it('should timeout if POST payload sending is too slow', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); - it('should timeout if configured with a small timeout value for a PUT', async () => { - const { server: innerServer, createRouter } = await server.setup(setupDeps); - const router = createRouter('/'); + router.post( + { + options: { + body: { + accepts: ['application/json'], + }, + timeout: { payload: 100 }, + }, + path: '/a', + validate: false, + }, + async (context, req, res) => { + return res.ok({}); + } + ); + await server.start(); - router.put( - { path: '/a', validate: false, options: { timeout: 1000 } }, - async (context, req, res) => { - await new Promise((resolve) => setTimeout(resolve, 2000)); - return res.ok({}); - } - ); - router.put({ path: '/b', validate: false }, (context, req, res) => res.ok({})); - await server.start(); + // start the request + const request = supertest(innerServer.listener) + .post('/a') + .set('Content-Type', 'application/json') + .set('Transfer-Encoding', 'chunked'); - expect(supertest(innerServer.listener).put('/a')).rejects.toThrow('socket hang up'); - await supertest(innerServer.listener).put('/b').expect(200, {}); - }); + const result = writeBodyCharAtATime(request, '{"foo":"bar"}', 10); - it('should timeout if configured with a small timeout value for a DELETE', async () => { - const { server: innerServer, createRouter } = await server.setup(setupDeps); - const router = createRouter('/'); + await expect(result).rejects.toMatchInlineSnapshot(`[Error: Request Timeout]`); + }); - router.delete( - { path: '/a', validate: false, options: { timeout: 1000 } }, - async (context, req, res) => { - await new Promise((resolve) => setTimeout(resolve, 2000)); - return res.ok({}); - } - ); - router.delete({ path: '/b', validate: false }, (context, req, res) => res.ok({})); - await server.start(); - expect(supertest(innerServer.listener).delete('/a')).rejects.toThrow('socket hang up'); - await supertest(innerServer.listener).delete('/b').expect(200, {}); - }); + it('should not timeout if POST payload sending is quick', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); - it('should timeout if configured with a small timeout value for a GET', async () => { - const { server: innerServer, createRouter } = await server.setup(setupDeps); - const router = createRouter('/'); + router.post( + { + path: '/a', + validate: false, + options: { body: { accepts: 'application/json' }, timeout: { payload: 10000 } }, + }, + async (context, req, res) => res.ok({}) + ); + await server.start(); - router.get( - // Note: There is a bug within Hapi Server where it cannot set the payload timeout for a GET call but it also cannot configure a timeout less than the payload body - // so the least amount of possible time to configure the timeout is 10 seconds. - { path: '/a', validate: false, options: { timeout: 100000 } }, - async (context, req, res) => { - // Cause a wait of 20 seconds to cause the socket hangup - await new Promise((resolve) => setTimeout(resolve, 200000)); - return res.ok({}); - } - ); - router.get({ path: '/b', validate: false }, (context, req, res) => res.ok({})); - await server.start(); + // start the request + const request = supertest(innerServer.listener) + .post('/a') + .set('Content-Type', 'application/json') + .set('Transfer-Encoding', 'chunked'); + + const result = writeBodyCharAtATime(request, '{}', 10); - expect(supertest(innerServer.listener).get('/a')).rejects.toThrow('socket hang up'); - await supertest(innerServer.listener).get('/b').expect(200, {}); + await expect(result).resolves.toHaveProperty('status', 200); + }); }); - it('should not timeout if configured with a 5 minute timeout value for a POST', async () => { - const { server: innerServer, createRouter } = await server.setup(setupDeps); - const router = createRouter('/'); + describe('idleSocket', () => { + it('should timeout if payload sending has too long of an idle period', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); - router.post( - { path: '/a', validate: false, options: { timeout: 300000 } }, - async (context, req, res) => res.ok({}) - ); - await server.start(); - await supertest(innerServer.listener).post('/a').expect(200, {}); - }); + router.post( + { + path: '/a', + validate: false, + options: { + body: { + accepts: ['application/json'], + }, + timeout: { idleSocket: 10 }, + }, + }, + async (context, req, res) => { + return res.ok({}); + } + ); - it('should not timeout if configured with a 5 minute timeout value for a PUT', async () => { - const { server: innerServer, createRouter } = await server.setup(setupDeps); - const router = createRouter('/'); + await server.start(); - router.put( - { path: '/a', validate: false, options: { timeout: 300000 } }, - async (context, req, res) => res.ok({}) - ); - await server.start(); + // start the request + const request = supertest(innerServer.listener) + .post('/a') + .set('Content-Type', 'application/json') + .set('Transfer-Encoding', 'chunked'); - await supertest(innerServer.listener).put('/a').expect(200, {}); - }); + const result = writeBodyCharAtATime(request, '{}', 20); - it('should not timeout if configured with a 5 minute timeout value for a DELETE', async () => { - const { server: innerServer, createRouter } = await server.setup(setupDeps); - const router = createRouter('/'); + await expect(result).rejects.toThrow('socket hang up'); + }); - router.delete( - { path: '/a', validate: false, options: { timeout: 300000 } }, - async (context, req, res) => res.ok({}) - ); - await server.start(); - await supertest(innerServer.listener).delete('/a').expect(200, {}); - }); + it(`should not timeout if payload sending doesn't have too long of an idle period`, async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); - it('should not timeout if configured with a 5 minute timeout value for a GET', async () => { - const { server: innerServer, createRouter } = await server.setup(setupDeps); - const router = createRouter('/'); + router.post( + { + path: '/a', + validate: false, + options: { + body: { + accepts: ['application/json'], + }, + timeout: { idleSocket: 1000 }, + }, + }, + async (context, req, res) => { + return res.ok({}); + } + ); - router.get( - { path: '/a', validate: false, options: { timeout: 300000 } }, - async (context, req, res) => res.ok({}) - ); - await server.start(); - await supertest(innerServer.listener).get('/a').expect(200, {}); + await server.start(); + + // start the request + const request = supertest(innerServer.listener) + .post('/a') + .set('Content-Type', 'application/json') + .set('Transfer-Encoding', 'chunked'); + + const result = writeBodyCharAtATime(request, '{}', 10); + + await expect(result).resolves.toHaveProperty('status', 200); + }); + + it('should timeout if servers response is too slow', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.post( + { + path: '/a', + validate: false, + options: { + body: { + accepts: ['application/json'], + }, + timeout: { idleSocket: 1000, payload: 100 }, + }, + }, + async (context, req, res) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return res.ok({}); + } + ); + + await server.start(); + await expect(supertest(innerServer.listener).post('/a')).rejects.toThrow('socket hang up'); + }); + + it('should not timeout if servers response is quick', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.post( + { + path: '/a', + validate: false, + options: { + body: { + accepts: ['application/json'], + }, + timeout: { idleSocket: 2000, payload: 100 }, + }, + }, + async (context, req, res) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return res.ok({}); + } + ); + + await server.start(); + await expect(supertest(innerServer.listener).post('/a')).resolves.toHaveProperty( + 'status', + 200 + ); + }); }); }); }); diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts index 93ffb5aa48259..278bc222b754b 100644 --- a/src/core/server/http/router/request.ts +++ b/src/core/server/http/router/request.ts @@ -211,15 +211,21 @@ export class KibanaRequest< private getRouteInfo(request: Request): KibanaRequestRoute { const method = request.method as Method; - const { parse, maxBytes, allow, output } = request.route.settings.payload || {}; - const timeout = request.route.settings.timeout?.socket; + const { parse, maxBytes, allow, output, timeout: payloadTimeout } = + request.route.settings.payload || {}; + // net.Socket#timeout isn't documented, yet, and isn't part of the types... https://github.com/nodejs/node/pull/34543 + // the socket is also undefined when using @hapi/shot, or when a "fake request" is used + const socketTimeout = (request.raw.req.socket as any)?.timeout; const options = ({ authRequired: this.getAuthRequired(request), // some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8 xsrfRequired: (request.route.settings.app as KibanaRouteState)?.xsrfRequired ?? true, tags: request.route.settings.tags || [], - timeout: typeof timeout === 'number' ? timeout - 1 : undefined, // We are forced to have the timeout be 1 millisecond greater than the server and payload so we subtract one here to give the user consist settings + timeout: { + payload: payloadTimeout, + idleSocket: socketTimeout === 0 ? undefined : socketTimeout, + }, body: isSafeMethod(method) ? undefined : { diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts index 676c494bec522..ce898a34e6b2c 100644 --- a/src/core/server/http/router/route.ts +++ b/src/core/server/http/router/route.ts @@ -146,10 +146,19 @@ export interface RouteConfigOptions { body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody; /** - * Timeouts for processing durations. Response timeout is in milliseconds. - * Default value: 2 minutes + * Defines per-route timeouts. */ - timeout?: number; + timeout?: { + /** + * Milliseconds to receive the payload + */ + payload?: Method extends 'get' | 'options' ? undefined : number; + + /** + * Milliseconds the socket can be idle before it's closed + */ + idleSocket?: number; + }; } /** diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 976d92e6fe7fb..0c1e8562a1deb 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -288,10 +288,7 @@ export class LegacyService implements CoreService { capabilities: setupDeps.core.capabilities, context: setupDeps.core.context, elasticsearch: { - legacy: { - client: setupDeps.core.elasticsearch.legacy.client, - createClient: setupDeps.core.elasticsearch.legacy.createClient, - }, + legacy: setupDeps.core.elasticsearch.legacy, }, http: { createCookieSessionStorageFactory: setupDeps.core.http.createCookieSessionStorageFactory, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 21ef66230f698..03545284e14fb 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -716,6 +716,7 @@ export class ElasticsearchConfig { export interface ElasticsearchServiceSetup { // @deprecated (undocumented) legacy: { + readonly config$: Observable; readonly createClient: (type: string, clientConfig?: Partial) => ILegacyCustomClusterClient; readonly client: ILegacyClusterClient; }; @@ -727,6 +728,7 @@ export interface ElasticsearchServiceStart { readonly createClient: (type: string, clientConfig?: Partial) => ICustomClusterClient; // @deprecated (undocumented) legacy: { + readonly config$: Observable; readonly createClient: (type: string, clientConfig?: Partial) => ILegacyCustomClusterClient; readonly client: ILegacyClusterClient; }; @@ -1884,7 +1886,10 @@ export interface RouteConfigOptions { authRequired?: boolean | 'optional'; body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody; tags?: readonly string[]; - timeout?: number; + timeout?: { + payload?: Method extends 'get' | 'options' ? undefined : number; + idleSocket?: number; + }; xsrfRequired?: Method extends 'get' ? never : boolean; } diff --git a/src/dev/build/README.md b/src/dev/build/README.md index 460ab01794334..f6e11af67da33 100644 --- a/src/dev/build/README.md +++ b/src/dev/build/README.md @@ -16,6 +16,16 @@ node scripts/build --release node scripts/build --skip-node-download --debug --no-oss ``` +# Fixing out of memory issues + +Building Kibana and its distributables can take a lot of memory to finish successfully. Builds do make use of child processes, which means you can increase the amount of memory available by specifying `NODE_OPTIONS="--max-old-space-size=VALUE-IN-MEGABYTES"`. + +```sh + +# Use 4GB instead of the standard 1GB for building +NODE_OPTIONS="--max-old-space-size=4096" node scripts/build --release +``` + # Structure The majority of this logic is extracted from the grunt build that has existed forever, and is designed to maintain the general structure grunt provides including tasks and config. The [build_distributables.js] file defines which tasks are run. diff --git a/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts index f1700ef7b578c..a5b9e01714f38 100644 --- a/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts +++ b/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts @@ -17,10 +17,15 @@ * under the License. */ +import { readFileSync } from 'fs'; +import Path from 'path'; + import { ToolingLog, ToolingLogCollectingWriter, createAbsolutePathSerializer, + createRecursiveSerializer, + REPO_ROOT, } from '@kbn/dev-utils'; import { Config } from '../../lib'; @@ -37,6 +42,14 @@ log.setWriters([testWriter]); expect.addSnapshotSerializer(createAbsolutePathSerializer()); +const nodeVersion = readFileSync(Path.resolve(REPO_ROOT, '.node-version'), 'utf8').trim(); +expect.addSnapshotSerializer( + createRecursiveSerializer( + (s) => typeof s === 'string' && s.includes(nodeVersion), + (s) => s.split(nodeVersion).join('') + ) +); + async function setup() { const config = await Config.create({ isRelease: true, @@ -74,8 +87,8 @@ it('runs expected fs operations', async () => { Object { "copy": Array [ Array [ - /.node_binaries/10.21.0/node.exe, - /.node_binaries/10.21.0/win32-x64/node.exe, + /.node_binaries//node.exe, + /.node_binaries//win32-x64/node.exe, Object { "clone": true, }, @@ -83,22 +96,22 @@ it('runs expected fs operations', async () => { ], "untar": Array [ Array [ - /.node_binaries/10.21.0/node-v10.21.0-linux-x64.tar.gz, - /.node_binaries/10.21.0/linux-x64, + /.node_binaries//node-v-linux-x64.tar.gz, + /.node_binaries//linux-x64, Object { "strip": 1, }, ], Array [ - /.node_binaries/10.21.0/node-v10.21.0-linux-arm64.tar.gz, - /.node_binaries/10.21.0/linux-arm64, + /.node_binaries//node-v-linux-arm64.tar.gz, + /.node_binaries//linux-arm64, Object { "strip": 1, }, ], Array [ - /.node_binaries/10.21.0/node-v10.21.0-darwin-x64.tar.gz, - /.node_binaries/10.21.0/darwin-x64, + /.node_binaries//node-v-darwin-x64.tar.gz, + /.node_binaries//darwin-x64, Object { "strip": 1, }, diff --git a/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts index 19416963d5edd..1a850890a33fe 100644 --- a/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts +++ b/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts @@ -17,10 +17,15 @@ * under the License. */ +import Path from 'path'; +import Fs from 'fs'; + import { ToolingLog, ToolingLogCollectingWriter, createAnyInstanceSerializer, + createRecursiveSerializer, + REPO_ROOT, } from '@kbn/dev-utils'; import { Config, Platform } from '../../lib'; @@ -41,6 +46,14 @@ log.setWriters([testWriter]); expect.addSnapshotSerializer(createAnyInstanceSerializer(Config)); +const nodeVersion = Fs.readFileSync(Path.resolve(REPO_ROOT, '.node-version'), 'utf8').trim(); +expect.addSnapshotSerializer( + createRecursiveSerializer( + (s) => typeof s === 'string' && s.includes(nodeVersion), + (s) => s.split(nodeVersion).join('') + ) +); + async function setup(actualShaSums?: Record) { const config = await Config.create({ isRelease: true, @@ -87,7 +100,7 @@ it('checks shasums for each downloaded node build', async () => { [MockFunction] { "calls": Array [ Array [ - "10.21.0", + "", ], ], "results": Array [ diff --git a/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts b/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts index 7a8f7316913be..9c6ca78f7146a 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts @@ -30,7 +30,7 @@ export async function bundleDockerFiles(config: Config, log: ToolingLog, scope: `Generating kibana${scope.imageFlavor}${scope.ubiImageFlavor} docker build context bundle` ); - const dockerFilesDirName = `kibana${scope.imageFlavor}${scope.ubiImageFlavor}-${scope.versionTag}-docker-build-context`; + const dockerFilesDirName = `kibana${scope.imageFlavor}${scope.ubiImageFlavor}-${scope.version}-docker-build-context`; const dockerFilesBuildDir = resolve(scope.dockerBuildDir, dockerFilesDirName); const dockerFilesOutputDir = config.resolveFromTarget(`${dockerFilesDirName}.tar.gz`); diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker index a7e0c9acd4f38..d7f137e965327 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker @@ -237,6 +237,7 @@ kibana_vars=( xpack.security.sessionTimeout xpack.security.session.idleTimeout xpack.security.session.lifespan + xpack.security.session.cleanupInterval xpack.security.loginAssistanceMessage xpack.security.loginHelp xpack.spaces.enabled diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index 0a26729f3502d..6cf4a7af70840 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -40,16 +40,16 @@ export async function runDockerGenerator( ubi: boolean = false ) { // UBI var config - const baseOSImage = ubi ? 'registry.access.redhat.com/ubi7/ubi-minimal:7.7' : 'centos:7'; - const ubiVersionTag = 'ubi7'; + const baseOSImage = ubi ? 'registry.access.redhat.com/ubi8/ubi-minimal:latest' : 'centos:8'; + const ubiVersionTag = 'ubi8'; const ubiImageFlavor = ubi ? `-${ubiVersionTag}` : ''; // General docker var config const license = build.isOss() ? 'ASL 2.0' : 'Elastic License'; const imageFlavor = build.isOss() ? '-oss' : ''; const imageTag = 'docker.elastic.co/kibana/kibana'; - const versionTag = config.getBuildVersion(); - const artifactTarball = `kibana${imageFlavor}-${versionTag}-linux-x86_64.tar.gz`; + const version = config.getBuildVersion(); + const artifactTarball = `kibana${imageFlavor}-${version}-linux-x86_64.tar.gz`; const artifactsDir = config.resolveFromTarget('.'); const dockerBuildDate = new Date().toISOString(); // That would produce oss, default and default-ubi7 @@ -59,12 +59,12 @@ export async function runDockerGenerator( build.isOss() ? `oss` : `default${ubiImageFlavor}` ); const dockerOutputDir = config.resolveFromTarget( - `kibana${imageFlavor}${ubiImageFlavor}-${versionTag}-docker.tar.gz` + `kibana${imageFlavor}${ubiImageFlavor}-${version}-docker.tar.gz` ); const scope: TemplateContext = { artifactTarball, imageFlavor, - versionTag, + version, license, artifactsDir, imageTag, @@ -73,6 +73,8 @@ export async function runDockerGenerator( baseOSImage, ubiImageFlavor, dockerBuildDate, + ubi, + revision: config.getBuildSha(), }; // Verify if we have the needed kibana target in order diff --git a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts index 115d4c6927c30..a7c40db44b87e 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts @@ -20,7 +20,7 @@ export interface TemplateContext { artifactTarball: string; imageFlavor: string; - versionTag: string; + version: string; license: string; artifactsDir: string; imageTag: string; @@ -30,4 +30,6 @@ export interface TemplateContext { ubiImageFlavor: string; dockerBuildDate: string; usePublicArtifact?: boolean; + ubi: boolean; + revision: string; } diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile new file mode 100644 index 0000000000000..d235bfe9d6fbc --- /dev/null +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile @@ -0,0 +1,122 @@ +################################################################################ +# This Dockerfile was generated from the template at: +# src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile +# +# Beginning of multi stage Dockerfile +################################################################################ + +################################################################################ +# Build stage 0 `builder`: +# Extract Kibana artifact +################################################################################ +FROM {{{baseOSImage}}} AS builder + +{{#ubi}} +RUN {{packageManager}} install -y findutils tar gzip +{{/ubi}} + +{{#usePublicArtifact}} +RUN cd /opt && \ + curl --retry 8 -s -L -O https://artifacts.elastic.co/downloads/kibana/{{artifactTarball}} && \ + cd - +{{/usePublicArtifact}} + +{{^usePublicArtifact}} +COPY {{artifactTarball}} /opt +{{/usePublicArtifact}} + +RUN mkdir /usr/share/kibana +WORKDIR /usr/share/kibana +RUN tar --strip-components=1 -zxf /opt/{{artifactTarball}} +# Ensure that group permissions are the same as user permissions. +# This will help when relying on GID-0 to run Kibana, rather than UID-1000. +# OpenShift does this, for example. +# REF: https://docs.openshift.org/latest/creating_images/guidelines.html +RUN chmod -R g=u /usr/share/kibana +RUN find /usr/share/kibana -type d -exec chmod g+s {} \; + +################################################################################ +# Build stage 1 (the actual Kibana image): +# +# Copy kibana from stage 0 +# Add entrypoint +################################################################################ +FROM {{{baseOSImage}}} +EXPOSE 5601 + +RUN for iter in {1..10}; do \ + {{packageManager}} update --setopt=tsflags=nodocs -y && \ + {{packageManager}} install --setopt=tsflags=nodocs -y \ + fontconfig freetype shadow-utils libnss3.so {{#ubi}}findutils{{/ubi}} && \ + {{packageManager}} clean all && exit_code=0 && break || exit_code=$? && echo "{{packageManager}} error: retry $iter in 10s" && \ + sleep 10; \ + done; \ + (exit $exit_code) + +# Add an init process, check the checksum to make sure it's a match +RUN curl -L -o /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.2/dumb-init_1.2.2_amd64 +RUN echo "37f2c1f0372a45554f1b89924fbb134fc24c3756efaedf11e07f599494e0eff9 /usr/local/bin/dumb-init" | sha256sum -c - +RUN chmod +x /usr/local/bin/dumb-init + +# Bring in Kibana from the initial stage. +COPY --from=builder --chown=1000:0 /usr/share/kibana /usr/share/kibana +WORKDIR /usr/share/kibana +RUN ln -s /usr/share/kibana /opt/kibana + +ENV ELASTIC_CONTAINER true +ENV PATH=/usr/share/kibana/bin:$PATH + +# Set some Kibana configuration defaults. +COPY --chown=1000:0 config/kibana.yml /usr/share/kibana/config/kibana.yml + +# Add the launcher/wrapper script. It knows how to interpret environment +# variables and translate them to Kibana CLI options. +COPY --chown=1000:0 bin/kibana-docker /usr/local/bin/ + +# Ensure gid 0 write permissions for OpenShift. +RUN chmod g+ws /usr/share/kibana && \ + find /usr/share/kibana -gid 0 -and -not -perm /g+w -exec chmod g+w {} \; + +# Remove the suid bit everywhere to mitigate "Stack Clash" +RUN find / -xdev -perm -4000 -exec chmod u-s {} + + +# Provide a non-root user to run the process. +RUN groupadd --gid 1000 kibana && \ + useradd --uid 1000 --gid 1000 \ + --home-dir /usr/share/kibana --no-create-home \ + kibana +USER kibana + +LABEL org.label-schema.build-date="{{dockerBuildDate}}" \ + org.label-schema.license="{{license}}" \ + org.label-schema.name="Kibana" \ + org.label-schema.schema-version="1.0" \ + org.label-schema.url="https://www.elastic.co/products/kibana" \ + org.label-schema.usage="https://www.elastic.co/guide/en/kibana/reference/index.html" \ + org.label-schema.vcs-ref="{{revision}}" \ + org.label-schema.vcs-url="https://github.com/elastic/kibana" \ + org.label-schema.vendor="Elastic" \ + org.label-schema.version="{{version}}" \ + org.opencontainers.image.created="{{dockerBuildDate}}" \ + org.opencontainers.image.documentation="https://www.elastic.co/guide/en/kibana/reference/index.html" \ + org.opencontainers.image.licenses="{{license}}" \ + org.opencontainers.image.revision="{{revision}}" \ + org.opencontainers.image.source="https://github.com/elastic/kibana" \ + org.opencontainers.image.title="Kibana" \ + org.opencontainers.image.url="https://www.elastic.co/products/kibana" \ + org.opencontainers.image.vendor="Elastic" \ + org.opencontainers.image.version="{{version}}" + +{{#ubi}} +LABEL name="Kibana" \ + maintainer="infra@elastic.co" \ + vendor="Elastic" \ + version="{{version}}" \ + release="1" \ + summary="Kibana" \ + description="Your window into the Elastic Stack." +{{/ubi}} + +ENTRYPOINT ["/usr/local/bin/dumb-init", "--"] + +CMD ["/usr/local/bin/kibana-docker"] diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts index ff6fcf7548d9d..699bba758e1c9 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts @@ -24,7 +24,7 @@ import { TemplateContext } from '../template_context'; function generator({ imageTag, imageFlavor, - versionTag, + version, dockerOutputDir, baseOSImage, ubiImageFlavor, @@ -39,9 +39,9 @@ function generator({ docker pull ${baseOSImage} echo "Building: kibana${imageFlavor}${ubiImageFlavor}-docker"; \\ - docker build -t ${imageTag}${imageFlavor}${ubiImageFlavor}:${versionTag} -f Dockerfile . || exit 1; + docker build -t ${imageTag}${imageFlavor}${ubiImageFlavor}:${version} -f Dockerfile . || exit 1; - docker save ${imageTag}${imageFlavor}${ubiImageFlavor}:${versionTag} | gzip -c > ${dockerOutputDir} + docker save ${imageTag}${imageFlavor}${ubiImageFlavor}:${version} | gzip -c > ${dockerOutputDir} exit 0 `); diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts index ea2f881768c8f..9733021319aee 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts @@ -17,118 +17,18 @@ * under the License. */ -import dedent from 'dedent'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import Mustache from 'mustache'; import { TemplateContext } from '../template_context'; -function generator({ - artifactTarball, - versionTag, - license, - usePublicArtifact, - baseOSImage, - ubiImageFlavor, - dockerBuildDate, -}: TemplateContext) { - const copyArtifactTarballInsideDockerOptFolder = () => { - if (usePublicArtifact) { - return `RUN cd /opt && curl --retry 8 -s -L -O https://artifacts.elastic.co/downloads/kibana/${artifactTarball} && cd -`; - } - - return `COPY ${artifactTarball} /opt`; - }; - - const packageManager = () => { - if (ubiImageFlavor) { - return 'microdnf'; - } - - return 'yum'; - }; - - return dedent(` - # - # ** THIS IS AN AUTO-GENERATED FILE ** - # - - ################################################################################ - # Build stage 0 - # Extract Kibana and make various file manipulations. - ################################################################################ - FROM ${baseOSImage} AS prep_files - # Add tar and gzip - RUN ${packageManager()} update -y && ${packageManager()} install -y tar gzip && ${packageManager()} clean all - ${copyArtifactTarballInsideDockerOptFolder()} - RUN mkdir /usr/share/kibana - WORKDIR /usr/share/kibana - RUN tar --strip-components=1 -zxf /opt/${artifactTarball} - # Ensure that group permissions are the same as user permissions. - # This will help when relying on GID-0 to run Kibana, rather than UID-1000. - # OpenShift does this, for example. - # REF: https://docs.openshift.org/latest/creating_images/guidelines.html - RUN chmod -R g=u /usr/share/kibana - RUN find /usr/share/kibana -type d -exec chmod g+s {} \\; - - ################################################################################ - # Build stage 1 - # Copy prepared files from the previous stage and complete the image. - ################################################################################ - FROM ${baseOSImage} - EXPOSE 5601 - - # Add Reporting dependencies. - RUN ${packageManager()} update -y && ${packageManager()} install -y fontconfig freetype shadow-utils && ${packageManager()} clean all - - # Add an init process, check the checksum to make sure it's a match - RUN curl -L -o /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.2/dumb-init_1.2.2_amd64 - RUN echo "37f2c1f0372a45554f1b89924fbb134fc24c3756efaedf11e07f599494e0eff9 /usr/local/bin/dumb-init" | sha256sum -c - - RUN chmod +x /usr/local/bin/dumb-init - - - # Bring in Kibana from the initial stage. - COPY --from=prep_files --chown=1000:0 /usr/share/kibana /usr/share/kibana - WORKDIR /usr/share/kibana - RUN ln -s /usr/share/kibana /opt/kibana - - ENV ELASTIC_CONTAINER true - ENV PATH=/usr/share/kibana/bin:$PATH - - # Set some Kibana configuration defaults. - COPY --chown=1000:0 config/kibana.yml /usr/share/kibana/config/kibana.yml - - # Add the launcher/wrapper script. It knows how to interpret environment - # variables and translate them to Kibana CLI options. - COPY --chown=1000:0 bin/kibana-docker /usr/local/bin/ - - # Ensure gid 0 write permissions for OpenShift. - RUN chmod g+ws /usr/share/kibana && \\ - find /usr/share/kibana -gid 0 -and -not -perm /g+w -exec chmod g+w {} \\; - - # Remove the suid bit everywhere to mitigate "Stack Clash" - RUN find / -xdev -perm -4000 -exec chmod u-s {} + - - # Provide a non-root user to run the process. - RUN groupadd --gid 1000 kibana && \\ - useradd --uid 1000 --gid 1000 \\ - --home-dir /usr/share/kibana --no-create-home \\ - kibana - USER kibana - - LABEL org.label-schema.schema-version="1.0" \\ - org.label-schema.vendor="Elastic" \\ - org.label-schema.name="kibana" \\ - org.label-schema.version="${versionTag}" \\ - org.label-schema.url="https://www.elastic.co/products/kibana" \\ - org.label-schema.vcs-url="https://github.com/elastic/kibana" \\ - org.label-schema.license="${license}" \\ - org.label-schema.usage="https://www.elastic.co/guide/en/kibana/index.html" \\ - org.label-schema.build-date="${dockerBuildDate}" \\ - license="${license}" - - ENTRYPOINT ["/usr/local/bin/dumb-init", "--"] - - CMD ["/usr/local/bin/kibana-docker"] - `); +function generator(options: TemplateContext) { + const template = readFileSync(resolve(__dirname, './Dockerfile')); + return Mustache.render(template.toString(), { + packageManager: options.ubiImageFlavor ? 'microdnf' : 'yum', + ...options, + }); } export const dockerfileTemplate = { diff --git a/src/plugins/dashboard/public/application/actions/index.ts b/src/plugins/dashboard/public/application/actions/index.ts index d7a84fb79f6af..be183976c676f 100644 --- a/src/plugins/dashboard/public/application/actions/index.ts +++ b/src/plugins/dashboard/public/application/actions/index.ts @@ -32,3 +32,8 @@ export { ClonePanelActionContext, ACTION_CLONE_PANEL, } from './clone_panel_action'; +export { + UnlinkFromLibraryActionContext, + ACTION_UNLINK_FROM_LIBRARY, + UnlinkFromLibraryAction, +} from './unlink_from_library_action'; diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx new file mode 100644 index 0000000000000..681a6a734a532 --- /dev/null +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.test.tsx @@ -0,0 +1,160 @@ +/* + * 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 { isErrorEmbeddable, IContainer, ReferenceOrValueEmbeddable } from '../../embeddable_plugin'; +import { DashboardContainer } from '../embeddable'; +import { getSampleDashboardInput } from '../test_helpers'; +import { + CONTACT_CARD_EMBEDDABLE, + ContactCardEmbeddableFactory, + ContactCardEmbeddable, + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, +} from '../../embeddable_plugin_test_samples'; +import { coreMock } from '../../../../../core/public/mocks'; +import { CoreStart } from 'kibana/public'; +import { UnlinkFromLibraryAction } from '.'; +import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { ViewMode } from '../../../../embeddable/public'; + +const { setup, doStart } = embeddablePluginMock.createInstance(); +setup.registerEmbeddableFactory( + CONTACT_CARD_EMBEDDABLE, + new ContactCardEmbeddableFactory((() => null) as any, {} as any) +); +const start = doStart(); + +let container: DashboardContainer; +let embeddable: ContactCardEmbeddable & ReferenceOrValueEmbeddable; +let coreStart: CoreStart; +beforeEach(async () => { + coreStart = coreMock.createStart(); + + const containerOptions = { + ExitFullScreenButton: () => null, + SavedObjectFinder: () => null, + application: {} as any, + embeddable: start, + inspector: {} as any, + notifications: {} as any, + overlays: coreStart.overlays, + savedObjectMetaData: {} as any, + uiActions: {} as any, + }; + + container = new DashboardContainer(getSampleDashboardInput(), containerOptions); + + const contactCardEmbeddable = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Kibanana', + }); + + if (isErrorEmbeddable(contactCardEmbeddable)) { + throw new Error('Failed to create embeddable'); + } + embeddable = embeddablePluginMock.mockRefOrValEmbeddable< + ContactCardEmbeddable, + ContactCardEmbeddableInput + >(contactCardEmbeddable, { + mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: contactCardEmbeddable.id }, + mockedByValueInput: { firstName: 'Kibanana', id: contactCardEmbeddable.id }, + }); + embeddable.updateInput({ viewMode: ViewMode.EDIT }); +}); + +test('Unlink is compatible when embeddable on dashboard has reference type input', async () => { + const action = new UnlinkFromLibraryAction(); + embeddable.updateInput(await embeddable.getInputAsRefType()); + expect(await action.isCompatible({ embeddable })).toBe(true); +}); + +test('Unlink is not compatible when embeddable input is by value', async () => { + const action = new UnlinkFromLibraryAction(); + embeddable.updateInput(await embeddable.getInputAsValueType()); + expect(await action.isCompatible({ embeddable })).toBe(false); +}); + +test('Unlink is not compatible when view mode is set to view', async () => { + const action = new UnlinkFromLibraryAction(); + embeddable.updateInput(await embeddable.getInputAsRefType()); + embeddable.updateInput({ viewMode: ViewMode.VIEW }); + expect(await action.isCompatible({ embeddable })).toBe(false); +}); + +test('Unlink is not compatible when embeddable is not in a dashboard container', async () => { + let orphanContactCard = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Orphan', + }); + orphanContactCard = embeddablePluginMock.mockRefOrValEmbeddable< + ContactCardEmbeddable, + ContactCardEmbeddableInput + >(orphanContactCard, { + mockedByReferenceInput: { savedObjectId: 'test', id: orphanContactCard.id }, + mockedByValueInput: { firstName: 'Kibanana', id: orphanContactCard.id }, + }); + const action = new UnlinkFromLibraryAction(); + expect(await action.isCompatible({ embeddable: orphanContactCard })).toBe(false); +}); + +test('Unlink replaces embeddableId but retains panel count', async () => { + const dashboard = embeddable.getRoot() as IContainer; + const originalPanelCount = Object.keys(dashboard.getInput().panels).length; + const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); + const action = new UnlinkFromLibraryAction(); + await action.execute({ embeddable }); + expect(Object.keys(container.getInput().panels).length).toEqual(originalPanelCount); + + const newPanelId = Object.keys(container.getInput().panels).find( + (key) => !originalPanelKeySet.has(key) + ); + expect(newPanelId).toBeDefined(); + const newPanel = container.getInput().panels[newPanelId!]; + expect(newPanel.type).toEqual(embeddable.type); +}); + +test('Unlink unwraps all attributes from savedObject', async () => { + const complicatedAttributes = { + attribute1: 'The best attribute', + attribute2: 22, + attribute3: ['array', 'of', 'strings'], + attribute4: { nestedattribute: 'hello from the nest' }, + }; + + embeddable = embeddablePluginMock.mockRefOrValEmbeddable(embeddable, { + mockedByReferenceInput: { savedObjectId: 'testSavedObjectId', id: embeddable.id }, + mockedByValueInput: { attributes: complicatedAttributes, id: embeddable.id }, + }); + const dashboard = embeddable.getRoot() as IContainer; + const originalPanelKeySet = new Set(Object.keys(dashboard.getInput().panels)); + const action = new UnlinkFromLibraryAction(); + await action.execute({ embeddable }); + const newPanelId = Object.keys(container.getInput().panels).find( + (key) => !originalPanelKeySet.has(key) + ); + expect(newPanelId).toBeDefined(); + const newPanel = container.getInput().panels[newPanelId!]; + expect(newPanel.type).toEqual(embeddable.type); + expect(newPanel.explicitInput.attributes).toEqual(complicatedAttributes); +}); diff --git a/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx new file mode 100644 index 0000000000000..e2a6ec7dd3947 --- /dev/null +++ b/src/plugins/dashboard/public/application/actions/unlink_from_library_action.tsx @@ -0,0 +1,92 @@ +/* + * 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 _ from 'lodash'; +import uuid from 'uuid'; +import { ActionByType, IncompatibleActionError } from '../../ui_actions_plugin'; +import { ViewMode, PanelState, IEmbeddable } from '../../embeddable_plugin'; +import { + PanelNotFoundError, + EmbeddableInput, + isReferenceOrValueEmbeddable, +} from '../../../../embeddable/public'; +import { DashboardPanelState, DASHBOARD_CONTAINER_TYPE, DashboardContainer } from '..'; + +export const ACTION_UNLINK_FROM_LIBRARY = 'unlinkFromLibrary'; + +export interface UnlinkFromLibraryActionContext { + embeddable: IEmbeddable; +} + +export class UnlinkFromLibraryAction implements ActionByType { + public readonly type = ACTION_UNLINK_FROM_LIBRARY; + public readonly id = ACTION_UNLINK_FROM_LIBRARY; + public order = 15; + + constructor() {} + + public getDisplayName({ embeddable }: UnlinkFromLibraryActionContext) { + if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { + throw new IncompatibleActionError(); + } + return i18n.translate('dashboard.panel.unlinkFromLibrary', { + defaultMessage: 'Unlink from library item', + }); + } + + public getIconType({ embeddable }: UnlinkFromLibraryActionContext) { + if (!embeddable.getRoot() || !embeddable.getRoot().isContainer) { + throw new IncompatibleActionError(); + } + return 'folderExclamation'; + } + + public async isCompatible({ embeddable }: UnlinkFromLibraryActionContext) { + return Boolean( + embeddable.getInput()?.viewMode !== ViewMode.VIEW && + embeddable.getRoot() && + embeddable.getRoot().isContainer && + embeddable.getRoot().type === DASHBOARD_CONTAINER_TYPE && + isReferenceOrValueEmbeddable(embeddable) && + embeddable.inputIsRefType(embeddable.getInput()) + ); + } + + public async execute({ embeddable }: UnlinkFromLibraryActionContext) { + if (!isReferenceOrValueEmbeddable(embeddable)) { + throw new IncompatibleActionError(); + } + + const newInput = await embeddable.getInputAsValueType(); + embeddable.updateInput(newInput); + + const dashboard = embeddable.getRoot() as DashboardContainer; + const panelToReplace = dashboard.getInput().panels[embeddable.id] as DashboardPanelState; + if (!panelToReplace) { + throw new PanelNotFoundError(); + } + + const newPanel: PanelState = { + type: embeddable.type, + explicitInput: { ...newInput, id: uuid.v4() }, + }; + dashboard.replacePanel(panelToReplace, newPanel); + } +} diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx index 3a4e49968626f..7a19514eebe17 100644 --- a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx @@ -468,9 +468,14 @@ export class DashboardAppController { const explicitInput = { savedVis: input, }; + const embeddableId = + 'embeddableId' in incomingEmbeddable + ? incomingEmbeddable.embeddableId + : undefined; container.addOrUpdateEmbeddable( incomingEmbeddable.type, - explicitInput + explicitInput, + embeddableId ); } } diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index ff74580ba256b..036880a1d088b 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -171,6 +171,7 @@ export class DashboardContainer extends Container = IEmbeddable - >(type: string, explicitInput: Partial) { - if (explicitInput.id && this.input.panels[explicitInput.id]) { - this.replacePanel(this.input.panels[explicitInput.id], { + >(type: string, explicitInput: Partial, embeddableId?: string) { + const idToReplace = embeddableId || explicitInput.id; + if (idToReplace && this.input.panels[idToReplace]) { + this.replacePanel(this.input.panels[idToReplace], { type, explicitInput: { ...explicitInput, diff --git a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap index 7985d34b117f5..82c90530c2b4c 100644 --- a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap +++ b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.js.snap @@ -11,6 +11,7 @@ exports[`after fetch hideWriteControls 1`] = ` findItems={[Function]} headingId="dashboardListingHeading" initialFilter="" + initialPageSize={10} listingLimit={1} noItemsFragment={
@@ -68,6 +69,7 @@ exports[`after fetch initialFilter 1`] = ` findItems={[Function]} headingId="dashboardListingHeading" initialFilter="my dashboard" + initialPageSize={10} listingLimit={1000} noItemsFragment={
@@ -169,6 +171,7 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = ` findItems={[Function]} headingId="dashboardListingHeading" initialFilter="" + initialPageSize={10} listingLimit={1} noItemsFragment={
@@ -270,6 +273,7 @@ exports[`after fetch renders table rows 1`] = ` findItems={[Function]} headingId="dashboardListingHeading" initialFilter="" + initialPageSize={10} listingLimit={1000} noItemsFragment={
@@ -371,6 +375,7 @@ exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` findItems={[Function]} headingId="dashboardListingHeading" initialFilter="" + initialPageSize={10} listingLimit={1} noItemsFragment={
diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.js b/src/plugins/dashboard/public/application/listing/dashboard_listing.js index c8cb551fbe561..1a7a6b1d75234 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.js +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.js @@ -115,7 +115,7 @@ export class DashboardListing extends React.Component { sampleDataInstallLink: ( - this.props.core.application.navigateTo('home', { + this.props.core.application.navigateToApp('home', { path: '#/tutorial_directory/sampleData', }) } diff --git a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.js b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.js index dccac4e7c3c76..6acb491f9a20c 100644 --- a/src/plugins/dashboard/public/application/listing/dashboard_listing.test.js +++ b/src/plugins/dashboard/public/application/listing/dashboard_listing.test.js @@ -79,6 +79,7 @@ describe('after fetch', () => { getViewUrl={() => {}} listingLimit={1000} hideWriteControls={false} + initialPageSize={10} initialFilter="my dashboard" core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} /> @@ -101,6 +102,7 @@ describe('after fetch', () => { editItem={() => {}} getViewUrl={() => {}} listingLimit={1000} + initialPageSize={10} hideWriteControls={false} core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} /> @@ -123,6 +125,7 @@ describe('after fetch', () => { editItem={() => {}} getViewUrl={() => {}} listingLimit={1} + initialPageSize={10} hideWriteControls={false} core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} /> @@ -145,6 +148,7 @@ describe('after fetch', () => { editItem={() => {}} getViewUrl={() => {}} listingLimit={1} + initialPageSize={10} hideWriteControls={true} core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} /> @@ -167,6 +171,7 @@ describe('after fetch', () => { editItem={() => {}} getViewUrl={() => {}} listingLimit={1} + initialPageSize={10} hideWriteControls={false} core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} /> diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 3b0863a9f4651..2a36f2d801850 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -80,6 +80,9 @@ import { RenderDeps, ReplacePanelAction, ReplacePanelActionContext, + ACTION_UNLINK_FROM_LIBRARY, + UnlinkFromLibraryActionContext, + UnlinkFromLibraryAction, } from './application'; import { createDashboardUrlGenerator, @@ -152,6 +155,7 @@ declare module '../../../plugins/ui_actions/public' { [ACTION_EXPAND_PANEL]: ExpandPanelActionContext; [ACTION_REPLACE_PANEL]: ReplacePanelActionContext; [ACTION_CLONE_PANEL]: ClonePanelActionContext; + [ACTION_UNLINK_FROM_LIBRARY]: UnlinkFromLibraryActionContext; } } @@ -163,6 +167,7 @@ export class DashboardPlugin private stopUrlTracking: (() => void) | undefined = undefined; private getActiveUrl: (() => string) | undefined = undefined; private currentHistory: ScopedHistory | undefined = undefined; + private dashboardFeatureFlagConfig?: DashboardFeatureFlagConfig; private dashboardUrlGenerator?: DashboardUrlGenerator; @@ -170,6 +175,9 @@ export class DashboardPlugin core: CoreSetup, { share, uiActions, embeddable, home, kibanaLegacy, data, usageCollection }: SetupDependencies ): Setup { + this.dashboardFeatureFlagConfig = this.initializerContext.config.get< + DashboardFeatureFlagConfig + >(); const expandPanelAction = new ExpandPanelAction(); uiActions.registerAction(expandPanelAction); uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id); @@ -415,6 +423,12 @@ export class DashboardPlugin uiActions.registerAction(clonePanelAction); uiActions.attachAction(CONTEXT_MENU_TRIGGER, clonePanelAction.id); + if (this.dashboardFeatureFlagConfig?.allowByValueEmbeddables) { + const unlinkFromLibraryAction = new UnlinkFromLibraryAction(); + uiActions.registerAction(unlinkFromLibraryAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, unlinkFromLibraryAction.id); + } + const savedDashboardLoader = createSavedDashboardLoader({ savedObjectsClient: core.savedObjects.client, indexPatterns, @@ -430,7 +444,7 @@ export class DashboardPlugin getSavedDashboardLoader: () => savedDashboardLoader, addEmbeddableToDashboard: this.addEmbeddableToDashboard.bind(this, core), dashboardUrlGenerator: this.dashboardUrlGenerator, - dashboardFeatureFlagConfig: this.initializerContext.config.get(), + dashboardFeatureFlagConfig: this.dashboardFeatureFlagConfig!, DashboardContainerByValueRenderer: createDashboardContainerByValueRenderer({ factory: dashboardContainerFactory, }), diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index d35069207ee84..ecf076aa517fb 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -363,6 +363,9 @@ export { SearchResponse, SearchSourceFields, SortDirection, + // expression functions and types + EsdslExpressionFunctionDefinition, + EsRawResponseExpressionTypeDefinition, } from './search'; // Search namespace diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index e6a48794d8b0f..564c571b6ccd6 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -148,7 +148,7 @@ export class DataPublicPlugin const searchService = this.searchService.setup(core, { usageCollection, packageInfo: this.packageInfo, - registerFunction: expressions.registerFunction, + expressions, }); return { diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 744376403e1a1..58c2bd9957ab8 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -348,6 +348,15 @@ export const ES_SEARCH_STRATEGY = "es"; // @public (undocumented) export type EsaggsExpressionFunctionDefinition = ExpressionFunctionDefinition<'esaggs', Input, Arguments, Output>; +// Warning: (ae-forgotten-export) The symbol "name" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Input" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Arguments" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Output" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "EsdslExpressionFunctionDefinition" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type EsdslExpressionFunctionDefinition = ExpressionFunctionDefinition; + // Warning: (ae-missing-release-tag) "esFilters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -444,6 +453,14 @@ export interface EsQueryConfig { // @public (undocumented) export type EsQuerySortValue = Record; +// Warning: (ae-forgotten-export) The symbol "ExpressionTypeDefinition" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "name" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "EsRawResponse" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "EsRawResponseExpressionTypeDefinition" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type EsRawResponseExpressionTypeDefinition = ExpressionTypeDefinition; + // Warning: (ae-missing-release-tag) "ExistsFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1975,21 +1992,21 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:371:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:372:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:381:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:382:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:383:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:384:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:392:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:393:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:372:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:372:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:372:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:372:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:374:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:375:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:384:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:385:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:386:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:387:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:391:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:392:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:395:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:45:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:62:5 - (ae-forgotten-export) The symbol "createFiltersFromValueClickAction" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:63:5 - (ae-forgotten-export) The symbol "createFiltersFromRangeSelectAction" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/expressions/__snapshots__/es_raw_response.test.ts.snap b/src/plugins/data/public/search/expressions/__snapshots__/es_raw_response.test.ts.snap new file mode 100644 index 0000000000000..c43663a50a2ba --- /dev/null +++ b/src/plugins/data/public/search/expressions/__snapshots__/es_raw_response.test.ts.snap @@ -0,0 +1,193 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`esRawResponse converts aggregations to table simple aggregation response 1`] = ` +Object { + "columns": Array [ + Object { + "id": "2.buckets.key", + "meta": Object { + "field": "2.buckets.key", + "params": Object {}, + "type": "string", + }, + "name": "2.buckets.key", + }, + Object { + "id": "2.buckets.doc_count", + "meta": Object { + "field": "2.buckets.doc_count", + "params": Object {}, + "type": "number", + }, + "name": "2.buckets.doc_count", + }, + Object { + "id": "2.doc_count_error_upper_bound", + "meta": Object { + "field": "2.doc_count_error_upper_bound", + "params": Object {}, + "type": "number", + }, + "name": "2.doc_count_error_upper_bound", + }, + Object { + "id": "2.sum_other_doc_count", + "meta": Object { + "field": "2.sum_other_doc_count", + "params": Object {}, + "type": "number", + }, + "name": "2.sum_other_doc_count", + }, + ], + "meta": Object { + "source": "*", + "type": "esdsl", + }, + "rows": Array [ + Object { + "2.buckets.doc_count": 1033, + "2.buckets.key": "FEMALE", + "2.doc_count_error_upper_bound": 0, + "2.sum_other_doc_count": 0, + }, + Object { + "2.buckets.doc_count": 944, + "2.buckets.key": "MALE", + "2.doc_count_error_upper_bound": 0, + "2.sum_other_doc_count": 0, + }, + ], + "type": "datatable", +} +`; + +exports[`esRawResponse converts raw docs to table simple docs response 1`] = ` +Object { + "columns": Array [ + Object { + "id": "order_date", + "meta": Object { + "field": "order_date", + "params": Object {}, + "type": "object", + }, + "name": "order_date", + }, + Object { + "id": "products.created_on", + "meta": Object { + "field": "products.created_on", + "params": Object {}, + "type": "object", + }, + "name": "products.created_on", + }, + ], + "meta": Object { + "source": "*", + "type": "esdsl", + }, + "rows": Array [ + Object { + "order_date": Array [ + "2020-07-13T09:27:22.000Z", + ], + "products.created_on": Array [ + "2016-12-12T09:27:22.000Z", + "2016-12-12T09:27:22.000Z", + ], + }, + Object { + "order_date": Array [ + "2020-07-15T08:12:29.000Z", + ], + "products.created_on": Array [ + "2016-12-14T08:12:29.000Z", + "2016-12-14T08:12:29.000Z", + ], + }, + Object { + "order_date": Array [ + "2020-07-15T01:26:24.000Z", + ], + "products.created_on": Array [ + "2016-12-14T01:26:24.000Z", + "2016-12-14T01:26:24.000Z", + ], + }, + Object { + "order_date": Array [ + "2020-07-10T19:55:12.000Z", + ], + "products.created_on": Array [ + "2016-12-09T19:55:12.000Z", + "2016-12-09T19:55:12.000Z", + ], + }, + ], + "type": "datatable", +} +`; + +exports[`esRawResponse returns aggs if both docs and aggs are present on response 1`] = ` +Object { + "columns": Array [ + Object { + "id": "2.buckets.key", + "meta": Object { + "field": "2.buckets.key", + "params": Object {}, + "type": "string", + }, + "name": "2.buckets.key", + }, + Object { + "id": "2.buckets.doc_count", + "meta": Object { + "field": "2.buckets.doc_count", + "params": Object {}, + "type": "number", + }, + "name": "2.buckets.doc_count", + }, + Object { + "id": "2.doc_count_error_upper_bound", + "meta": Object { + "field": "2.doc_count_error_upper_bound", + "params": Object {}, + "type": "number", + }, + "name": "2.doc_count_error_upper_bound", + }, + Object { + "id": "2.sum_other_doc_count", + "meta": Object { + "field": "2.sum_other_doc_count", + "params": Object {}, + "type": "number", + }, + "name": "2.sum_other_doc_count", + }, + ], + "meta": Object { + "source": "*", + "type": "esdsl", + }, + "rows": Array [ + Object { + "2.buckets.doc_count": 1033, + "2.buckets.key": "FEMALE", + "2.doc_count_error_upper_bound": 0, + "2.sum_other_doc_count": 0, + }, + Object { + "2.buckets.doc_count": 944, + "2.buckets.key": "MALE", + "2.doc_count_error_upper_bound": 0, + "2.sum_other_doc_count": 0, + }, + ], + "type": "datatable", +} +`; diff --git a/src/plugins/data/public/search/expressions/__snapshots__/esdsl.test.ts.snap b/src/plugins/data/public/search/expressions/__snapshots__/esdsl.test.ts.snap new file mode 100644 index 0000000000000..cd71217276373 --- /dev/null +++ b/src/plugins/data/public/search/expressions/__snapshots__/esdsl.test.ts.snap @@ -0,0 +1,191 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`esdsl correctly handles filter, query and timerange on context 1`] = ` +Object { + "body": Object { + "params": Object { + "body": Object { + "_source": false, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "match_phrase": Object { + "gender": "male", + }, + }, + ], + "must": Array [ + Object { + "query_string": Object { + "query": "*", + "time_zone": true, + }, + }, + Object { + "term": Object { + "machine.os.keyword": "osx", + }, + }, + ], + "must_not": Array [], + "should": Array [], + }, + }, + }, + "index": "kibana_sample_data_logs", + "size": 4, + }, + }, + "type": "es_raw_response", +} +`; + +exports[`esdsl correctly handles input adds filters 1`] = ` +Object { + "body": Object { + "params": Object { + "body": Object { + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "match_phrase": Object { + "gender": "male", + }, + }, + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + }, + }, + "index": "test", + "size": 0, + }, + }, + "type": "es_raw_response", +} +`; + +exports[`esdsl correctly handles input adds filters to query with filters 1`] = ` +Object { + "body": Object { + "params": Object { + "body": Object { + "_source": false, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "match_phrase": Object { + "gender": "male", + }, + }, + ], + "must": Array [ + Object { + "term": Object { + "machine.os.keyword": "osx", + }, + }, + ], + "must_not": Array [], + "should": Array [], + }, + }, + }, + "index": "kibana_sample_data_logs", + "size": 4, + }, + }, + "type": "es_raw_response", +} +`; + +exports[`esdsl correctly handles input adds query 1`] = ` +Object { + "body": Object { + "params": Object { + "body": Object { + "query": Object { + "bool": Object { + "filter": Array [], + "must": Array [ + Object { + "query_string": Object { + "query": "*", + "time_zone": true, + }, + }, + ], + "must_not": Array [], + "should": Array [], + }, + }, + }, + "index": "test", + "size": 0, + }, + }, + "type": "es_raw_response", +} +`; + +exports[`esdsl correctly handles input adds query to a query with filters 1`] = ` +Object { + "body": Object { + "params": Object { + "body": Object { + "_source": false, + "query": Object { + "bool": Object { + "filter": Array [], + "must": Array [ + Object { + "query_string": Object { + "query": "*", + "time_zone": true, + }, + }, + Object { + "term": Object { + "machine.os.keyword": "osx", + }, + }, + ], + "must_not": Array [], + "should": Array [], + }, + }, + }, + "index": "kibana_sample_data_logs", + "size": 4, + }, + }, + "type": "es_raw_response", +} +`; + +exports[`esdsl correctly handles input ignores timerange 1`] = ` +Object { + "body": Object { + "params": Object { + "body": Object { + "query": Object { + "bool": Object { + "filter": Array [], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + }, + }, + "index": "test", + "size": 0, + }, + }, + "type": "es_raw_response", +} +`; diff --git a/src/plugins/data/public/search/expressions/es_raw_response.test.ts b/src/plugins/data/public/search/expressions/es_raw_response.test.ts new file mode 100644 index 0000000000000..4acb75fa4a255 --- /dev/null +++ b/src/plugins/data/public/search/expressions/es_raw_response.test.ts @@ -0,0 +1,827 @@ +/* + * 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 { EsRawResponse, esRawResponse } from './es_raw_response'; + +jest.mock('@kbn/i18n', () => { + return { + i18n: { + translate: (id: string, { defaultMessage }: { defaultMessage: string }) => defaultMessage, + }, + }; +}); + +describe('esRawResponse', () => { + describe('converts aggregations to table', () => { + test('simple aggregation response', () => { + const response: EsRawResponse = { + type: 'es_raw_response', + body: { + took: 7, + timed_out: false, + _shards: { + total: 7, + successful: 7, + skipped: 0, + failed: 0, + }, + hits: { + total: 1977, + max_score: 0, + hits: [], + }, + aggregations: { + '2': { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'FEMALE', + doc_count: 1033, + }, + { + key: 'MALE', + doc_count: 944, + }, + ], + }, + }, + }, + }; + const result = esRawResponse.to!.datatable(response, {}); + expect(result).toMatchSnapshot(); + }); + }); + + describe('converts raw docs to table', () => { + test('simple docs response', () => { + const response: EsRawResponse = { + type: 'es_raw_response', + body: { + took: 5, + timed_out: false, + _shards: { + total: 7, + successful: 7, + skipped: 0, + failed: 0, + }, + hits: { + total: 1977, + max_score: 0, + hits: [ + { + _index: 'kibana_sample_data_ecommerce', + _id: 'AncqUnMBMY_orZma2mZy', + _type: 'document', + _score: 0, + _source: { + category: ["Men's Clothing"], + currency: 'EUR', + customer_first_name: 'Oliver', + customer_full_name: 'Oliver Rios', + customer_gender: 'MALE', + customer_id: 7, + customer_last_name: 'Rios', + customer_phone: '', + day_of_week: 'Monday', + day_of_week_i: 0, + email: 'oliver@rios-family.zzz', + manufacturer: ['Low Tide Media', 'Elitelligence'], + order_date: '2020-07-13T09:27:22+00:00', + order_id: 565855, + products: [ + { + base_price: 20.99, + discount_percentage: 0, + quantity: 1, + manufacturer: 'Low Tide Media', + tax_amount: 0, + product_id: 19919, + category: "Men's Clothing", + sku: 'ZO0417504175', + taxless_price: 20.99, + unit_discount_amount: 0, + min_price: 9.87, + _id: 'sold_product_565855_19919', + discount_amount: 0, + created_on: '2016-12-12T09:27:22+00:00', + product_name: 'Shirt - dark blue white', + price: 20.99, + taxful_price: 20.99, + base_unit_price: 20.99, + }, + { + base_price: 24.99, + discount_percentage: 0, + quantity: 1, + manufacturer: 'Elitelligence', + tax_amount: 0, + product_id: 24502, + category: "Men's Clothing", + sku: 'ZO0535205352', + taxless_price: 24.99, + unit_discount_amount: 0, + min_price: 12.49, + _id: 'sold_product_565855_24502', + discount_amount: 0, + created_on: '2016-12-12T09:27:22+00:00', + product_name: 'Slim fit jeans - raw blue', + price: 24.99, + taxful_price: 24.99, + base_unit_price: 24.99, + }, + ], + sku: ['ZO0417504175', 'ZO0535205352'], + taxful_total_price: 45.98, + taxless_total_price: 45.98, + total_quantity: 2, + total_unique_products: 2, + type: 'order', + user: 'oliver', + geoip: { + country_iso_code: 'GB', + location: { + lon: -0.1, + lat: 51.5, + }, + continent_name: 'Europe', + }, + event: { + dataset: 'sample_ecommerce', + }, + }, + fields: { + order_date: ['2020-07-13T09:27:22.000Z'], + 'products.created_on': ['2016-12-12T09:27:22.000Z', '2016-12-12T09:27:22.000Z'], + }, + }, + { + _index: 'kibana_sample_data_ecommerce', + _id: 'I3cqUnMBMY_orZma2mZy', + _type: 'document', + _score: 0, + _source: { + category: ["Men's Clothing"], + currency: 'EUR', + customer_first_name: 'Boris', + customer_full_name: 'Boris Bradley', + customer_gender: 'MALE', + customer_id: 36, + customer_last_name: 'Bradley', + customer_phone: '', + day_of_week: 'Wednesday', + day_of_week_i: 2, + email: 'boris@bradley-family.zzz', + manufacturer: ['Microlutions', 'Elitelligence'], + order_date: '2020-07-15T08:12:29+00:00', + order_id: 568397, + products: [ + { + base_price: 32.99, + discount_percentage: 0, + quantity: 1, + manufacturer: 'Microlutions', + tax_amount: 0, + product_id: 24419, + category: "Men's Clothing", + sku: 'ZO0112101121', + taxless_price: 32.99, + unit_discount_amount: 0, + min_price: 17.48, + _id: 'sold_product_568397_24419', + discount_amount: 0, + created_on: '2016-12-14T08:12:29+00:00', + product_name: 'Cargo trousers - oliv', + price: 32.99, + taxful_price: 32.99, + base_unit_price: 32.99, + }, + { + base_price: 28.99, + discount_percentage: 0, + quantity: 1, + manufacturer: 'Elitelligence', + tax_amount: 0, + product_id: 20207, + category: "Men's Clothing", + sku: 'ZO0530405304', + taxless_price: 28.99, + unit_discount_amount: 0, + min_price: 13.92, + _id: 'sold_product_568397_20207', + discount_amount: 0, + created_on: '2016-12-14T08:12:29+00:00', + product_name: 'Trousers - black', + price: 28.99, + taxful_price: 28.99, + base_unit_price: 28.99, + }, + ], + sku: ['ZO0112101121', 'ZO0530405304'], + taxful_total_price: 61.98, + taxless_total_price: 61.98, + total_quantity: 2, + total_unique_products: 2, + type: 'order', + user: 'boris', + geoip: { + country_iso_code: 'GB', + location: { + lon: -0.1, + lat: 51.5, + }, + continent_name: 'Europe', + }, + event: { + dataset: 'sample_ecommerce', + }, + }, + fields: { + order_date: ['2020-07-15T08:12:29.000Z'], + 'products.created_on': ['2016-12-14T08:12:29.000Z', '2016-12-14T08:12:29.000Z'], + }, + }, + { + _index: 'kibana_sample_data_ecommerce', + _id: 'JHcqUnMBMY_orZma2mZy', + _score: 0, + _type: 'document', + _source: { + category: ["Men's Clothing"], + currency: 'EUR', + customer_first_name: 'Oliver', + customer_full_name: 'Oliver Hubbard', + customer_gender: 'MALE', + customer_id: 7, + customer_last_name: 'Hubbard', + customer_phone: '', + day_of_week: 'Wednesday', + day_of_week_i: 2, + email: 'oliver@hubbard-family.zzz', + manufacturer: ['Spritechnologies', 'Microlutions'], + order_date: '2020-07-15T01:26:24+00:00', + order_id: 568044, + products: [ + { + base_price: 14.99, + discount_percentage: 0, + quantity: 1, + manufacturer: 'Spritechnologies', + tax_amount: 0, + product_id: 12799, + category: "Men's Clothing", + sku: 'ZO0630406304', + taxless_price: 14.99, + unit_discount_amount: 0, + min_price: 6.9, + _id: 'sold_product_568044_12799', + discount_amount: 0, + created_on: '2016-12-14T01:26:24+00:00', + product_name: 'Undershirt - dark grey multicolor', + price: 14.99, + taxful_price: 14.99, + base_unit_price: 14.99, + }, + { + base_price: 16.99, + discount_percentage: 0, + quantity: 1, + manufacturer: 'Microlutions', + tax_amount: 0, + product_id: 18008, + category: "Men's Clothing", + sku: 'ZO0120201202', + taxless_price: 16.99, + unit_discount_amount: 0, + min_price: 8.83, + _id: 'sold_product_568044_18008', + discount_amount: 0, + created_on: '2016-12-14T01:26:24+00:00', + product_name: 'Long sleeved top - purple', + price: 16.99, + taxful_price: 16.99, + base_unit_price: 16.99, + }, + ], + sku: ['ZO0630406304', 'ZO0120201202'], + taxful_total_price: 31.98, + taxless_total_price: 31.98, + total_quantity: 2, + total_unique_products: 2, + type: 'order', + user: 'oliver', + geoip: { + country_iso_code: 'GB', + location: { + lon: -0.1, + lat: 51.5, + }, + continent_name: 'Europe', + }, + event: { + dataset: 'sample_ecommerce', + }, + }, + fields: { + order_date: ['2020-07-15T01:26:24.000Z'], + 'products.created_on': ['2016-12-14T01:26:24.000Z', '2016-12-14T01:26:24.000Z'], + }, + }, + { + _index: 'kibana_sample_data_ecommerce', + _id: 'LHcqUnMBMY_orZma2mZy', + _score: 0, + _type: 'document', + _source: { + category: ["Women's Shoes", "Women's Clothing"], + currency: 'EUR', + customer_first_name: 'Wilhemina St.', + customer_full_name: 'Wilhemina St. Parker', + customer_gender: 'FEMALE', + customer_id: 17, + customer_last_name: 'Parker', + customer_phone: '', + day_of_week: 'Friday', + day_of_week_i: 4, + email: 'wilhemina st.@parker-family.zzz', + manufacturer: ['Low Tide Media', 'Tigress Enterprises'], + order_date: '2020-07-10T19:55:12+00:00', + order_id: 562351, + products: [ + { + base_price: 49.99, + discount_percentage: 0, + quantity: 1, + manufacturer: 'Low Tide Media', + tax_amount: 0, + product_id: 18495, + category: "Women's Shoes", + sku: 'ZO0376403764', + taxless_price: 49.99, + unit_discount_amount: 0, + min_price: 25, + _id: 'sold_product_562351_18495', + discount_amount: 0, + created_on: '2016-12-09T19:55:12+00:00', + product_name: 'Ankle boots - cognac', + price: 49.99, + taxful_price: 49.99, + base_unit_price: 49.99, + }, + { + base_price: 28.99, + discount_percentage: 0, + quantity: 1, + manufacturer: 'Tigress Enterprises', + tax_amount: 0, + product_id: 22598, + category: "Women's Clothing", + sku: 'ZO0050800508', + taxless_price: 28.99, + unit_discount_amount: 0, + min_price: 14.78, + _id: 'sold_product_562351_22598', + discount_amount: 0, + created_on: '2016-12-09T19:55:12+00:00', + product_name: 'Shift dress - black', + price: 28.99, + taxful_price: 28.99, + base_unit_price: 28.99, + }, + ], + sku: ['ZO0376403764', 'ZO0050800508'], + taxful_total_price: 78.98, + taxless_total_price: 78.98, + total_quantity: 2, + total_unique_products: 2, + type: 'order', + user: 'wilhemina', + geoip: { + country_iso_code: 'MC', + location: { + lon: 7.4, + lat: 43.7, + }, + continent_name: 'Europe', + city_name: 'Monte Carlo', + }, + event: { + dataset: 'sample_ecommerce', + }, + }, + fields: { + order_date: ['2020-07-10T19:55:12.000Z'], + 'products.created_on': ['2016-12-09T19:55:12.000Z', '2016-12-09T19:55:12.000Z'], + }, + }, + ], + }, + }, + }; + const result = esRawResponse.to!.datatable(response, {}); + expect(result).toMatchSnapshot(); + }); + }); + + test('returns aggs if both docs and aggs are present on response', () => { + const response: EsRawResponse = { + type: 'es_raw_response', + body: { + took: 5, + timed_out: false, + _shards: { + total: 7, + successful: 7, + skipped: 0, + failed: 0, + }, + hits: { + total: 1977, + max_score: 0, + hits: [ + { + _index: 'kibana_sample_data_ecommerce', + _id: 'AncqUnMBMY_orZma2mZy', + _type: 'document', + _score: 0, + _source: { + category: ["Men's Clothing"], + currency: 'EUR', + customer_first_name: 'Oliver', + customer_full_name: 'Oliver Rios', + customer_gender: 'MALE', + customer_id: 7, + customer_last_name: 'Rios', + customer_phone: '', + day_of_week: 'Monday', + day_of_week_i: 0, + email: 'oliver@rios-family.zzz', + manufacturer: ['Low Tide Media', 'Elitelligence'], + order_date: '2020-07-13T09:27:22+00:00', + order_id: 565855, + products: [ + { + base_price: 20.99, + discount_percentage: 0, + quantity: 1, + manufacturer: 'Low Tide Media', + tax_amount: 0, + product_id: 19919, + category: "Men's Clothing", + sku: 'ZO0417504175', + taxless_price: 20.99, + unit_discount_amount: 0, + min_price: 9.87, + _id: 'sold_product_565855_19919', + discount_amount: 0, + created_on: '2016-12-12T09:27:22+00:00', + product_name: 'Shirt - dark blue white', + price: 20.99, + taxful_price: 20.99, + base_unit_price: 20.99, + }, + { + base_price: 24.99, + discount_percentage: 0, + quantity: 1, + manufacturer: 'Elitelligence', + tax_amount: 0, + product_id: 24502, + category: "Men's Clothing", + sku: 'ZO0535205352', + taxless_price: 24.99, + unit_discount_amount: 0, + min_price: 12.49, + _id: 'sold_product_565855_24502', + discount_amount: 0, + created_on: '2016-12-12T09:27:22+00:00', + product_name: 'Slim fit jeans - raw blue', + price: 24.99, + taxful_price: 24.99, + base_unit_price: 24.99, + }, + ], + sku: ['ZO0417504175', 'ZO0535205352'], + taxful_total_price: 45.98, + taxless_total_price: 45.98, + total_quantity: 2, + total_unique_products: 2, + type: 'order', + user: 'oliver', + geoip: { + country_iso_code: 'GB', + location: { + lon: -0.1, + lat: 51.5, + }, + continent_name: 'Europe', + }, + event: { + dataset: 'sample_ecommerce', + }, + }, + fields: { + order_date: ['2020-07-13T09:27:22.000Z'], + 'products.created_on': ['2016-12-12T09:27:22.000Z', '2016-12-12T09:27:22.000Z'], + }, + }, + { + _index: 'kibana_sample_data_ecommerce', + _id: 'I3cqUnMBMY_orZma2mZy', + _type: 'document', + _score: 0, + _source: { + category: ["Men's Clothing"], + currency: 'EUR', + customer_first_name: 'Boris', + customer_full_name: 'Boris Bradley', + customer_gender: 'MALE', + customer_id: 36, + customer_last_name: 'Bradley', + customer_phone: '', + day_of_week: 'Wednesday', + day_of_week_i: 2, + email: 'boris@bradley-family.zzz', + manufacturer: ['Microlutions', 'Elitelligence'], + order_date: '2020-07-15T08:12:29+00:00', + order_id: 568397, + products: [ + { + base_price: 32.99, + discount_percentage: 0, + quantity: 1, + manufacturer: 'Microlutions', + tax_amount: 0, + product_id: 24419, + category: "Men's Clothing", + sku: 'ZO0112101121', + taxless_price: 32.99, + unit_discount_amount: 0, + min_price: 17.48, + _id: 'sold_product_568397_24419', + discount_amount: 0, + created_on: '2016-12-14T08:12:29+00:00', + product_name: 'Cargo trousers - oliv', + price: 32.99, + taxful_price: 32.99, + base_unit_price: 32.99, + }, + { + base_price: 28.99, + discount_percentage: 0, + quantity: 1, + manufacturer: 'Elitelligence', + tax_amount: 0, + product_id: 20207, + category: "Men's Clothing", + sku: 'ZO0530405304', + taxless_price: 28.99, + unit_discount_amount: 0, + min_price: 13.92, + _id: 'sold_product_568397_20207', + discount_amount: 0, + created_on: '2016-12-14T08:12:29+00:00', + product_name: 'Trousers - black', + price: 28.99, + taxful_price: 28.99, + base_unit_price: 28.99, + }, + ], + sku: ['ZO0112101121', 'ZO0530405304'], + taxful_total_price: 61.98, + taxless_total_price: 61.98, + total_quantity: 2, + total_unique_products: 2, + type: 'order', + user: 'boris', + geoip: { + country_iso_code: 'GB', + location: { + lon: -0.1, + lat: 51.5, + }, + continent_name: 'Europe', + }, + event: { + dataset: 'sample_ecommerce', + }, + }, + fields: { + order_date: ['2020-07-15T08:12:29.000Z'], + 'products.created_on': ['2016-12-14T08:12:29.000Z', '2016-12-14T08:12:29.000Z'], + }, + }, + { + _index: 'kibana_sample_data_ecommerce', + _id: 'JHcqUnMBMY_orZma2mZy', + _score: 0, + _type: 'document', + _source: { + category: ["Men's Clothing"], + currency: 'EUR', + customer_first_name: 'Oliver', + customer_full_name: 'Oliver Hubbard', + customer_gender: 'MALE', + customer_id: 7, + customer_last_name: 'Hubbard', + customer_phone: '', + day_of_week: 'Wednesday', + day_of_week_i: 2, + email: 'oliver@hubbard-family.zzz', + manufacturer: ['Spritechnologies', 'Microlutions'], + order_date: '2020-07-15T01:26:24+00:00', + order_id: 568044, + products: [ + { + base_price: 14.99, + discount_percentage: 0, + quantity: 1, + manufacturer: 'Spritechnologies', + tax_amount: 0, + product_id: 12799, + category: "Men's Clothing", + sku: 'ZO0630406304', + taxless_price: 14.99, + unit_discount_amount: 0, + min_price: 6.9, + _id: 'sold_product_568044_12799', + discount_amount: 0, + created_on: '2016-12-14T01:26:24+00:00', + product_name: 'Undershirt - dark grey multicolor', + price: 14.99, + taxful_price: 14.99, + base_unit_price: 14.99, + }, + { + base_price: 16.99, + discount_percentage: 0, + quantity: 1, + manufacturer: 'Microlutions', + tax_amount: 0, + product_id: 18008, + category: "Men's Clothing", + sku: 'ZO0120201202', + taxless_price: 16.99, + unit_discount_amount: 0, + min_price: 8.83, + _id: 'sold_product_568044_18008', + discount_amount: 0, + created_on: '2016-12-14T01:26:24+00:00', + product_name: 'Long sleeved top - purple', + price: 16.99, + taxful_price: 16.99, + base_unit_price: 16.99, + }, + ], + sku: ['ZO0630406304', 'ZO0120201202'], + taxful_total_price: 31.98, + taxless_total_price: 31.98, + total_quantity: 2, + total_unique_products: 2, + type: 'order', + user: 'oliver', + geoip: { + country_iso_code: 'GB', + location: { + lon: -0.1, + lat: 51.5, + }, + continent_name: 'Europe', + }, + event: { + dataset: 'sample_ecommerce', + }, + }, + fields: { + order_date: ['2020-07-15T01:26:24.000Z'], + 'products.created_on': ['2016-12-14T01:26:24.000Z', '2016-12-14T01:26:24.000Z'], + }, + }, + { + _index: 'kibana_sample_data_ecommerce', + _id: 'LHcqUnMBMY_orZma2mZy', + _score: 0, + _type: 'document', + _source: { + category: ["Women's Shoes", "Women's Clothing"], + currency: 'EUR', + customer_first_name: 'Wilhemina St.', + customer_full_name: 'Wilhemina St. Parker', + customer_gender: 'FEMALE', + customer_id: 17, + customer_last_name: 'Parker', + customer_phone: '', + day_of_week: 'Friday', + day_of_week_i: 4, + email: 'wilhemina st.@parker-family.zzz', + manufacturer: ['Low Tide Media', 'Tigress Enterprises'], + order_date: '2020-07-10T19:55:12+00:00', + order_id: 562351, + products: [ + { + base_price: 49.99, + discount_percentage: 0, + quantity: 1, + manufacturer: 'Low Tide Media', + tax_amount: 0, + product_id: 18495, + category: "Women's Shoes", + sku: 'ZO0376403764', + taxless_price: 49.99, + unit_discount_amount: 0, + min_price: 25, + _id: 'sold_product_562351_18495', + discount_amount: 0, + created_on: '2016-12-09T19:55:12+00:00', + product_name: 'Ankle boots - cognac', + price: 49.99, + taxful_price: 49.99, + base_unit_price: 49.99, + }, + { + base_price: 28.99, + discount_percentage: 0, + quantity: 1, + manufacturer: 'Tigress Enterprises', + tax_amount: 0, + product_id: 22598, + category: "Women's Clothing", + sku: 'ZO0050800508', + taxless_price: 28.99, + unit_discount_amount: 0, + min_price: 14.78, + _id: 'sold_product_562351_22598', + discount_amount: 0, + created_on: '2016-12-09T19:55:12+00:00', + product_name: 'Shift dress - black', + price: 28.99, + taxful_price: 28.99, + base_unit_price: 28.99, + }, + ], + sku: ['ZO0376403764', 'ZO0050800508'], + taxful_total_price: 78.98, + taxless_total_price: 78.98, + total_quantity: 2, + total_unique_products: 2, + type: 'order', + user: 'wilhemina', + geoip: { + country_iso_code: 'MC', + location: { + lon: 7.4, + lat: 43.7, + }, + continent_name: 'Europe', + city_name: 'Monte Carlo', + }, + event: { + dataset: 'sample_ecommerce', + }, + }, + fields: { + order_date: ['2020-07-10T19:55:12.000Z'], + 'products.created_on': ['2016-12-09T19:55:12.000Z', '2016-12-09T19:55:12.000Z'], + }, + }, + ], + }, + aggregations: { + '2': { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'FEMALE', + doc_count: 1033, + }, + { + key: 'MALE', + doc_count: 944, + }, + ], + }, + }, + }, + }; + const result = esRawResponse.to!.datatable(response, {}); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/data/public/search/expressions/es_raw_response.ts b/src/plugins/data/public/search/expressions/es_raw_response.ts new file mode 100644 index 0000000000000..bd0fcb3d49c54 --- /dev/null +++ b/src/plugins/data/public/search/expressions/es_raw_response.ts @@ -0,0 +1,102 @@ +/* + * 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 { SearchResponse } from 'elasticsearch'; +import { ExpressionTypeDefinition } from '../../../../expressions/common'; + +const name = 'es_raw_response'; + +export interface EsRawResponse { + type: typeof name; + body: SearchResponse; +} + +// flattens elasticsearch object into table rows +function flatten(obj: any, keyPrefix = '') { + let topLevelKeys: Record = {}; + const nestedRows: any[] = []; + const prefix = keyPrefix ? keyPrefix + '.' : ''; + Object.keys(obj).forEach((key) => { + if (Array.isArray(obj[key])) { + nestedRows.push( + ...obj[key] + .map((nestedRow: any) => flatten(nestedRow, prefix + key)) + .reduce((acc: any, object: any) => [...acc, ...object], []) + ); + } else if (typeof obj[key] === 'object' && obj[key] !== null) { + const subRows = flatten(obj[key], prefix + key); + if (subRows.length === 1) { + topLevelKeys = { ...topLevelKeys, ...subRows[0] }; + } else { + nestedRows.push(...subRows); + } + } else { + topLevelKeys[prefix + key] = obj[key]; + } + }); + if (nestedRows.length === 0) { + return [topLevelKeys]; + } else { + return nestedRows.map((nestedRow) => ({ ...nestedRow, ...topLevelKeys })); + } +} + +const parseRawDocs = (hits: SearchResponse['hits']) => { + return hits.hits.map((hit) => hit.fields || hit._source).filter((hit) => hit); +}; + +const convertResult = (body: SearchResponse) => { + return !body.aggregations ? parseRawDocs(body.hits) : flatten(body.aggregations); +}; + +export type EsRawResponseExpressionTypeDefinition = ExpressionTypeDefinition< + typeof name, + EsRawResponse, + EsRawResponse +>; + +export const esRawResponse: EsRawResponseExpressionTypeDefinition = { + name, + to: { + datatable: (context: EsRawResponse) => { + const rows = convertResult(context.body); + const columns = rows.length + ? Object.keys(rows[0]).map((key) => ({ + id: key, + name: key, + meta: { + type: typeof rows[0][key], + field: key, + params: {}, + }, + })) + : []; + + return { + type: 'datatable', + meta: { + type: 'esdsl', + source: '*', + }, + columns, + rows, + }; + }, + }, +}; diff --git a/src/plugins/data/public/search/expressions/esdsl.test.ts b/src/plugins/data/public/search/expressions/esdsl.test.ts new file mode 100644 index 0000000000000..9458962464f65 --- /dev/null +++ b/src/plugins/data/public/search/expressions/esdsl.test.ts @@ -0,0 +1,170 @@ +/* + * 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 { esdsl } from './esdsl'; + +jest.mock('@kbn/i18n', () => { + return { + i18n: { + translate: (id: string, { defaultMessage }: { defaultMessage: string }) => defaultMessage, + }, + }; +}); + +jest.mock('../../services', () => ({ + getUiSettings: () => ({ + get: () => true, + }), + getSearchService: () => ({ + search: jest.fn((params: any) => { + return { + toPromise: async () => { + return { rawResponse: params }; + }, + }; + }), + }), +})); + +describe('esdsl', () => { + describe('correctly handles input', () => { + test('throws on invalid json input', async () => { + const fn = async function () { + await esdsl().fn(null, { dsl: 'invalid json', index: 'test', size: 0 }, { + inspectorAdapters: {}, + } as any); + }; + + let errorMessage; + try { + await fn(); + } catch (error) { + errorMessage = error.message; + } + expect(errorMessage).toEqual('Unexpected token i in JSON at position 0'); + }); + + test('adds filters', async () => { + const result = await esdsl().fn( + { + type: 'kibana_context', + filters: [ + { + meta: { index: '1', alias: 'test', negate: false, disabled: false }, + query: { match_phrase: { gender: 'male' } }, + }, + ], + }, + { dsl: '{}', index: 'test', size: 0 }, + { inspectorAdapters: {} } as any + ); + + expect(result).toMatchSnapshot(); + }); + + test('adds filters to query with filters', async () => { + const result = await esdsl().fn( + { + type: 'kibana_context', + filters: [ + { + meta: { index: '1', alias: 'test', negate: false, disabled: false }, + query: { match_phrase: { gender: 'male' } }, + }, + ], + }, + { + index: 'kibana_sample_data_logs', + size: 4, + dsl: '{"_source": false, "query": { "term": { "machine.os.keyword": "osx"}}}', + }, + { inspectorAdapters: {} } as any + ); + + expect(result).toMatchSnapshot(); + }); + + test('adds query', async () => { + const result = await esdsl().fn( + { + type: 'kibana_context', + query: { language: 'lucene', query: '*' }, + }, + { dsl: '{}', index: 'test', size: 0 }, + { inspectorAdapters: {} } as any + ); + + expect(result).toMatchSnapshot(); + }); + + test('adds query to a query with filters', async () => { + const result = await esdsl().fn( + { + type: 'kibana_context', + query: { language: 'lucene', query: '*' }, + }, + { + index: 'kibana_sample_data_logs', + size: 4, + dsl: '{ "_source": false, "query": { "term": { "machine.os.keyword": "osx"}}}', + }, + { inspectorAdapters: {} } as any + ); + + expect(result).toMatchSnapshot(); + }); + + test('ignores timerange', async () => { + const result = await esdsl().fn( + { + type: 'kibana_context', + timeRange: { from: 'now-15m', to: 'now' }, + }, + { dsl: '{}', index: 'test', size: 0 }, + { inspectorAdapters: {} } as any + ); + + expect(result).toMatchSnapshot(); + }); + }); + + test('correctly handles filter, query and timerange on context', async () => { + const result = await esdsl().fn( + { + type: 'kibana_context', + query: { language: 'lucene', query: '*' }, + timeRange: { from: 'now-15m', to: 'now' }, + filters: [ + { + meta: { index: '1', alias: 'test', negate: false, disabled: false }, + query: { match_phrase: { gender: 'male' } }, + }, + ], + }, + { + index: 'kibana_sample_data_logs', + size: 4, + dsl: '{ "_source": false, "query": { "term": { "machine.os.keyword": "osx"}}}', + }, + { inspectorAdapters: {} } as any + ); + + expect(result).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/data/public/search/expressions/esdsl.ts b/src/plugins/data/public/search/expressions/esdsl.ts new file mode 100644 index 0000000000000..d7b897ace29b4 --- /dev/null +++ b/src/plugins/data/public/search/expressions/esdsl.ts @@ -0,0 +1,201 @@ +/* + * 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 { + KibanaContext, + ExpressionFunctionDefinition, +} from '../../../../../plugins/expressions/public'; + +import { getSearchService, getUiSettings } from '../../services'; +import { EsRawResponse } from './es_raw_response'; +import { RequestStatistics, RequestAdapter } from '../../../../inspector/common'; +import { IEsSearchResponse } from '../../../common/search/es_search'; +import { buildEsQuery, getEsQueryConfig } from '../../../common/es_query/es_query'; +import { DataPublicPluginStart } from '../../types'; + +const name = 'esdsl'; + +type Input = KibanaContext | null; +type Output = Promise; + +interface Arguments { + dsl: string; + index: string; + size: number; +} + +export type EsdslExpressionFunctionDefinition = ExpressionFunctionDefinition< + typeof name, + Input, + Arguments, + Output +>; + +export const esdsl = (): EsdslExpressionFunctionDefinition => ({ + name, + type: 'es_raw_response', + inputTypes: ['kibana_context', 'null'], + help: i18n.translate('data.search.esdsl.help', { + defaultMessage: 'Run Elasticsearch request', + }), + args: { + dsl: { + types: ['string'], + aliases: ['_', 'q', 'query'], + help: i18n.translate('data.search.esdsl.q.help', { + defaultMessage: 'Query DSL', + }), + required: true, + }, + index: { + types: ['string'], + help: i18n.translate('data.search.esdsl.index.help', { + defaultMessage: 'ElasticSearch index to query', + }), + required: true, + }, + size: { + types: ['number'], + help: i18n.translate('data.search.esdsl.size.help', { + defaultMessage: 'ElasticSearch searchAPI size parameter', + }), + default: 10, + }, + }, + async fn(input, args, { inspectorAdapters, abortSignal }) { + const searchService: DataPublicPluginStart['search'] = getSearchService(); + + const dsl = JSON.parse(args.dsl); + + if (input) { + const esQueryConfigs = getEsQueryConfig(getUiSettings()); + const query = buildEsQuery( + undefined, // args.index, + input.query || [], + input.filters || [], + esQueryConfigs + ); + + if (!dsl.query) { + dsl.query = query; + } else { + query.bool.must.push(dsl.query); + dsl.query = query; + } + } + + if (!inspectorAdapters.requests) { + inspectorAdapters.requests = new RequestAdapter(); + } + + const request = inspectorAdapters.requests.start( + i18n.translate('data.search.dataRequest.title', { + defaultMessage: 'Data', + }), + { + description: i18n.translate('data.search.es_search.dataRequest.description', { + defaultMessage: + 'This request queries Elasticsearch to fetch the data for the visualization.', + }), + } + ); + + request.stats({ + indexPattern: { + label: i18n.translate('data.search.es_search.indexPatternLabel', { + defaultMessage: 'Index pattern', + }), + value: args.index, + description: i18n.translate('data.search.es_search.indexPatternDescription', { + defaultMessage: 'The index pattern that connected to the Elasticsearch indices.', + }), + }, + }); + + let res: IEsSearchResponse; + try { + res = await searchService + .search( + { + params: { + index: args.index, + size: args.size, + body: dsl, + }, + }, + { signal: abortSignal } + ) + .toPromise(); + + const stats: RequestStatistics = {}; + const resp = res.rawResponse; + + if (resp && resp.took) { + stats.queryTime = { + label: i18n.translate('data.search.es_search.queryTimeLabel', { + defaultMessage: 'Query time', + }), + value: i18n.translate('data.search.es_search.queryTimeValue', { + defaultMessage: '{queryTime}ms', + values: { queryTime: resp.took }, + }), + description: i18n.translate('data.search.es_search.queryTimeDescription', { + defaultMessage: + 'The time it took to process the query. ' + + 'Does not include the time to send the request or parse it in the browser.', + }), + }; + } + + if (resp && resp.hits) { + stats.hitsTotal = { + label: i18n.translate('data.search.es_search.hitsTotalLabel', { + defaultMessage: 'Hits (total)', + }), + value: `${resp.hits.total}`, + description: i18n.translate('data.search.es_search.hitsTotalDescription', { + defaultMessage: 'The number of documents that match the query.', + }), + }; + + stats.hits = { + label: i18n.translate('data.search.es_search.hitsLabel', { + defaultMessage: 'Hits', + }), + value: `${resp.hits.hits.length}`, + description: i18n.translate('data.search.es_search.hitsDescription', { + defaultMessage: 'The number of documents returned by the query.', + }), + }; + } + + request.stats(stats).ok({ json: resp }); + request.json(dsl); + + return { + type: 'es_raw_response', + body: resp, + }; + } catch (e) { + request.error({ json: e }); + throw e; + } + }, +}); diff --git a/src/plugins/data/public/search/expressions/index.ts b/src/plugins/data/public/search/expressions/index.ts index 25839a805d8c5..02df7986479ad 100644 --- a/src/plugins/data/public/search/expressions/index.ts +++ b/src/plugins/data/public/search/expressions/index.ts @@ -18,4 +18,6 @@ */ export * from './esaggs'; +export * from './es_raw_response'; +export * from './esdsl'; export * from './utils'; diff --git a/src/plugins/data/public/search/search_service.test.ts b/src/plugins/data/public/search/search_service.test.ts index e6897a16a353a..4360a0caa7cde 100644 --- a/src/plugins/data/public/search/search_service.test.ts +++ b/src/plugins/data/public/search/search_service.test.ts @@ -37,7 +37,7 @@ describe('Search service', () => { it('exposes proper contract', async () => { const setup = searchService.setup(mockCoreSetup, ({ packageInfo: { version: '8' }, - registerFunction: jest.fn(), + expressions: { registerFunction: jest.fn(), registerType: jest.fn() }, } as unknown) as SearchServiceSetupDependencies); expect(setup).toHaveProperty('aggs'); expect(setup).toHaveProperty('usageCollector'); diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts index bd9c1b1253fe2..04e1a46c84652 100644 --- a/src/plugins/data/public/search/search_service.ts +++ b/src/plugins/data/public/search/search_service.ts @@ -22,18 +22,20 @@ import { ISearchSetup, ISearchStart, SearchEnhancements } from './types'; import { createSearchSource, SearchSource, SearchSourceDependencies } from './search_source'; import { getEsClient, LegacyApiCaller } from './legacy'; -import { AggsService, AggsSetupDependencies, AggsStartDependencies } from './aggs'; +import { AggsService, AggsStartDependencies } from './aggs'; import { IndexPatternsContract } from '../index_patterns/index_patterns'; import { ISearchInterceptor, SearchInterceptor } from './search_interceptor'; import { ISearchGeneric } from './types'; import { SearchUsageCollector, createUsageCollector } from './collectors'; import { UsageCollectionSetup } from '../../../usage_collection/public'; +import { esdsl, esRawResponse } from './expressions'; +import { ExpressionsSetup } from '../../../expressions/public'; /** @internal */ export interface SearchServiceSetupDependencies { packageInfo: PackageInfo; - registerFunction: AggsSetupDependencies['registerFunction']; usageCollection?: UsageCollectionSetup; + expressions: ExpressionsSetup; } /** @internal */ @@ -50,7 +52,7 @@ export class SearchService implements Plugin { public setup( core: CoreSetup, - { packageInfo, registerFunction, usageCollection }: SearchServiceSetupDependencies + { packageInfo, usageCollection, expressions }: SearchServiceSetupDependencies ): ISearchSetup { this.usageCollector = createUsageCollector(core, usageCollection); this.esClient = getEsClient(core.injectedMetadata, core.http, packageInfo); @@ -71,9 +73,12 @@ export class SearchService implements Plugin { core.injectedMetadata.getInjectedVar('esRequestTimeout') as number ); + expressions.registerFunction(esdsl); + expressions.registerType(esRawResponse); + return { aggs: this.aggsService.setup({ - registerFunction, + registerFunction: expressions.registerFunction, uiSettings: core.uiSettings, }), usageCollector: this.usageCollector!, diff --git a/src/plugins/data/public/ui/query_string_input/_query_bar.scss b/src/plugins/data/public/ui/query_string_input/_query_bar.scss index 007be9da63e49..00895ec49003b 100644 --- a/src/plugins/data/public/ui/query_string_input/_query_bar.scss +++ b/src/plugins/data/public/ui/query_string_input/_query_bar.scss @@ -59,6 +59,7 @@ &.kbnQueryBar__datePickerWrapper-isHidden { width: 0; overflow: hidden; + max-width: 0; } } } diff --git a/src/plugins/discover/public/application/angular/doc.html b/src/plugins/discover/public/application/angular/doc.html index fc1bff7eef6ec..dcd5760eff155 100644 --- a/src/plugins/discover/public/application/angular/doc.html +++ b/src/plugins/discover/public/application/angular/doc.html @@ -1,6 +1,5 @@
{ .when('/doc/:indexPattern/:index', { // have to be written as function expression, because it's not compiled in dev mode // eslint-disable-next-line object-shorthand - controller: function ($scope: LazyScope, $route: any, es: any) { + controller: function ($scope: LazyScope, $route: any) { timefilter.disableAutoRefreshSelector(); timefilter.disableTimeRangeSelector(); - $scope.esClient = es; $scope.id = $route.current.params.id; $scope.index = $route.current.params.index; $scope.indexPatternId = $route.current.params.indexPattern; diff --git a/src/plugins/discover/public/application/components/doc/doc.test.tsx b/src/plugins/discover/public/application/components/doc/doc.test.tsx index c9fa551f61aca..d562291db46ac 100644 --- a/src/plugins/discover/public/application/components/doc/doc.test.tsx +++ b/src/plugins/discover/public/application/components/doc/doc.test.tsx @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { throwError, of } from 'rxjs'; import React from 'react'; import { act } from 'react-dom/test-utils'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; @@ -23,6 +24,8 @@ import { ReactWrapper } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; import { Doc, DocProps } from './doc'; +const mockSearchApi = jest.fn(); + jest.mock('../../../kibana_services', () => { let registry: any[] = []; @@ -31,6 +34,11 @@ jest.mock('../../../kibana_services', () => { metadata: { branch: 'test', }, + data: { + search: { + search: mockSearchApi, + }, + }, }), getDocViewsRegistry: () => ({ addDocView(view: any) { @@ -59,7 +67,7 @@ const waitForPromises = async () => * this works but logs ugly error messages until we're using React 16.9 * should be adapted when we upgrade */ -async function mountDoc(search: () => void, update = false, indexPatternGetter: any = null) { +async function mountDoc(update = false, indexPatternGetter: any = null) { const indexPattern = { getComputedFields: () => [], }; @@ -70,7 +78,6 @@ async function mountDoc(search: () => void, update = false, indexPatternGetter: const props = { id: '1', index: 'index1', - esClient: { search } as any, indexPatternId: 'xyz', indexPatternService, } as DocProps; @@ -88,32 +95,33 @@ async function mountDoc(search: () => void, update = false, indexPatternGetter: describe('Test of of Discover', () => { test('renders loading msg', async () => { - const comp = await mountDoc(jest.fn()); + const comp = await mountDoc(); expect(findTestSubject(comp, 'doc-msg-loading').length).toBe(1); }); test('renders IndexPattern notFound msg', async () => { const indexPatternGetter = jest.fn(() => Promise.reject({ savedObjectId: '007' })); - const comp = await mountDoc(jest.fn(), true, indexPatternGetter); + const comp = await mountDoc(true, indexPatternGetter); expect(findTestSubject(comp, 'doc-msg-notFoundIndexPattern').length).toBe(1); }); test('renders notFound msg', async () => { - const search = jest.fn(() => Promise.reject({ status: 404 })); - const comp = await mountDoc(search, true); + mockSearchApi.mockImplementation(() => throwError({ status: 404 })); + const comp = await mountDoc(true); expect(findTestSubject(comp, 'doc-msg-notFound').length).toBe(1); }); test('renders error msg', async () => { - const search = jest.fn(() => Promise.reject('whatever')); - const comp = await mountDoc(search, true); + mockSearchApi.mockImplementation(() => throwError({ error: 'something else' })); + const comp = await mountDoc(true); expect(findTestSubject(comp, 'doc-msg-error').length).toBe(1); }); test('renders elasticsearch hit ', async () => { - const hit = { hits: { total: 1, hits: [{ _id: 1, _source: { test: 1 } }] } }; - const search = jest.fn(() => Promise.resolve(hit)); - const comp = await mountDoc(search, true); + mockSearchApi.mockImplementation(() => + of({ rawResponse: { hits: { total: 1, hits: [{ _id: 1, _source: { test: 1 } }] } } }) + ); + const comp = await mountDoc(true); expect(findTestSubject(comp, 'doc-hit').length).toBe(1); }); }); diff --git a/src/plugins/discover/public/application/components/doc/doc.tsx b/src/plugins/discover/public/application/components/doc/doc.tsx index 0e31ded267b75..2623b5a270a31 100644 --- a/src/plugins/discover/public/application/components/doc/doc.tsx +++ b/src/plugins/discover/public/application/components/doc/doc.tsx @@ -23,17 +23,6 @@ import { IndexPatternsContract } from 'src/plugins/data/public'; import { ElasticRequestState, useEsDocSearch } from './use_es_doc_search'; import { getServices } from '../../../kibana_services'; import { DocViewer } from '../doc_viewer/doc_viewer'; -import { ElasticSearchHit } from '../../doc_views/doc_views_types'; - -export interface ElasticSearchResult { - hits: { - hits: [ElasticSearchHit]; - max_score: number; - }; - timed_out: boolean; - took: number; - shards: Record; -} export interface DocProps { /** @@ -53,12 +42,6 @@ export interface DocProps { * IndexPatternService to get a given index pattern by ID */ indexPatternService: IndexPatternsContract; - /** - * Client of ElasticSearch to use for the query - */ - esClient: { - search: (payload: { index: string; body: Record }) => Promise; - }; } export function Doc(props: DocProps) { diff --git a/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx b/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx index a8fe8de833315..e0d505f9aa6fa 100644 --- a/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx +++ b/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx @@ -19,6 +19,21 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { buildSearchBody, useEsDocSearch, ElasticRequestState } from './use_es_doc_search'; import { DocProps } from './doc'; +import { Observable } from 'rxjs'; + +const mockSearchResult = new Observable(); + +jest.mock('../../../kibana_services', () => ({ + getServices: () => ({ + data: { + search: { + search: jest.fn(() => { + return mockSearchResult; + }), + }, + }, + }), +})); describe('Test of helper / hook', () => { test('buildSearchBody', () => { @@ -53,7 +68,6 @@ describe('Test of helper / hook', () => { const props = { id: '1', index: 'index1', - esClient: { search: jest.fn(() => new Promise(() => {})) }, indexPatternId: 'xyz', indexPatternService, } as DocProps; diff --git a/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts b/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts index 00496a3a72681..522ebad1691a9 100644 --- a/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts +++ b/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts @@ -17,7 +17,7 @@ * under the License. */ import { useEffect, useState } from 'react'; -import { IndexPattern } from '../../../kibana_services'; +import { IndexPattern, getServices } from '../../../kibana_services'; import { DocProps } from './doc'; import { ElasticSearchHit } from '../../doc_views/doc_views_types'; @@ -53,7 +53,6 @@ export function buildSearchBody(id: string, indexPattern: IndexPattern): Record< * Custom react hook for querying a single doc in ElasticSearch */ export function useEsDocSearch({ - esClient, id, index, indexPatternId, @@ -69,12 +68,18 @@ export function useEsDocSearch({ const indexPatternEntity = await indexPatternService.get(indexPatternId); setIndexPattern(indexPatternEntity); - const { hits } = await esClient.search({ - index, - body: buildSearchBody(id, indexPatternEntity), - }); + const { rawResponse } = await getServices() + .data.search.search({ + params: { + index, + body: buildSearchBody(id, indexPatternEntity), + }, + }) + .toPromise(); - if (hits && hits.hits && hits.hits[0]) { + const hits = rawResponse.hits; + + if (hits?.hits?.[0]) { setStatus(ElasticRequestState.Found); setHit(hits.hits[0]); } else { @@ -91,6 +96,6 @@ export function useEsDocSearch({ } } requestData(); - }, [esClient, id, index, indexPatternId, indexPatternService]); + }, [id, index, indexPatternId, indexPatternService]); return [status, hit, indexPattern]; } diff --git a/src/plugins/discover/public/application/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap b/src/plugins/discover/public/application/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap index ee88ce6088d7e..d6f48a9b3c774 100644 --- a/src/plugins/discover/public/application/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap +++ b/src/plugins/discover/public/application/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap @@ -9,6 +9,9 @@ exports[`returns the \`JsonCodeEditor\` component 1`] = ` > { "_index": "test", + "_type": "doc", + "_id": "foo", + "_score": 1, "_source": { "test": 123 } diff --git a/src/plugins/discover/public/application/components/json_code_block/json_code_block.test.tsx b/src/plugins/discover/public/application/components/json_code_block/json_code_block.test.tsx index 3cbcab5036251..a737b3954ceea 100644 --- a/src/plugins/discover/public/application/components/json_code_block/json_code_block.test.tsx +++ b/src/plugins/discover/public/application/components/json_code_block/json_code_block.test.tsx @@ -23,7 +23,7 @@ import { IndexPattern } from '../../../../../data/public'; it('returns the `JsonCodeEditor` component', () => { const props = { - hit: { _index: 'test', _source: { test: 123 } }, + hit: { _index: 'test', _type: 'doc', _id: 'foo', _score: 1, _source: { test: 123 } }, columns: [], indexPattern: {} as IndexPattern, filter: jest.fn(), diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.scss b/src/plugins/discover/public/application/components/sidebar/discover_field.scss new file mode 100644 index 0000000000000..8e1dd41f66ab1 --- /dev/null +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.scss @@ -0,0 +1,4 @@ +.dscSidebarItem__fieldPopoverPanel { + min-width: 260px; + max-width: 300px; +} diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx index e1abbfd7657d0..a0d9e3c541e47 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx @@ -104,9 +104,4 @@ describe('discover sidebar field', function () { findTestSubject(comp, 'fieldToggle-bytes').simulate('click'); expect(props.onRemoveField).toHaveBeenCalledWith('bytes'); }); - it('should trigger onShowDetails', function () { - const { comp, props } = getComponent(); - findTestSubject(comp, 'field-bytes-showDetails').simulate('click'); - expect(props.onShowDetails).toHaveBeenCalledWith(true, props.field); - }); }); diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx index 724908281146d..639dbfe09277c 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx @@ -16,15 +16,16 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; -import { EuiButton } from '@elastic/eui'; +import React, { useState } from 'react'; +import { EuiPopover, EuiPopoverTitle, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DiscoverFieldDetails } from './discover_field_details'; -import { FieldIcon } from '../../../../../kibana_react/public'; +import { FieldIcon, FieldButton } from '../../../../../kibana_react/public'; import { FieldDetails } from './types'; import { IndexPatternField, IndexPattern } from '../../../../../data/public'; import { shortenDottedString } from '../../helpers'; import { getFieldTypeName } from './lib/get_field_type_name'; +import './discover_field.scss'; export interface DiscoverFieldProps { /** @@ -48,14 +49,6 @@ export interface DiscoverFieldProps { * @param fieldName */ onRemoveField: (fieldName: string) => void; - /** - * Callback to hide/show details, buckets of the field - */ - onShowDetails: (show: boolean, field: IndexPatternField) => void; - /** - * Determines, whether details of the field are displayed - */ - showDetails: boolean; /** * Retrieve details data for the field */ @@ -76,22 +69,14 @@ export function DiscoverField({ onAddField, onRemoveField, onAddFilter, - onShowDetails, - showDetails, getDetails, selected, useShortDots, }: DiscoverFieldProps) { - const addLabel = i18n.translate('discover.fieldChooser.discoverField.addButtonLabel', { - defaultMessage: 'Add', - }); const addLabelAria = i18n.translate('discover.fieldChooser.discoverField.addButtonAriaLabel', { defaultMessage: 'Add {field} to table', values: { field: field.name }, }); - const removeLabel = i18n.translate('discover.fieldChooser.discoverField.removeButtonLabel', { - defaultMessage: 'Remove', - }); const removeLabelAria = i18n.translate( 'discover.fieldChooser.discoverField.removeButtonAriaLabel', { @@ -100,6 +85,8 @@ export function DiscoverField({ } ); + const [infoIsOpen, setOpen] = useState(false); + const toggleDisplay = (f: IndexPatternField) => { if (selected) { onRemoveField(f.name); @@ -108,6 +95,10 @@ export function DiscoverField({ } }; + function togglePopover() { + setOpen(!infoIsOpen); + } + function wrapOnDot(str?: string) { // u200B is a non-width white-space character, which allows // the browser to efficiently word-wrap right after the dot @@ -115,64 +106,96 @@ export function DiscoverField({ return str ? str.replace(/\./g, '.\u200B') : ''; } - return ( - <> -
onShowDetails(!showDetails, field)} - onKeyPress={() => onShowDetails(!showDetails, field)} - data-test-subj={`field-${field.name}-showDetails`} + const dscFieldIcon = ( + + ); + + const fieldName = ( + + {useShortDots ? wrapOnDot(shortenDottedString(field.name)) : wrapOnDot(field.displayName)} + + ); + + let actionButton; + if (field.name !== '_source' && !selected) { + actionButton = ( + + ) => { + ev.preventDefault(); + ev.stopPropagation(); + toggleDisplay(field); + }} + data-test-subj={`fieldToggle-${field.name}`} + aria-label={addLabelAria} + /> + + ); + } else if (field.name !== '_source' && selected) { + actionButton = ( + - - - - - {useShortDots ? wrapOnDot(shortenDottedString(field.name)) : wrapOnDot(field.displayName)} - - - {field.name !== '_source' && !selected && ( - ) => { - ev.preventDefault(); - ev.stopPropagation(); - toggleDisplay(field); - }} - data-test-subj={`fieldToggle-${field.name}`} - arial-label={addLabelAria} - > - {addLabel} - - )} - {field.name !== '_source' && selected && ( - ) => { - ev.preventDefault(); - ev.stopPropagation(); - toggleDisplay(field); - }} - data-test-subj={`fieldToggle-${field.name}`} - arial-label={removeLabelAria} - > - {removeLabel} - - )} - -
- {showDetails && ( + ) => { + ev.preventDefault(); + ev.stopPropagation(); + toggleDisplay(field); + }} + data-test-subj={`fieldToggle-${field.name}`} + aria-label={removeLabelAria} + /> + + ); + } + + return ( + { + togglePopover(); + }} + buttonProps={{ 'data-test-subj': `field-${field.name}-showDetails` }} + fieldIcon={dscFieldIcon} + fieldAction={actionButton} + fieldName={fieldName} + /> + } + isOpen={infoIsOpen} + closePopover={() => setOpen(false)} + anchorPosition="rightUp" + panelClassName="dscSidebarItem__fieldPopoverPanel" + > + + {' '} + {i18n.translate('discover.fieldChooser.discoverField.fieldTopValuesLabel', { + defaultMessage: 'Top 5 values', + })} + + {infoIsOpen && ( )} - + ); } diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field_details.scss b/src/plugins/discover/public/application/components/sidebar/discover_field_details.scss new file mode 100644 index 0000000000000..f4b3eed741f9f --- /dev/null +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_details.scss @@ -0,0 +1,5 @@ +.dscFieldDetails__visualizeBtn { + @include euiFontSizeXS; + height: $euiSizeL !important; + min-width: $euiSize * 4; +} diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field_details.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_details.tsx index dd95a45f71626..875b5a0aa446f 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field_details.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_details.tsx @@ -17,13 +17,14 @@ * under the License. */ import React from 'react'; -import { EuiLink, EuiIconTip, EuiText } from '@elastic/eui'; +import { EuiLink, EuiIconTip, EuiText, EuiPopoverFooter, EuiButton, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { DiscoverFieldBucket } from './discover_field_bucket'; import { getWarnings } from './lib/get_warnings'; import { Bucket, FieldDetails } from './types'; import { getServices } from '../../../kibana_services'; import { IndexPatternField, IndexPattern } from '../../../../../data/public'; +import './discover_field_details.scss'; interface DiscoverFieldDetailsProps { field: IndexPatternField; @@ -41,62 +42,68 @@ export function DiscoverFieldDetails({ const warnings = getWarnings(field); return ( -
- {!details.error && ( - - {' '} - {!indexPattern.metaFields.includes(field.name) && !field.scripted ? ( - onAddFilter('_exists_', field.name, '+')}> - {details.exists} - - ) : ( - {details.exists} - )}{' '} - / {details.total}{' '} - - - )} - {details.error && {details.error}} - {!details.error && ( -
- {details.buckets.map((bucket: Bucket, idx: number) => ( - - ))} -
- )} + <> +
+ {details.error && {details.error}} + {!details.error && ( +
+ {details.buckets.map((bucket: Bucket, idx: number) => ( + + ))} +
+ )} - {details.visualizeUrl && ( - <> - { - getServices().core.application.navigateToApp(details.visualizeUrl.app, { - path: details.visualizeUrl.path, - }); - }} - className="kuiButton kuiButton--secondary kuiButton--small kuiVerticalRhythmSmall" - data-test-subj={`fieldVisualize-${field.name}`} - > - + {details.visualizeUrl && ( + <> + + { + getServices().core.application.navigateToApp(details.visualizeUrl.app, { + path: details.visualizeUrl.path, + }); + }} + size="s" + className="dscFieldDetails__visualizeBtn" + data-test-subj={`fieldVisualize-${field.name}`} + > + + {warnings.length > 0 && ( )} - - + + )} +
+ {!details.error && ( + + + {!indexPattern.metaFields.includes(field.name) && !field.scripted ? ( + onAddFilter('_exists_', field.name, '+')}> + {' '} + {details.exists} + + ) : ( + {details.exists} + )}{' '} + / {details.total}{' '} + + + )} -
+ ); } diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss index 07efd64752c84..f130b0399f467 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss @@ -42,54 +42,15 @@ } .dscSidebarItem { - border-top: 1px solid transparent; - position: relative; - display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - font-size: $euiFontSizeXS; - border-top: solid 1px transparent; - border-bottom: solid 1px transparent; - line-height: normal; - margin-bottom: $euiSizeXS * 0.5; - &:hover, - &:focus { + &:focus-within, + &[class*='-isActive'] { .dscSidebarItem__action { opacity: 1; } } } -.dscSidebarItem--active { - border-top: 1px solid $euiColorLightShade; - color: $euiColorFullShade; -} - -.dscSidebarField { - padding: $euiSizeXS; - display: flex; - align-items: center; - max-width: 100%; - width: 100%; - border: none; - border-radius: $euiBorderRadius - 1px; - text-align: left; -} - -.dscSidebarField__name { - margin-left: $euiSizeS; - flex-grow: 1; - word-break: break-word; - padding-right: 1px; -} - -.dscSidebarField__fieldIcon { - margin-top: $euiSizeXS / 2; - margin-right: $euiSizeXS / 2; -} - /** * 1. Only visually hide the action, so that it's still accessible to screen readers. * 2. When tabbed to, this element needs to be visible for keyboard accessibility. @@ -101,7 +62,7 @@ &:focus { opacity: 1; /* 2 */ } - font-size: 12px; + font-size: $euiFontSizeXS; padding: 2px 6px !important; height: 22px !important; min-width: auto !important; @@ -130,8 +91,6 @@ } .dscFieldDetails { - padding: $euiSizeS; - background-color: $euiColorLightestShade; color: $euiTextColor; margin-bottom: $euiSizeS; } diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx index 58b468762c501..450bb93f60bf3 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx @@ -92,7 +92,6 @@ export function DiscoverSidebar({ setIndexPattern, state, }: DiscoverSidebarProps) { - const [openFieldMap, setOpenFieldMap] = useState(new Map()); const [showFields, setShowFields] = useState(false); const [fields, setFields] = useState(null); const [fieldFilterState, setFieldFilterState] = useState(getDefaultFieldFilter()); @@ -103,19 +102,6 @@ export function DiscoverSidebar({ setFields(newFields); }, [selectedIndexPattern, fieldCounts, hits, services]); - const onShowDetails = useCallback( - (show: boolean, field: IndexPatternField) => { - if (!show) { - setOpenFieldMap(new Map(openFieldMap.set(field.name, false))); - } else { - setOpenFieldMap(new Map(openFieldMap.set(field.name, true))); - if (services.capabilities.discover.save) { - selectedIndexPattern.popularizeField(field.name, 1); - } - } - }, - [openFieldMap, selectedIndexPattern, services.capabilities.discover.save] - ); const onChangeFieldSearch = useCallback( (field: string, value: string | boolean | undefined) => { const newState = setFieldFilterProp(fieldFilterState, field, value); @@ -213,9 +199,7 @@ export function DiscoverSidebar({ onAddField={onAddField} onRemoveField={onRemoveField} onAddFilter={onAddFilter} - onShowDetails={onShowDetails} getDetails={getDetailsByField} - showDetails={openFieldMap.get(field.name) || false} selected={true} useShortDots={useShortDots} /> @@ -290,9 +274,7 @@ export function DiscoverSidebar({ onAddField={onAddField} onRemoveField={onRemoveField} onAddFilter={onAddFilter} - onShowDetails={onShowDetails} getDetails={getDetailsByField} - showDetails={openFieldMap.get(field.name) || false} useShortDots={useShortDots} /> @@ -318,9 +300,7 @@ export function DiscoverSidebar({ onAddField={onAddField} onRemoveField={onRemoveField} onAddFilter={onAddFilter} - onShowDetails={onShowDetails} getDetails={getDetailsByField} - showDetails={openFieldMap.get(field.name) || false} useShortDots={useShortDots} /> diff --git a/src/plugins/discover/public/application/components/table/table.test.tsx b/src/plugins/discover/public/application/components/table/table.test.tsx index 5b840a25d8beb..07e9e0a129a26 100644 --- a/src/plugins/discover/public/application/components/table/table.test.tsx +++ b/src/plugins/discover/public/application/components/table/table.test.tsx @@ -74,6 +74,8 @@ describe('DocViewTable at Discover', () => { const hit = { _index: 'logstash-2014.09.09', + _type: 'doc', + _id: 'id123', _score: 1, _source: { message: @@ -191,6 +193,8 @@ describe('DocViewTable at Discover Doc', () => { const hit = { _index: 'logstash-2014.09.09', _score: 1, + _type: 'doc', + _id: 'id123', _source: { extension: 'html', not_mapped: 'yes', @@ -213,6 +217,9 @@ describe('DocViewTable at Discover Context', () => { // here no toggleColumnButtons are rendered const hit = { _index: 'logstash-2014.09.09', + _type: 'doc', + _id: 'id123', + _score: 1, _source: { message: 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. \ diff --git a/src/plugins/discover/public/application/doc_views/doc_views_types.ts b/src/plugins/discover/public/application/doc_views/doc_views_types.ts index 0c86c4f812749..6c90861e26727 100644 --- a/src/plugins/discover/public/application/doc_views/doc_views_types.ts +++ b/src/plugins/discover/public/application/doc_views/doc_views_types.ts @@ -18,6 +18,7 @@ */ import { ComponentType } from 'react'; import { IScope } from 'angular'; +import { SearchResponse } from 'elasticsearch'; import { IndexPattern } from '../../../../data/public'; export interface AngularDirective { @@ -27,7 +28,7 @@ export interface AngularDirective { export type AngularScope = IScope; -export type ElasticSearchHit = Record>; +export type ElasticSearchHit = SearchResponse['hits']['hits'][number]; export interface FieldMapping { filterable?: boolean; diff --git a/src/plugins/discover/public/get_inner_angular.ts b/src/plugins/discover/public/get_inner_angular.ts index 0b3c2fad8d45b..85b0752f13463 100644 --- a/src/plugins/discover/public/get_inner_angular.ts +++ b/src/plugins/discover/public/get_inner_angular.ts @@ -110,7 +110,6 @@ export function initializeInnerAngularModule( createLocalPromiseModule(); createLocalTopNavModule(navigation); createLocalStorageModule(); - createElasticSearchModule(data); createPagerFactoryModule(); createDocTableModule(); initialized = true; @@ -145,7 +144,6 @@ export function initializeInnerAngularModule( 'discoverPromise', 'discoverTopNav', 'discoverLocalStorageProvider', - 'discoverEs', 'discoverDocTable', 'discoverPagerFactory', ]) @@ -201,16 +199,6 @@ const createLocalStorageService = function (type: string) { }; }; -function createElasticSearchModule(data: DataPublicPluginStart) { - angular - .module('discoverEs', []) - // Elasticsearch client used for requesting data. Connects to the /elasticsearch proxy - // have to be written as function expression, because it's not compiled in dev mode - .service('es', function () { - return data.search.__LEGACY.esClient; - }); -} - function createPagerFactoryModule() { angular.module('discoverPagerFactory', []).factory('pagerFactory', createPagerFactory); } diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx b/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx index 594a7ad73c396..8c3d7ab9c30d0 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx @@ -56,12 +56,18 @@ test('is compatible when edit url is available, in edit mode and editable', asyn test('redirects to app using state transfer', async () => { applicationMock.currentAppId$ = of('superCoolCurrentApp'); const action = new EditPanelAction(getFactory, applicationMock, stateTransferMock); - const embeddable = new EditableEmbeddable({ id: '123', viewMode: ViewMode.EDIT }, true); + const input = { id: '123', viewMode: ViewMode.EDIT }; + const embeddable = new EditableEmbeddable(input, true); embeddable.getOutput = jest.fn(() => ({ editApp: 'ultraVisualize', editPath: '/123' })); await action.execute({ embeddable }); expect(stateTransferMock.navigateToEditor).toHaveBeenCalledWith('ultraVisualize', { path: '/123', - state: { originatingApp: 'superCoolCurrentApp' }, + state: { + originatingApp: 'superCoolCurrentApp', + byValueMode: true, + embeddableId: '123', + valueInput: input, + }, }); }); diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index 9177a77d547b0..8d12ddd0299e7 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -24,7 +24,12 @@ import { take } from 'rxjs/operators'; import { ViewMode } from '../types'; import { EmbeddableFactoryNotFoundError } from '../errors'; import { EmbeddableStart } from '../../plugin'; -import { IEmbeddable, EmbeddableEditorState, EmbeddableStateTransfer } from '../..'; +import { + IEmbeddable, + EmbeddableEditorState, + EmbeddableStateTransfer, + SavedObjectEmbeddableInput, +} from '../..'; export const ACTION_EDIT_PANEL = 'editPanel'; @@ -109,8 +114,17 @@ export class EditPanelAction implements Action { const app = embeddable ? embeddable.getOutput().editApp : undefined; const path = embeddable ? embeddable.getOutput().editPath : undefined; if (app && path) { - const state = this.currentAppId ? { originatingApp: this.currentAppId } : undefined; - return { app, path, state }; + if (this.currentAppId) { + const byValueMode = !(embeddable.getInput() as SavedObjectEmbeddableInput).savedObjectId; + const state: EmbeddableEditorState = { + originatingApp: this.currentAppId, + byValueMode, + valueInput: byValueMode ? embeddable.getInput() : undefined, + embeddableId: embeddable.id, + }; + return { app, path, state }; + } + return { app, path }; } } diff --git a/src/plugins/embeddable/public/lib/state_transfer/types.ts b/src/plugins/embeddable/public/lib/state_transfer/types.ts index a6721784302ac..3f3456d914728 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/types.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/types.ts @@ -27,6 +27,7 @@ export interface EmbeddableEditorState { originatingApp: string; byValueMode?: boolean; valueInput?: EmbeddableInput; + embeddableId?: string; } export function isEmbeddableEditorState(state: unknown): state is EmbeddableEditorState { @@ -49,6 +50,7 @@ export interface EmbeddablePackageByReferenceState { export interface EmbeddablePackageByValueState { type: string; input: EmbeddableInput; + embeddableId?: string; } export type EmbeddablePackageState = diff --git a/src/plugins/embeddable/public/mocks.tsx b/src/plugins/embeddable/public/mocks.tsx index fa79af909a427..7ec03ba659cda 100644 --- a/src/plugins/embeddable/public/mocks.tsx +++ b/src/plugins/embeddable/public/mocks.tsx @@ -35,6 +35,7 @@ import { dataPluginMock } from '../../data/public/mocks'; import { inspectorPluginMock } from '../../inspector/public/mocks'; import { uiActionsPluginMock } from '../../ui_actions/public/mocks'; +import { SavedObjectEmbeddableInput, ReferenceOrValueEmbeddable, EmbeddableInput } from './lib'; export type Setup = jest.Mocked; export type Start = jest.Mocked; @@ -84,6 +85,25 @@ export const createEmbeddableStateTransferMock = (): Partial( + embeddable: IEmbeddable, + options: { + mockedByReferenceInput: RefTypeInput; + mockedByValueInput: ValTypeInput; + } +): OriginalEmbeddableType & ReferenceOrValueEmbeddable => { + const newEmbeddable: ReferenceOrValueEmbeddable = (embeddable as unknown) as ReferenceOrValueEmbeddable; + newEmbeddable.inputIsRefType = (input: unknown): input is RefTypeInput => + !!(input as RefTypeInput).savedObjectId; + newEmbeddable.getInputAsRefType = () => Promise.resolve(options.mockedByReferenceInput); + newEmbeddable.getInputAsValueType = () => Promise.resolve(options.mockedByValueInput); + return newEmbeddable as OriginalEmbeddableType & ReferenceOrValueEmbeddable; +}; + const createSetupContract = (): Setup => { const setupContract: Setup = { registerEmbeddableFactory: jest.fn(), @@ -126,4 +146,5 @@ export const embeddablePluginMock = { createSetupContract, createStartContract, createInstance, + mockRefOrValEmbeddable, }; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx index f00beb470a9fc..eead90d2f75b7 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx @@ -17,9 +17,10 @@ * under the License. */ import React, { useEffect } from 'react'; +import { act } from 'react-dom/test-utils'; -import { registerTestBed } from '../shared_imports'; -import { OnUpdateHandler } from '../types'; +import { registerTestBed, TestBed } from '../shared_imports'; +import { FormHook, OnUpdateHandler, FieldConfig } from '../types'; import { useForm } from '../hooks/use_form'; import { Form } from './form'; import { UseField } from './use_field'; @@ -62,4 +63,91 @@ describe('', () => { lastName: 'Snow', }); }); + + describe('serializer(), deserializer(), formatter()', () => { + interface MyForm { + name: string; + } + + const serializer = jest.fn(); + const deserializer = jest.fn(); + const formatter = jest.fn(); + + const fieldConfig: FieldConfig = { + defaultValue: '', + serializer, + deserializer, + formatters: [formatter], + }; + + let formHook: FormHook | null = null; + + beforeEach(() => { + formHook = null; + serializer.mockReset().mockImplementation((value) => `${value}-serialized`); + deserializer.mockReset().mockImplementation((value) => `${value}-deserialized`); + formatter.mockReset().mockImplementation((value: string) => value.toUpperCase()); + }); + + const onFormHook = (_form: FormHook) => { + formHook = _form; + }; + + const TestComp = ({ onForm }: { onForm: (form: FormHook) => void }) => { + const { form } = useForm({ defaultValue: { name: 'John' } }); + + useEffect(() => { + onForm(form); + }, [form]); + + return ( +
+ + + ); + }; + + test('should call each handler at expected lifecycle', async () => { + const setup = registerTestBed(TestComp, { + memoryRouter: { wrapComponent: false }, + defaultProps: { onForm: onFormHook }, + }); + + const testBed = setup() as TestBed; + + if (!formHook) { + throw new Error( + `formHook is not defined. Use the onForm() prop to update the reference to the form hook.` + ); + } + + const { form } = testBed; + + expect(deserializer).toBeCalled(); + expect(serializer).not.toBeCalled(); + expect(formatter).not.toBeCalled(); + + let formData = formHook.getFormData({ unflatten: false }); + expect(formData.name).toEqual('John-deserialized'); + + await act(async () => { + form.setInputValue('myField', 'Mike'); + }); + + expect(formatter).toBeCalled(); // Formatters are executed on each value change + expect(serializer).not.toBeCalled(); // Serializer are executed *only** when outputting the form data + + formData = formHook.getFormData(); + expect(serializer).toBeCalled(); + expect(formData.name).toEqual('MIKE-serialized'); + + // Make sure that when we reset the form values, we don't serialize the fields + serializer.mockReset(); + + await act(async () => { + formHook!.reset(); + }); + expect(serializer).not.toBeCalled(); + }); + }); }); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts index 15ea99eb6cc3a..caf75b42598f5 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts @@ -118,15 +118,13 @@ export const useField = ( setIsChangingValue(true); } - const newValue = serializeOutput(value); - // Notify listener if (valueChangeListener) { - valueChangeListener(newValue as T); + valueChangeListener(value); } // Update the form data observable - __updateFormDataAt(path, newValue); + __updateFormDataAt(path, value); // Validate field(s) and update form.isValid state await __validateFields(fieldsToValidateOnChange ?? [path]); @@ -153,7 +151,6 @@ export const useField = ( } } }, [ - serializeOutput, valueChangeListener, errorDisplayDelay, path, @@ -442,13 +439,7 @@ export const useField = ( if (resetValue) { setValue(initialValue); - /** - * Having to call serializeOutput() is a current bug of the lib and will be fixed - * in a future PR. The serializer function should only be called when outputting - * the form data. If we need to continuously format the data while it changes, - * we need to use the field `formatter` config. - */ - return serializeOutput(initialValue); + return initialValue; } }, [setValue, serializeOutput, initialValue] diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx index 216c7974a9679..b2cc91152b571 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx @@ -22,7 +22,7 @@ import { act } from 'react-dom/test-utils'; import { registerTestBed, getRandomString, TestBed } from '../shared_imports'; import { Form, UseField } from '../components'; -import { FormSubmitHandler, OnUpdateHandler } from '../types'; +import { FormSubmitHandler, OnUpdateHandler, FormHook, ValidationFunc } from '../types'; import { useForm } from './use_form'; interface MyForm { @@ -123,6 +123,71 @@ describe('use_form() hook', () => { expect(formData).toEqual(expectedData); }); + + test('should not build the object if the form is not valid', async () => { + let formHook: FormHook | null = null; + + const onFormHook = (_form: FormHook) => { + formHook = _form; + }; + + const TestComp = ({ onForm }: { onForm: (form: FormHook) => void }) => { + const { form } = useForm({ defaultValue: { username: 'initialValue' } }); + const validator: ValidationFunc = ({ value }) => { + if (value === 'wrongValue') { + return { message: 'Error on the field' }; + } + }; + + useEffect(() => { + onForm(form); + }, [form]); + + return ( +
+ + + ); + }; + + const setup = registerTestBed(TestComp, { + defaultProps: { onForm: onFormHook }, + memoryRouter: { wrapComponent: false }, + }); + + const { + form: { setInputValue }, + } = setup() as TestBed; + + if (!formHook) { + throw new Error( + `formHook is not defined. Use the onForm() prop to update the reference to the form hook.` + ); + } + + let data; + let isValid; + + await act(async () => { + ({ data, isValid } = await formHook!.submit()); + }); + + expect(isValid).toBe(true); + expect(data).toEqual({ username: 'initialValue' }); + + setInputValue('myField', 'wrongValue'); // Validation will fail + + await act(async () => { + ({ data, isValid } = await formHook!.submit()); + }); + + expect(isValid).toBe(false); + expect(data).toEqual({}); // Don't build the object (and call the serializers()) when invalid + }); }); describe('form.subscribe()', () => { diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts index 46b8958491e56..1f51b75a80b21 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts @@ -140,7 +140,7 @@ export function useForm( return Object.entries(fieldsRefs.current).reduce( (acc, [key, field]) => ({ ...acc, - [key]: field.__serializeOutput(), + [key]: field.value, }), {} as T ); @@ -233,8 +233,7 @@ export function useForm( fieldsRefs.current[field.path] = field; if (!{}.hasOwnProperty.call(getFormData$().value, field.path)) { - const fieldValue = field.__serializeOutput(); - updateFormDataAt(field.path, fieldValue); + updateFormDataAt(field.path, field.value); } }, [getFormData$, updateFormDataAt] @@ -301,7 +300,7 @@ export function useForm( setSubmitting(true); const isFormValid = await validateAllFields(); - const formData = getFormData(); + const formData = isFormValid ? getFormData() : ({} as T); if (onSubmit) { await onSubmit(formData, isFormValid!); diff --git a/src/plugins/expressions/common/expression_types/specs/boolean.ts b/src/plugins/expressions/common/expression_types/specs/boolean.ts index d730f95d7c423..adbdeafc34fd2 100644 --- a/src/plugins/expressions/common/expression_types/specs/boolean.ts +++ b/src/plugins/expressions/common/expression_types/specs/boolean.ts @@ -41,7 +41,8 @@ export const boolean: ExpressionTypeDefinition<'boolean', boolean> = { }, datatable: (value): Datatable => ({ type: 'datatable', - columns: [{ name: 'value', type: name }], + meta: {}, + columns: [{ id: 'value', name: 'value', meta: { type: name } }], rows: [{ value }], }), }, diff --git a/src/plugins/expressions/common/expression_types/specs/datatable.ts b/src/plugins/expressions/common/expression_types/specs/datatable.ts index 5cd53df663e1d..dd3c653878de7 100644 --- a/src/plugins/expressions/common/expression_types/specs/datatable.ts +++ b/src/plugins/expressions/common/expression_types/specs/datatable.ts @@ -23,6 +23,13 @@ import { ExpressionTypeDefinition } from '../types'; import { PointSeries, PointSeriesColumn } from './pointseries'; import { ExpressionValueRender } from './render'; +type State = string | number | boolean | null | undefined | SerializableState; + +/** @internal **/ +export interface SerializableState { + [key: string]: State | State[]; +} + const name = 'datatable'; /** @@ -42,12 +49,23 @@ export type DatatableColumnType = 'string' | 'number' | 'boolean' | 'date' | 'nu */ export type DatatableRow = Record; +export interface DatatableColumnMeta { + type: DatatableColumnType; + field?: string; + params?: SerializableState; +} /** * This type represents the shape of a column in a `Datatable`. */ export interface DatatableColumn { + id: string; name: string; - type: DatatableColumnType; + meta: DatatableColumnMeta; +} + +export interface DatatableMeta { + type?: string; + source?: string; } /** @@ -55,6 +73,7 @@ export interface DatatableColumn { */ export interface Datatable { type: typeof name; + meta?: DatatableMeta; columns: DatatableColumn[]; rows: DatatableRow[]; } @@ -103,14 +122,16 @@ export const datatable: ExpressionTypeDefinition ({ type: name, + meta: {}, rows: [], columns: [], }), pointseries: (value: PointSeries) => ({ type: name, + meta: {}, rows: value.rows, columns: map(value.columns, (val: PointSeriesColumn, colName) => { - return { name: colName, type: val.type }; + return { id: colName, name: colName, meta: { type: val.type } }; }), }), }, @@ -127,13 +148,13 @@ export const datatable: ExpressionTypeDefinition { const validFields = ['x', 'y', 'color', 'size', 'text']; - const columns = table.columns.filter((column) => validFields.includes(column.name)); + const columns = table.columns.filter((column) => validFields.includes(column.id)); const rows = table.rows.map((row) => pick(row, validFields)); return { type: 'pointseries', columns: columns.reduce>((acc, column) => { acc[column.name] = { - type: column.type, + type: column.meta.type, expression: column.name, role: 'dimension', }; diff --git a/src/plugins/expressions/common/expression_types/specs/num.ts b/src/plugins/expressions/common/expression_types/specs/num.ts index 191e617fdc858..041747f39740b 100644 --- a/src/plugins/expressions/common/expression_types/specs/num.ts +++ b/src/plugins/expressions/common/expression_types/specs/num.ts @@ -73,7 +73,8 @@ export const num: ExpressionTypeDefinition<'num', ExpressionValueNum> = { }, datatable: ({ value }): Datatable => ({ type: 'datatable', - columns: [{ name: 'value', type: 'number' }], + meta: {}, + columns: [{ id: 'value', name: 'value', meta: { type: 'number' } }], rows: [{ value }], }), }, diff --git a/src/plugins/expressions/common/expression_types/specs/number.ts b/src/plugins/expressions/common/expression_types/specs/number.ts index 10986659c7848..c5fdacf3408a1 100644 --- a/src/plugins/expressions/common/expression_types/specs/number.ts +++ b/src/plugins/expressions/common/expression_types/specs/number.ts @@ -55,7 +55,8 @@ export const number: ExpressionTypeDefinition = { }, datatable: (value): Datatable => ({ type: 'datatable', - columns: [{ name: 'value', type: 'number' }], + meta: {}, + columns: [{ id: 'value', name: 'value', meta: { type: 'number' } }], rows: [{ value }], }), }, diff --git a/src/plugins/expressions/common/expression_types/specs/string.ts b/src/plugins/expressions/common/expression_types/specs/string.ts index 46f460891c2fb..3d52707279bfc 100644 --- a/src/plugins/expressions/common/expression_types/specs/string.ts +++ b/src/plugins/expressions/common/expression_types/specs/string.ts @@ -40,7 +40,8 @@ export const string: ExpressionTypeDefinition = { }, datatable: (value): Datatable => ({ type: 'datatable', - columns: [{ name: 'value', type: 'string' }], + meta: {}, + columns: [{ id: 'value', name: 'value', meta: { type: 'string' } }], rows: [{ value }], }), }, diff --git a/src/plugins/kibana_react/public/field_button/__snapshots__/field_button.test.tsx.snap b/src/plugins/kibana_react/public/field_button/__snapshots__/field_button.test.tsx.snap new file mode 100644 index 0000000000000..e65b5fcb8fbbd --- /dev/null +++ b/src/plugins/kibana_react/public/field_button/__snapshots__/field_button.test.tsx.snap @@ -0,0 +1,134 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`fieldAction is rendered 1`] = ` +
+ +
+ + fieldAction + +
+
+`; + +exports[`fieldIcon is rendered 1`] = ` +
+ +
+`; + +exports[`isActive defaults to false 1`] = ` +
+ +
+`; + +exports[`isActive renders true 1`] = ` +
+ +
+`; + +exports[`isDraggable is rendered 1`] = ` +
+ +
+`; + +exports[`sizes m is applied 1`] = ` +
+ +
+`; + +exports[`sizes s is applied 1`] = ` +
+ +
+`; diff --git a/src/plugins/kibana_react/public/field_button/field_button.scss b/src/plugins/kibana_react/public/field_button/field_button.scss new file mode 100644 index 0000000000000..43f60e4503576 --- /dev/null +++ b/src/plugins/kibana_react/public/field_button/field_button.scss @@ -0,0 +1,75 @@ +.kbnFieldButton { + @include euiFontSizeS; + border-radius: $euiBorderRadius; + margin-bottom: $euiSizeXS; + display: flex; + align-items: center; + transition: box-shadow $euiAnimSpeedFast $euiAnimSlightResistance, + background-color $euiAnimSpeedFast $euiAnimSlightResistance; // sass-lint:disable-line indentation + + &:focus-within, + &-isActive { + @include euiFocusRing; + } +} + +.kbnFieldButton--isDraggable { + background: lightOrDarkTheme($euiColorEmptyShade, $euiColorLightestShade); + + &:hover, + &:focus, + &:focus-within { + @include euiBottomShadowMedium; + border-radius: $euiBorderRadius; + z-index: 2; + } + + .kbnFieldButton__button { + &:hover, + &:focus { + cursor: grab; + } + } +} + +.kbnFieldButton__button { + flex-grow: 1; + text-align: left; + padding: $euiSizeS; + display: flex; + align-items: flex-start; +} + +.kbnFieldButton__fieldIcon { + flex-shrink: 0; + line-height: 0; + margin-right: $euiSizeS; +} + +.kbnFieldButton__name { + flex-grow: 1; + word-break: break-word; +} + +.kbnFieldButton__infoIcon { + flex-shrink: 0; + margin-left: $euiSizeXS; +} + +.kbnFieldButton__fieldAction { + margin-right: $euiSizeS; +} + +// Reduce text size and spacing for the small size +.kbnFieldButton--small { + font-size: $euiFontSizeXS; + + .kbnFieldButton__button { + padding: $euiSizeXS; + } + + .kbnFieldButton__fieldIcon, + .kbnFieldButton__fieldAction { + margin-right: $euiSizeXS; + } +} diff --git a/src/plugins/kibana_react/public/field_button/field_button.test.tsx b/src/plugins/kibana_react/public/field_button/field_button.test.tsx new file mode 100644 index 0000000000000..32e1203b89718 --- /dev/null +++ b/src/plugins/kibana_react/public/field_button/field_button.test.tsx @@ -0,0 +1,68 @@ +/* + * 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 { shallow } from 'enzyme'; +import { FieldButton, SIZES } from './field_button'; + +const noop = () => {}; + +describe('sizes', () => { + SIZES.forEach((size) => { + test(`${size} is applied`, () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + }); +}); + +describe('isDraggable', () => { + it('is rendered', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); +}); + +describe('fieldIcon', () => { + it('is rendered', () => { + const component = shallow( + fieldIcon} /> + ); + expect(component).toMatchSnapshot(); + }); +}); + +describe('fieldAction', () => { + it('is rendered', () => { + const component = shallow( + fieldAction} /> + ); + expect(component).toMatchSnapshot(); + }); +}); + +describe('isActive', () => { + it('defaults to false', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); + it('renders true', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/kibana_react/public/field_button/field_button.tsx b/src/plugins/kibana_react/public/field_button/field_button.tsx new file mode 100644 index 0000000000000..e5833b261946a --- /dev/null +++ b/src/plugins/kibana_react/public/field_button/field_button.tsx @@ -0,0 +1,111 @@ +/* + * 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 './field_button.scss'; +import classNames from 'classnames'; +import React, { ReactNode, HTMLAttributes, ButtonHTMLAttributes } from 'react'; +import { CommonProps } from '@elastic/eui'; + +export interface FieldButtonProps extends HTMLAttributes { + /** + * Label for the button + */ + fieldName: ReactNode; + /** + * Icon representing the field type. + * Recommend using FieldIcon + */ + fieldIcon?: ReactNode; + /** + * An optional node to place inside and at the end of the + {fieldAction &&
{fieldAction}
} +
+ ); +} diff --git a/src/plugins/kibana_react/public/field_button/index.ts b/src/plugins/kibana_react/public/field_button/index.ts new file mode 100644 index 0000000000000..4819a7623a163 --- /dev/null +++ b/src/plugins/kibana_react/public/field_button/index.ts @@ -0,0 +1,19 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export * from './field_button'; diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index 7f8bf6c04cecc..34140703fd8ae 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -23,6 +23,7 @@ export * from './context'; export * from './overlays'; export * from './ui_settings'; export * from './field_icon'; +export * from './field_button'; export * from './table_list_view'; export * from './split_panel'; export * from './react_router_navigate'; diff --git a/src/plugins/kibana_utils/public/ui/configurable.ts b/src/plugins/kibana_utils/public/ui/configurable.ts index a4a9f09c1c0e0..3fa5cdc8b5e47 100644 --- a/src/plugins/kibana_utils/public/ui/configurable.ts +++ b/src/plugins/kibana_utils/public/ui/configurable.ts @@ -26,12 +26,12 @@ export interface Configurable /** * Create default config for this item, used when item is created for the first time. */ - readonly createConfig: () => Config; + readonly createConfig: (context: Context) => Config; /** * Is this config valid. Used to validate user's input before saving. */ - readonly isConfigValid: (config: Config) => boolean; + readonly isConfigValid: (config: Config, context: Context) => boolean; /** * `UiComponent` to be rendered when collecting configuration for this item. diff --git a/src/plugins/maps_legacy/public/index.ts b/src/plugins/maps_legacy/public/index.ts index 6b9c7d1c52db9..14612ab1b2a57 100644 --- a/src/plugins/maps_legacy/public/index.ts +++ b/src/plugins/maps_legacy/public/index.ts @@ -48,6 +48,10 @@ export interface MapsLegacyConfigType { includeElasticMapsService: boolean; proxyElasticMapsServiceInMaps: boolean; tilemap: any; + emsFontLibraryUrl: string; + emsFileApiUrl: string; + emsTileApiUrl: string; + emsLandingPageUrl: string; } export function plugin(initializerContext: PluginInitializerContext) { diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx index 1c5642f9b75b7..b058ef0de448b 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -46,6 +46,7 @@ export function TopNavMenuItem(props: TopNavMenuData) { iconType: props.iconType, iconSide: props.iconSide, 'data-test-subj': props.testId, + className: props.className, }; const btn = props.emphasize ? ( diff --git a/src/plugins/timelion/public/app.js b/src/plugins/timelion/public/app.js index 614a7539de44c..40fffe7a5a063 100644 --- a/src/plugins/timelion/public/app.js +++ b/src/plugins/timelion/public/app.js @@ -43,6 +43,7 @@ import { initTimelionOptionsSheetDirective } from './directives/timelion_options import { initSavedObjectSaveAsCheckBoxDirective } from './directives/saved_object_save_as_checkbox'; import { initSavedObjectFinderDirective } from './directives/saved_object_finder'; import { initTimelionTabsDirective } from './components/timelionhelp_tabs_directive'; +import { initTimelionTDeprecationDirective } from './components/timelion_deprecation_directive'; import { initInputFocusDirective } from './directives/input_focus'; import { Chart } from './directives/chart/chart'; import { TimelionInterval } from './directives/timelion_interval/timelion_interval'; @@ -84,6 +85,7 @@ export function initTimelionApp(app, deps) { initTimelionHelpDirective(app); initInputFocusDirective(app); initTimelionTabsDirective(app, deps); + initTimelionTDeprecationDirective(app, deps); initSavedObjectFinderDirective(app, savedSheetLoader, deps.core.uiSettings); initSavedObjectSaveAsCheckBoxDirective(app); initCellsDirective(app); diff --git a/src/plugins/timelion/public/components/timelion_deprecation.tsx b/src/plugins/timelion/public/components/timelion_deprecation.tsx new file mode 100644 index 0000000000000..f9f04d3504570 --- /dev/null +++ b/src/plugins/timelion/public/components/timelion_deprecation.tsx @@ -0,0 +1,52 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSpacer, EuiCallOut, EuiLink } from '@elastic/eui'; +import React from 'react'; +import { DocLinksStart } from '../../../../core/public'; + +export const TimelionDeprecation = ({ links }: DocLinksStart) => { + const timelionDeprecationLink = links.visualize.timelionDeprecation; + return ( + <> + + +
+ ), + }} + /> + } + color="warning" + iconType="alert" + size="s" + /> + + + ); +}; diff --git a/src/plugins/timelion/public/components/timelion_deprecation_directive.js b/src/plugins/timelion/public/components/timelion_deprecation_directive.js new file mode 100644 index 0000000000000..6a38161c7d40d --- /dev/null +++ b/src/plugins/timelion/public/components/timelion_deprecation_directive.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. + */ +import React from 'react'; +import { TimelionDeprecation } from './timelion_deprecation'; + +export function initTimelionTDeprecationDirective(app, deps) { + app.directive('timelionDeprecation', function (reactDirective) { + return reactDirective( + () => { + return ( + + + + ); + }, + [], + { + restrict: 'E', + scope: { + docLinks: '=', + }, + } + ); + }); +} diff --git a/src/plugins/timelion/public/index.html b/src/plugins/timelion/public/index.html index 54efae7f81ba7..0cf64287a3bd5 100644 --- a/src/plugins/timelion/public/index.html +++ b/src/plugins/timelion/public/index.html @@ -28,6 +28,7 @@
+ diff --git a/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts index df89c9c2f70e9..f65a72f334d07 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts @@ -115,7 +115,7 @@ export class UiActionsExecutionService { context, trigger, })), - title: tasks[0].trigger.title, // title of context menu is title of trigger which originated the chain + title: '', // intentionally don't have any title closeMenu: () => { tasks.forEach((t) => t.defer.resolve()); session.close(); diff --git a/src/plugins/ui_actions/public/triggers/apply_filter_trigger.ts b/src/plugins/ui_actions/public/triggers/apply_filter_trigger.ts index fa9ace1a36c69..aa54706476a8f 100644 --- a/src/plugins/ui_actions/public/triggers/apply_filter_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/apply_filter_trigger.ts @@ -17,11 +17,16 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { Trigger } from '.'; export const APPLY_FILTER_TRIGGER = 'FILTER_TRIGGER'; export const applyFilterTrigger: Trigger<'FILTER_TRIGGER'> = { id: APPLY_FILTER_TRIGGER, - title: 'Apply filter', - description: 'Triggered when user applies filter to an embeddable.', + title: i18n.translate('uiActions.triggers.applyFilterTitle', { + defaultMessage: 'Apply filter', + }), + description: i18n.translate('uiActions.triggers.applyFilterDescription', { + defaultMessage: 'When kibana filter is applied. Could be a single value or a range filter.', + }), }; diff --git a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts index c7c998907381a..f6d5547f62481 100644 --- a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts @@ -17,13 +17,16 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { Trigger } from '.'; export const SELECT_RANGE_TRIGGER = 'SELECT_RANGE_TRIGGER'; export const selectRangeTrigger: Trigger<'SELECT_RANGE_TRIGGER'> = { id: SELECT_RANGE_TRIGGER, - // This is empty string to hide title of ui_actions context menu that appears - // when this trigger is executed. - title: '', - description: 'Applies a range filter', + title: i18n.translate('uiActions.triggers.selectRangeTitle', { + defaultMessage: 'Range selection', + }), + description: i18n.translate('uiActions.triggers.selectRangeDescription', { + defaultMessage: 'Select a group of values', + }), }; diff --git a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts index 5fe060f55dc77..e1e7b6507d82b 100644 --- a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts @@ -17,13 +17,16 @@ * under the License. */ +import { i18n } from '@kbn/i18n'; import { Trigger } from '.'; export const VALUE_CLICK_TRIGGER = 'VALUE_CLICK_TRIGGER'; export const valueClickTrigger: Trigger<'VALUE_CLICK_TRIGGER'> = { id: VALUE_CLICK_TRIGGER, - // This is empty string to hide title of ui_actions context menu that appears - // when this trigger is executed. - title: '', - description: 'Value was clicked', + title: i18n.translate('uiActions.triggers.valueClickTitle', { + defaultMessage: 'Single click', + }), + description: i18n.translate('uiActions.triggers.valueClickDescription', { + defaultMessage: 'A single point clicked on a visualization', + }), }; diff --git a/src/plugins/usage_collection/README.md b/src/plugins/usage_collection/README.md index 4f0f10703c5e9..0b1cca07de007 100644 --- a/src/plugins/usage_collection/README.md +++ b/src/plugins/usage_collection/README.md @@ -177,7 +177,7 @@ New fields added to the telemetry payload currently mean that telemetry cluster There are a few ways you can test that your usage collector is working properly. -1. The `/api/stats?extended=true` HTTP API in Kibana (added in 6.4.0) will call the fetch methods of all the registered collectors, and add them to a stats object you can see in a browser or in curl. To test that your usage collector has been registered correctly and that it has the model of data you expected it to have, call that HTTP API manually and you should see a key in the `usage` object of the response named after your usage collector's `type` field. This method tests the Metricbeat scenario described above where `callCluster` wraps `callWithRequest`. +1. The `/api/stats?extended=true&legacy=true` HTTP API in Kibana (added in 6.4.0) will call the fetch methods of all the registered collectors, and add them to a stats object you can see in a browser or in curl. To test that your usage collector has been registered correctly and that it has the model of data you expected it to have, call that HTTP API manually and you should see a key in the `usage` object of the response named after your usage collector's `type` field. This method tests the Metricbeat scenario described above where `callCluster` wraps `callWithRequest`. 2. There is a dev script in x-pack that will give a sample of a payload of data that gets sent up to the telemetry cluster for the sending phase of telemetry. Collected data comes from: - The `.monitoring-*` indices, when Monitoring is enabled. Monitoring enhances the sent payload of telemetry by producing usage data potentially of multiple clusters that exist in the monitoring data. Monitoring data is time-based, and the time frame of collection is the last 15 minutes. - Live-pulled from ES API endpoints. This will get just real-time stats without context of historical data. diff --git a/src/plugins/vis_default_editor/public/default_editor_controller.tsx b/src/plugins/vis_default_editor/public/default_editor_controller.tsx index 3158582438558..56fb15ea8354a 100644 --- a/src/plugins/vis_default_editor/public/default_editor_controller.tsx +++ b/src/plugins/vis_default_editor/public/default_editor_controller.tsx @@ -69,7 +69,6 @@ class DefaultEditorController { ] : visType.editorConfig.optionTabs), ]; - this.state = { vis, optionTabs, diff --git a/src/plugins/vis_type_table/public/agg_table/agg_table_group.html b/src/plugins/vis_type_table/public/agg_table/agg_table_group.html index b095ee035c9bf..4567b80b5f66c 100644 --- a/src/plugins/vis_type_table/public/agg_table/agg_table_group.html +++ b/src/plugins/vis_type_table/public/agg_table/agg_table_group.html @@ -16,6 +16,7 @@ dimensions="dimensions" per-page="perPage" sort="sort" + export-title="exportTitle" percentage-col="percentageCol" show-total="showTotal" total-func="totalFunc"> @@ -54,6 +55,7 @@ dimensions="dimensions" per-page="perPage" sort="sort" + export-title="exportTitle" show-total="showTotal" percentage-col="percentageCol" total-func="totalFunc"> diff --git a/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap b/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap index 001382d946df6..639559dff3091 100644 --- a/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap +++ b/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`VegaVisualizations VegaVisualization - basics should show vega blank rectangle on top of a map (vegamap) 1`] = `"
"`; +exports[`VegaVisualizations VegaVisualization - basics should show vega blank rectangle on top of a map (vegamap) 1`] = `"
"`; -exports[`VegaVisualizations VegaVisualization - basics should show vega graph (may fail in dev env) 1`] = `"
"`; +exports[`VegaVisualizations VegaVisualization - basics should show vega graph (may fail in dev env) 1`] = `"
"`; -exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 1`] = `"
"`; +exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 1`] = `"
"`; -exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 2`] = `"
"`; +exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 2`] = `"
"`; diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js b/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js index ac92f31b890ed..4883a8129903a 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js @@ -18,6 +18,7 @@ */ import { cloneDeep } from 'lodash'; +import { euiThemeVars } from '@kbn/ui-shared-deps/theme'; import { VegaParser } from './vega_parser'; import { bypassExternalUrlCheck } from '../vega_view/vega_base_view'; @@ -70,8 +71,31 @@ describe(`VegaParser._setDefaultColors`, () => { `vegalite`, check({}, true, { config: { + axis: { + domainColor: euiThemeVars.euiColorChartLines, + gridColor: euiThemeVars.euiColorChartLines, + tickColor: euiThemeVars.euiColorChartLines, + }, + background: 'transparent', range: { category: { scheme: 'elastic' } }, mark: { color: '#54B399' }, + style: { + 'group-title': { + fill: euiThemeVars.euiColorDarkestShade, + }, + 'guide-label': { + fill: euiThemeVars.euiColorDarkShade, + }, + 'guide-title': { + fill: euiThemeVars.euiColorDarkestShade, + }, + 'group-subtitle': { + fill: euiThemeVars.euiColorDarkestShade, + }, + }, + title: { + color: euiThemeVars.euiColorDarkestShade, + }, }, }) ); @@ -80,6 +104,12 @@ describe(`VegaParser._setDefaultColors`, () => { `vega`, check({}, false, { config: { + axis: { + domainColor: euiThemeVars.euiColorChartLines, + gridColor: euiThemeVars.euiColorChartLines, + tickColor: euiThemeVars.euiColorChartLines, + }, + background: 'transparent', range: { category: { scheme: 'elastic' } }, arc: { fill: '#54B399' }, area: { fill: '#54B399' }, @@ -90,6 +120,23 @@ describe(`VegaParser._setDefaultColors`, () => { shape: { stroke: '#54B399' }, symbol: { fill: '#54B399' }, trail: { fill: '#54B399' }, + style: { + 'group-title': { + fill: euiThemeVars.euiColorDarkestShade, + }, + 'guide-label': { + fill: euiThemeVars.euiColorDarkShade, + }, + 'guide-title': { + fill: euiThemeVars.euiColorDarkestShade, + }, + 'group-subtitle': { + fill: euiThemeVars.euiColorDarkestShade, + }, + }, + title: { + color: euiThemeVars.euiColorDarkestShade, + }, }, }) ); diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts index aceeefd953655..da5c76fa6beab 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts @@ -21,7 +21,8 @@ import _ from 'lodash'; import schemaParser from 'vega-schema-url-parser'; import versionCompare from 'compare-versions'; import hjson from 'hjson'; -import { VISUALIZATION_COLORS } from '@elastic/eui'; +import { euiPaletteColorBlind } from '@elastic/eui'; +import { euiThemeVars } from '@kbn/ui-shared-deps/theme'; import { i18n } from '@kbn/i18n'; // @ts-ignore import { vega, vegaLite } from '../lib/vega'; @@ -47,7 +48,7 @@ import { } from './types'; // Set default single color to match other Kibana visualizations -const defaultColor: string = VISUALIZATION_COLORS[0]; +const defaultColor: string = euiPaletteColorBlind()[0]; const locToDirMap: Record = { left: 'row-reverse', @@ -659,6 +660,35 @@ The URL is an identifier only. Kibana and your browser will never access this UR this._setDefaultValue(defaultColor, 'config', 'trail', 'fill'); } } + + // provide right colors for light and dark themes + this._setDefaultValue(euiThemeVars.euiColorDarkestShade, 'config', 'title', 'color'); + this._setDefaultValue(euiThemeVars.euiColorDarkShade, 'config', 'style', 'guide-label', 'fill'); + this._setDefaultValue( + euiThemeVars.euiColorDarkestShade, + 'config', + 'style', + 'guide-title', + 'fill' + ); + this._setDefaultValue( + euiThemeVars.euiColorDarkestShade, + 'config', + 'style', + 'group-title', + 'fill' + ); + this._setDefaultValue( + euiThemeVars.euiColorDarkestShade, + 'config', + 'style', + 'group-subtitle', + 'fill' + ); + this._setDefaultValue(euiThemeVars.euiColorChartLines, 'config', 'axis', 'tickColor'); + this._setDefaultValue(euiThemeVars.euiColorChartLines, 'config', 'axis', 'domainColor'); + this._setDefaultValue(euiThemeVars.euiColorChartLines, 'config', 'axis', 'gridColor'); + this._setDefaultValue('transparent', 'config', 'background'); } /** diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js index a2a973d232de0..9b51b68e93bb4 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js @@ -22,14 +22,14 @@ import moment from 'moment'; import dateMath from '@elastic/datemath'; import { vega, vegaLite } from '../lib/vega'; import { Utils } from '../data_model/utils'; -import { VISUALIZATION_COLORS } from '@elastic/eui'; +import { euiPaletteColorBlind } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { TooltipHandler } from './vega_tooltip'; import { esFilters } from '../../../data/public'; import { getEnableExternalUrls } from '../services'; -vega.scheme('elastic', VISUALIZATION_COLORS); +vega.scheme('elastic', euiPaletteColorBlind()); // Vega's extension functions are global. When called, // we forward execution to the instance-specific handler diff --git a/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts b/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts index 45c750de05ae1..194deef82a5f0 100644 --- a/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts +++ b/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts @@ -41,7 +41,8 @@ export const createVisEmbeddableFromObject = (deps: VisualizeEmbeddableFactoryDe try { const visId = vis.id as string; - const editPath = visId ? savedVisualizations.urlFor(visId) : ''; + const editPath = visId ? savedVisualizations.urlFor(visId) : '#/edit_by_value'; + const editUrl = visId ? getHttp().basePath.prepend(`/app/visualize${savedVisualizations.urlFor(visId)}`) : ''; diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index 80e577930fa8d..c4d5f5206ee90 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -47,6 +47,7 @@ import { Vis } from '../vis'; import { getExpressions, getUiActions } from '../services'; import { VIS_EVENT_TO_TRIGGER } from './events'; import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory'; +import { TriggerId } from '../../../ui_actions/public'; const getKeys = (o: T): Array => Object.keys(o) as Array; @@ -402,7 +403,7 @@ export class VisualizeEmbeddable extends Embeddable { @@ -48,6 +53,9 @@ export const VisualizeApp = () => { return ( + + + diff --git a/src/plugins/visualize/public/application/components/index.ts b/src/plugins/visualize/public/application/components/index.ts index a3a7fde1d6569..1666bae9b72e0 100644 --- a/src/plugins/visualize/public/application/components/index.ts +++ b/src/plugins/visualize/public/application/components/index.ts @@ -20,3 +20,4 @@ export { VisualizeListing } from './visualize_listing'; export { VisualizeEditor } from './visualize_editor'; export { VisualizeNoMatch } from './visualize_no_match'; +export { VisualizeByValueEditor } from './visualize_byvalue_editor'; diff --git a/src/plugins/visualize/public/application/components/visualize_byvalue_editor.tsx b/src/plugins/visualize/public/application/components/visualize_byvalue_editor.tsx new file mode 100644 index 0000000000000..a78633d6841e5 --- /dev/null +++ b/src/plugins/visualize/public/application/components/visualize_byvalue_editor.tsx @@ -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. + */ + +import './visualize_editor.scss'; +import React, { useEffect, useState } from 'react'; +import { EventEmitter } from 'events'; + +import { VisualizeInput } from 'src/plugins/visualizations/public'; +import { useKibana } from '../../../../kibana_react/public'; +import { + useChromeVisibility, + useVisByValue, + useVisualizeAppState, + useEditorUpdates, + useLinkedSearchUpdates, +} from '../utils'; +import { VisualizeServices } from '../types'; +import { VisualizeEditorCommon } from './visualize_editor_common'; + +export const VisualizeByValueEditor = () => { + const [originatingApp, setOriginatingApp] = useState(); + const { services } = useKibana(); + const [eventEmitter] = useState(new EventEmitter()); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(true); + const [embeddableId, setEmbeddableId] = useState(); + const [valueInput, setValueInput] = useState(); + + useEffect(() => { + const { originatingApp: value, embeddableId: embeddableIdValue, valueInput: valueInputValue } = + services.embeddable + .getStateTransfer(services.scopedHistory) + .getIncomingEditorState({ keysToRemoveAfterFetch: ['id', 'embeddableId', 'valueInput'] }) || + {}; + setOriginatingApp(value); + setValueInput(valueInputValue); + setEmbeddableId(embeddableIdValue); + if (!valueInputValue) { + history.back(); + } + }, [services]); + + const isChromeVisible = useChromeVisibility(services.chrome); + + const { byValueVisInstance, visEditorRef, visEditorController } = useVisByValue( + services, + eventEmitter, + isChromeVisible, + valueInput, + originatingApp + ); + const { appState, hasUnappliedChanges } = useVisualizeAppState( + services, + eventEmitter, + byValueVisInstance + ); + const { isEmbeddableRendered, currentAppState } = useEditorUpdates( + services, + eventEmitter, + setHasUnsavedChanges, + appState, + byValueVisInstance, + visEditorController + ); + useLinkedSearchUpdates(services, eventEmitter, appState, byValueVisInstance); + + useEffect(() => { + // clean up all registered listeners if any is left + return () => { + eventEmitter.removeAllListeners(); + }; + }, [eventEmitter]); + + return ( + + ); +}; diff --git a/src/plugins/visualize/public/application/components/visualize_editor.tsx b/src/plugins/visualize/public/application/components/visualize_editor.tsx index 516dcacfe5813..0bf5b26e1339f 100644 --- a/src/plugins/visualize/public/application/components/visualize_editor.tsx +++ b/src/plugins/visualize/public/application/components/visualize_editor.tsx @@ -21,8 +21,6 @@ import './visualize_editor.scss'; import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { EventEmitter } from 'events'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiScreenReaderOnly } from '@elastic/eui'; import { useKibana } from '../../../../kibana_react/public'; import { @@ -33,8 +31,7 @@ import { useLinkedSearchUpdates, } from '../utils'; import { VisualizeServices } from '../types'; -import { ExperimentalVisInfo } from './experimental_vis_info'; -import { VisualizeTopNav } from './visualize_top_nav'; +import { VisualizeEditorCommon } from './visualize_editor_common'; export const VisualizeEditor = () => { const { id: visualizationIdFromUrl } = useParams(); @@ -67,7 +64,9 @@ export const VisualizeEditor = () => { useEffect(() => { const { originatingApp: value } = - services.embeddable.getStateTransfer(services.scopedHistory).getIncomingEditorState() || {}; + services.embeddable + .getStateTransfer(services.scopedHistory) + .getIncomingEditorState({ keysToRemoveAfterFetch: ['id', 'input'] }) || {}; setOriginatingApp(value); }, [services]); @@ -79,38 +78,19 @@ export const VisualizeEditor = () => { }, [eventEmitter]); return ( -
- {savedVisInstance && appState && currentAppState && ( - - )} - {savedVisInstance?.vis?.type?.isExperimental && } - {savedVisInstance && ( - -

- -

-
- )} -
-
+ ); }; diff --git a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx new file mode 100644 index 0000000000000..b811936c63b14 --- /dev/null +++ b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx @@ -0,0 +1,110 @@ +/* + * 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 './visualize_editor.scss'; +import React, { RefObject } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiScreenReaderOnly } from '@elastic/eui'; +import { VisualizeTopNav } from './visualize_top_nav'; +import { ExperimentalVisInfo } from './experimental_vis_info'; +import { + SavedVisInstance, + VisualizeAppState, + VisualizeAppStateContainer, + VisualizeEditorVisInstance, +} from '../types'; + +interface VisualizeEditorCommonProps { + visInstance?: VisualizeEditorVisInstance; + appState: VisualizeAppStateContainer | null; + currentAppState?: VisualizeAppState; + isChromeVisible?: boolean; + hasUnsavedChanges: boolean; + setHasUnsavedChanges: (value: boolean) => void; + hasUnappliedChanges: boolean; + isEmbeddableRendered: boolean; + visEditorRef: RefObject; + originatingApp?: string; + setOriginatingApp?: (originatingApp: string | undefined) => void; + visualizationIdFromUrl?: string; + embeddableId?: string; +} + +export const VisualizeEditorCommon = ({ + visInstance, + appState, + currentAppState, + isChromeVisible, + hasUnsavedChanges, + setHasUnsavedChanges, + hasUnappliedChanges, + isEmbeddableRendered, + originatingApp, + setOriginatingApp, + visualizationIdFromUrl, + embeddableId, + visEditorRef, +}: VisualizeEditorCommonProps) => { + return ( +
+ {visInstance && appState && currentAppState && ( + + )} + {visInstance?.vis?.type?.isExperimental && } + {visInstance && ( + +

+ {'savedVis' in visInstance && visInstance.savedVis.id ? ( + + ) : ( + + )} +

+
+ )} +
+
+ ); +}; diff --git a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx index f2cb2d49f59b0..12a3d1cdf95b1 100644 --- a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx +++ b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx @@ -25,7 +25,7 @@ import { VisualizeServices, VisualizeAppState, VisualizeAppStateContainer, - SavedVisInstance, + VisualizeEditorVisInstance, } from '../types'; import { APP_NAME } from '../visualize_constants'; import { getTopNavConfig } from '../utils'; @@ -38,10 +38,11 @@ interface VisualizeTopNavProps { setHasUnsavedChanges: (value: boolean) => void; hasUnappliedChanges: boolean; originatingApp?: string; + visInstance: VisualizeEditorVisInstance; setOriginatingApp?: (originatingApp: string | undefined) => void; - savedVisInstance: SavedVisInstance; stateContainer: VisualizeAppStateContainer; visualizationIdFromUrl?: string; + embeddableId?: string; } const TopNav = ({ @@ -53,26 +54,26 @@ const TopNav = ({ hasUnappliedChanges, originatingApp, setOriginatingApp, - savedVisInstance, + visInstance, stateContainer, visualizationIdFromUrl, + embeddableId, }: VisualizeTopNavProps) => { const { services } = useKibana(); const { TopNavMenu } = services.navigation.ui; - const { embeddableHandler, vis } = savedVisInstance; + const { embeddableHandler, vis } = visInstance; const [inspectorSession, setInspectorSession] = useState(); const openInspector = useCallback(() => { const session = embeddableHandler.openInspector(); setInspectorSession(session); }, [embeddableHandler]); - const handleRefresh = useCallback( (_payload: any, isUpdate?: boolean) => { if (isUpdate === false) { - savedVisInstance.embeddableHandler.reload(); + visInstance.embeddableHandler.reload(); } }, - [savedVisInstance.embeddableHandler] + [visInstance.embeddableHandler] ); const config = useMemo(() => { @@ -85,9 +86,10 @@ const TopNav = ({ openInspector, originatingApp, setOriginatingApp, - savedVisInstance, + visInstance, stateContainer, visualizationIdFromUrl, + embeddableId, }, services ); @@ -99,11 +101,12 @@ const TopNav = ({ hasUnappliedChanges, openInspector, originatingApp, + visInstance, setOriginatingApp, - savedVisInstance, stateContainer, visualizationIdFromUrl, services, + embeddableId, ]); const [indexPattern, setIndexPattern] = useState(vis.data.indexPattern); const showDatePicker = () => { diff --git a/src/plugins/visualize/public/application/types.ts b/src/plugins/visualize/public/application/types.ts index 02ae1cc155dd2..65b88485b2f06 100644 --- a/src/plugins/visualize/public/application/types.ts +++ b/src/plugins/visualize/public/application/types.ts @@ -121,6 +121,14 @@ export interface SavedVisInstance { embeddableHandler: VisualizeEmbeddableContract; } +export interface ByValueVisInstance { + vis: Vis; + savedSearch?: SavedObject; + embeddableHandler: VisualizeEmbeddableContract; +} + +export type VisualizeEditorVisInstance = SavedVisInstance | ByValueVisInstance; + export interface IEditorController { render(props: EditorRenderProps): void; destroy(): void; diff --git a/src/plugins/visualize/public/application/utils/breadcrumbs.ts b/src/plugins/visualize/public/application/utils/breadcrumbs.ts index a1e5a9e8912e1..a5c246c539c54 100644 --- a/src/plugins/visualize/public/application/utils/breadcrumbs.ts +++ b/src/plugins/visualize/public/application/utils/breadcrumbs.ts @@ -21,6 +21,18 @@ import { i18n } from '@kbn/i18n'; import { VisualizeConstants } from '../visualize_constants'; +const appPrefixes: Record = { + dashboards: { + text: i18n.translate('visualize.dashboard.prefix.breadcrumb', { + defaultMessage: 'Dashboard', + }), + }, +}; + +const defaultEditText = i18n.translate('visualize.editor.defaultEditBreadcrumbText', { + defaultMessage: 'Edit', +}); + export function getLandingBreadcrumbs() { return [ { @@ -43,7 +55,12 @@ export function getCreateBreadcrumbs() { ]; } -export function getEditBreadcrumbs(text: string) { +export function getBreadcrumbsPrefixedWithApp(originatingApp: string) { + const originatingAppBreadcrumb = appPrefixes[originatingApp]; + return [originatingAppBreadcrumb, ...getLandingBreadcrumbs(), { text: defaultEditText }]; +} + +export function getEditBreadcrumbs(text: string = defaultEditText) { return [ ...getLandingBreadcrumbs(), { diff --git a/src/plugins/visualize/public/application/utils/create_visualize_app_state.ts b/src/plugins/visualize/public/application/utils/create_visualize_app_state.ts index 52b7e3ede298b..7a7e04d78354b 100644 --- a/src/plugins/visualize/public/application/utils/create_visualize_app_state.ts +++ b/src/plugins/visualize/public/application/utils/create_visualize_app_state.ts @@ -32,6 +32,7 @@ const STATE_STORAGE_KEY = '_a'; interface Arguments { kbnUrlStateStorage: IKbnUrlStateStorage; stateDefaults: VisualizeAppState; + byValue?: boolean; } function toObject(state: PureVisState): PureVisState { @@ -40,55 +41,67 @@ function toObject(state: PureVisState): PureVisState { }) as PureVisState; } -export function createVisualizeAppState({ stateDefaults, kbnUrlStateStorage }: Arguments) { +const pureTransitions = { + set: (state) => (prop, value) => ({ ...state, [prop]: value }), + setVis: (state) => (vis) => ({ + ...state, + vis: { + ...state.vis, + ...vis, + }, + }), + unlinkSavedSearch: (state) => ({ query, parentFilters = [] }) => ({ + ...state, + query: query || state.query, + filters: union(state.filters, parentFilters), + linked: false, + }), + updateVisState: (state) => (newVisState) => ({ ...state, vis: toObject(newVisState) }), + updateSavedQuery: (state) => (savedQueryId) => { + const updatedState = { + ...state, + savedQuery: savedQueryId, + }; + + if (!savedQueryId) { + delete updatedState.savedQuery; + } + + return updatedState; + }, +} as VisualizeAppStateTransitions; + +function createVisualizeByValueAppState(stateDefaults: VisualizeAppState) { + const initialState = migrateAppState({ + ...stateDefaults, + ...stateDefaults, + }); + const stateContainer = createStateContainer( + initialState, + pureTransitions + ); + const stopStateSync = () => {}; + return { stateContainer, stopStateSync }; +} + +function createDefaultVisualizeAppState({ stateDefaults, kbnUrlStateStorage }: Arguments) { const urlState = kbnUrlStateStorage.get(STATE_STORAGE_KEY); const initialState = migrateAppState({ ...stateDefaults, ...urlState, }); - /* - make sure url ('_a') matches initial state - Initializing appState does two things - first it translates the defaults into AppState, - second it updates appState based on the url (the url trumps the defaults). This means if - we update the state format at all and want to handle BWC, we must not only migrate the - data stored with saved vis, but also any old state in the url. - */ + make sure url ('_a') matches initial state + Initializing appState does two things - first it translates the defaults into AppState, + second it updates appState based on the url (the url trumps the defaults). This means if + we update the state format at all and want to handle BWC, we must not only migrate the + data stored with saved vis, but also any old state in the url. + */ kbnUrlStateStorage.set(STATE_STORAGE_KEY, initialState, { replace: true }); - const stateContainer = createStateContainer( initialState, - { - set: (state) => (prop, value) => ({ ...state, [prop]: value }), - setVis: (state) => (vis) => ({ - ...state, - vis: { - ...state.vis, - ...vis, - }, - }), - unlinkSavedSearch: (state) => ({ query, parentFilters = [] }) => ({ - ...state, - query: query || state.query, - filters: union(state.filters, parentFilters), - linked: false, - }), - updateVisState: (state) => (newVisState) => ({ ...state, vis: toObject(newVisState) }), - updateSavedQuery: (state) => (savedQueryId) => { - const updatedState = { - ...state, - savedQuery: savedQueryId, - }; - - if (!savedQueryId) { - delete updatedState.savedQuery; - } - - return updatedState; - }, - } + pureTransitions ); - const { start: startStateSync, stop: stopStateSync } = syncState({ storageKey: STATE_STORAGE_KEY, stateContainer: { @@ -102,9 +115,14 @@ export function createVisualizeAppState({ stateDefaults, kbnUrlStateStorage }: A }, stateStorage: kbnUrlStateStorage, }); - // start syncing the appState with the ('_a') url startStateSync(); - return { stateContainer, stopStateSync }; } + +export function createVisualizeAppState({ stateDefaults, kbnUrlStateStorage, byValue }: Arguments) { + if (byValue) { + return createVisualizeByValueAppState(stateDefaults); + } + return createDefaultVisualizeAppState({ stateDefaults, kbnUrlStateStorage }); +} diff --git a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx index da9ba66a914dd..87a6437192aa9 100644 --- a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx @@ -31,9 +31,14 @@ import { } from '../../../../saved_objects/public'; import { unhashUrl } from '../../../../kibana_utils/public'; -import { SavedVisInstance, VisualizeServices, VisualizeAppStateContainer } from '../types'; +import { + VisualizeServices, + VisualizeAppStateContainer, + VisualizeEditorVisInstance, +} from '../types'; import { VisualizeConstants } from '../visualize_constants'; import { getEditBreadcrumbs } from './breadcrumbs'; + interface TopNavConfigParams { hasUnsavedChanges: boolean; setHasUnsavedChanges: (value: boolean) => void; @@ -41,9 +46,10 @@ interface TopNavConfigParams { originatingApp?: string; setOriginatingApp?: (originatingApp: string | undefined) => void; hasUnappliedChanges: boolean; - savedVisInstance: SavedVisInstance; + visInstance: VisualizeEditorVisInstance; stateContainer: VisualizeAppStateContainer; visualizationIdFromUrl?: string; + embeddableId?: string; } export const getTopNavConfig = ( @@ -54,9 +60,10 @@ export const getTopNavConfig = ( originatingApp, setOriginatingApp, hasUnappliedChanges, - savedVisInstance: { embeddableHandler, savedVis, vis }, + visInstance, stateContainer, visualizationIdFromUrl, + embeddableId, }: TopNavConfigParams, { application, @@ -71,10 +78,15 @@ export const getTopNavConfig = ( featureFlagConfig, }: VisualizeServices ) => { + const { vis, embeddableHandler } = visInstance; + const savedVis = 'savedVis' in visInstance ? visInstance.savedVis : undefined; /** * Called when the user clicks "Save" button. */ async function doSave(saveOptions: SavedObjectSaveOpts) { + if (!savedVis) { + return {}; + } const newlyCreated = !Boolean(savedVis.id) || savedVis.copyOnSave; // vis.title was not bound and it's needed to reflect title into visState stateContainer.transitions.setVis({ @@ -147,8 +159,26 @@ export const getTopNavConfig = ( } } + const createVisReference = () => { + if (!originatingApp) { + return; + } + const state = { + input: { + ...vis.serialize(), + id: embeddableId ? embeddableId : uuid.v4(), + }, + type: VISUALIZE_EMBEDDABLE_TYPE, + embeddableId: '', + }; + if (embeddableId) { + state.embeddableId = embeddableId; + } + embeddable.getStateTransfer().navigateToWithEmbeddablePackage(originatingApp, { state }); + }; + const topNavMenu: TopNavMenuData[] = [ - ...(originatingApp && savedVis.id + ...(originatingApp && ((savedVis && savedVis.id) || embeddableId) ? [ { id: 'saveAndReturn', @@ -180,27 +210,35 @@ export const getTopNavConfig = ( confirmOverwrite: false, returnToOrigin: true, }; + if ( + originatingApp === 'dashboards' && + featureFlagConfig.showNewVisualizeFlow && + !savedVis + ) { + return createVisReference(); + } return doSave(saveOptions); }, }, ] : []), - ...(visualizeCapabilities.save + ...(visualizeCapabilities.save && !embeddableId ? [ { id: 'save', label: - savedVis.id && originatingApp + savedVis?.id && originatingApp ? i18n.translate('visualize.topNavMenu.saveVisualizationAsButtonLabel', { defaultMessage: 'save as', }) : i18n.translate('visualize.topNavMenu.saveVisualizationButtonLabel', { defaultMessage: 'save', }), - emphasize: !savedVis.id || !originatingApp, + emphasize: (savedVis && !savedVis.id) || !originatingApp, description: i18n.translate('visualize.topNavMenu.saveVisualizationButtonAriaLabel', { defaultMessage: 'Save Visualization', }), + className: savedVis?.id && originatingApp ? 'saveAsButton' : '', testId: 'visualizeSaveButton', disableButton: hasUnappliedChanges, tooltip() { @@ -213,7 +251,7 @@ export const getTopNavConfig = ( ); } }, - run: () => { + run: (anchorElement: HTMLElement) => { const onSave = async ({ newTitle, newCopyOnSave, @@ -222,6 +260,9 @@ export const getTopNavConfig = ( newDescription, returnToOrigin, }: OnSaveProps & { returnToOrigin: boolean }) => { + if (!savedVis) { + return; + } const currentTitle = savedVis.title; savedVis.title = newTitle; savedVis.copyOnSave = newCopyOnSave; @@ -239,32 +280,23 @@ export const getTopNavConfig = ( } return response; }; - - const createVisReference = () => { - if (!originatingApp) { - return; - } - const input = { - ...vis.serialize(), - id: uuid.v4(), - }; - embeddable.getStateTransfer().navigateToWithEmbeddablePackage(originatingApp, { - state: { input, type: VISUALIZE_EMBEDDABLE_TYPE }, - }); - }; - const saveModal = ( {}} originatingApp={originatingApp} /> ); - if (originatingApp === 'dashboards' && featureFlagConfig.showNewVisualizeFlow) { + const isSaveAsButton = anchorElement.classList.contains('saveAsButton'); + if ( + originatingApp === 'dashboards' && + featureFlagConfig.showNewVisualizeFlow && + !isSaveAsButton + ) { createVisReference(); - } else { + } else if (savedVis) { showSaveModal(saveModal, I18nContext); } }, @@ -281,23 +313,24 @@ export const getTopNavConfig = ( }), testId: 'shareTopNavButton', run: (anchorElement) => { - if (share) { + if (share && !embeddableId) { + // TODO: support sharing in by-value mode share.toggleShareContextMenu({ anchorElement, allowEmbed: true, allowShortUrl: visualizeCapabilities.createShortUrl, shareableUrl: unhashUrl(window.location.href), - objectId: savedVis.id, + objectId: savedVis?.id, objectType: 'visualization', sharingData: { - title: savedVis.title, + title: savedVis?.title, }, isDirty: hasUnappliedChanges || hasUnsavedChanges, }); } }, // disable the Share button if no action specified - disableButton: !share, + disableButton: !share || !!embeddableId, }, { id: 'inspector', diff --git a/src/plugins/visualize/public/application/utils/get_visualization_instance.ts b/src/plugins/visualize/public/application/utils/get_visualization_instance.ts index a75c84cf0b71c..3ffca578f8052 100644 --- a/src/plugins/visualize/public/application/utils/get_visualization_instance.ts +++ b/src/plugins/visualize/public/application/utils/get_visualization_instance.ts @@ -18,46 +18,31 @@ */ import { i18n } from '@kbn/i18n'; -import { VisSavedObject, VisualizeEmbeddableContract } from 'src/plugins/visualizations/public'; +import { + SerializedVis, + Vis, + VisSavedObject, + VisualizeEmbeddableContract, + VisualizeInput, +} from 'src/plugins/visualizations/public'; import { SearchSourceFields } from 'src/plugins/data/public'; import { SavedObject } from 'src/plugins/saved_objects/public'; +import { cloneDeep } from 'lodash'; import { createSavedSearchesLoader } from '../../../../discover/public'; import { VisualizeServices } from '../types'; -export const getVisualizationInstance = async ( - { +const createVisualizeEmbeddableAndLinkSavedSearch = async ( + vis: Vis, + visualizeServices: VisualizeServices +) => { + const { chrome, data, overlays, - visualizations, createVisEmbeddableFromObject, savedObjects, - savedVisualizations, toastNotifications, - }: VisualizeServices, - /** - * opts can be either a saved visualization id passed as string, - * or an object of new visualization params. - * Both come from url search query - */ - opts?: Record | string -) => { - const savedVis: VisSavedObject = await savedVisualizations.get(opts); - - if (typeof opts !== 'string') { - savedVis.searchSourceFields = { index: opts?.indexPattern } as SearchSourceFields; - } - const serializedVis = visualizations.convertToSerializedVis(savedVis); - let vis = await visualizations.createVis(serializedVis.type, serializedVis); - - if (vis.type.setup) { - try { - vis = await vis.type.setup(vis); - } catch { - // skip this catch block - } - } - + } = visualizeServices; const embeddableHandler = (await createVisEmbeddableFromObject(vis, { timeRange: data.query.timefilter.timefilter.getTime(), filters: data.query.filterManager.getFilters(), @@ -86,5 +71,67 @@ export const getVisualizationInstance = async ( }).get(vis.data.savedSearchId); } - return { vis, savedVis, savedSearch, embeddableHandler }; + return { savedSearch, embeddableHandler }; +}; + +export const getVisualizationInstanceFromInput = async ( + visualizeServices: VisualizeServices, + input: VisualizeInput +) => { + const { visualizations } = visualizeServices; + const visState = input.savedVis as SerializedVis; + let vis = await visualizations.createVis(visState.type, cloneDeep(visState)); + if (vis.type.setup) { + try { + vis = await vis.type.setup(vis); + } catch { + // skip this catch block + } + } + const { embeddableHandler, savedSearch } = await createVisualizeEmbeddableAndLinkSavedSearch( + vis, + visualizeServices + ); + return { + vis, + embeddableHandler, + savedSearch, + }; +}; + +export const getVisualizationInstance = async ( + visualizeServices: VisualizeServices, + /** + * opts can be either a saved visualization id passed as string, + * or an object of new visualization params. + * Both come from url search query + */ + opts?: Record | string +) => { + const { visualizations, savedVisualizations } = visualizeServices; + const savedVis: VisSavedObject = await savedVisualizations.get(opts); + + if (typeof opts !== 'string') { + savedVis.searchSourceFields = { index: opts?.indexPattern } as SearchSourceFields; + } + const serializedVis = visualizations.convertToSerializedVis(savedVis); + let vis = await visualizations.createVis(serializedVis.type, serializedVis); + if (vis.type.setup) { + try { + vis = await vis.type.setup(vis); + } catch { + // skip this catch block + } + } + + const { embeddableHandler, savedSearch } = await createVisualizeEmbeddableAndLinkSavedSearch( + vis, + visualizeServices + ); + return { + vis, + embeddableHandler, + savedSearch, + savedVis, + }; }; diff --git a/src/plugins/visualize/public/application/utils/use/index.ts b/src/plugins/visualize/public/application/utils/use/index.ts index 8bd9456b10572..98d1f11d81a8e 100644 --- a/src/plugins/visualize/public/application/utils/use/index.ts +++ b/src/plugins/visualize/public/application/utils/use/index.ts @@ -22,3 +22,4 @@ export { useEditorUpdates } from './use_editor_updates'; export { useSavedVisInstance } from './use_saved_vis_instance'; export { useVisualizeAppState } from './use_visualize_app_state'; export { useLinkedSearchUpdates } from './use_linked_search_updates'; +export { useVisByValue } from './use_vis_byvalue'; diff --git a/src/plugins/visualize/public/application/utils/use/use_editor_updates.ts b/src/plugins/visualize/public/application/utils/use/use_editor_updates.ts index 0f4b2d34e8e87..c29f6337a6246 100644 --- a/src/plugins/visualize/public/application/utils/use/use_editor_updates.ts +++ b/src/plugins/visualize/public/application/utils/use/use_editor_updates.ts @@ -25,8 +25,8 @@ import { VisualizeServices, VisualizeAppState, VisualizeAppStateContainer, - SavedVisInstance, IEditorController, + VisualizeEditorVisInstance, } from '../../types'; export const useEditorUpdates = ( @@ -34,21 +34,22 @@ export const useEditorUpdates = ( eventEmitter: EventEmitter, setHasUnsavedChanges: (value: boolean) => void, appState: VisualizeAppStateContainer | null, - savedVisInstance: SavedVisInstance | undefined, + visInstance: VisualizeEditorVisInstance | undefined, visEditorController: IEditorController | undefined ) => { const [isEmbeddableRendered, setIsEmbeddableRendered] = useState(false); const [currentAppState, setCurrentAppState] = useState(); useEffect(() => { - if (appState && savedVisInstance) { + if (appState && visInstance) { const { timefilter: { timefilter }, filterManager, queryString, state$, } = services.data.query; - const { embeddableHandler, savedVis, savedSearch, vis } = savedVisInstance; + const { embeddableHandler, savedSearch, vis } = visInstance; + const savedVis = 'savedVis' in visInstance ? visInstance.savedVis : undefined; const initialState = appState.getState(); setCurrentAppState(initialState); @@ -79,15 +80,18 @@ export const useEditorUpdates = ( }); const handleLinkedSearch = (linked: boolean) => { - if (linked && !savedVis.savedSearchId && savedSearch) { + if (linked && savedVis && !savedVis.savedSearchId && savedSearch) { savedVis.savedSearchId = savedSearch.id; vis.data.savedSearchId = savedSearch.id; if (vis.data.searchSource) { vis.data.searchSource.setParent(savedSearch.searchSource); } - } else if (!linked && savedVis.savedSearchId) { + } else if (!linked && savedVis && savedVis.savedSearchId) { delete savedVis.savedSearchId; delete vis.data.savedSearchId; + } else if (!linked && !savedVis) { + // delete link when it's not a saved vis + delete vis.data.savedSearchId; } }; @@ -105,8 +109,7 @@ export const useEditorUpdates = ( const unsubscribeStateUpdates = appState.subscribe((state) => { setCurrentAppState(state); - - if (savedVis.id && !services.history.location.pathname.includes(savedVis.id)) { + if (savedVis && savedVis.id && !services.history.location.pathname.includes(savedVis.id)) { // this filters out the case when manipulating the browser history back/forward // and initializing different visualizations return; @@ -118,6 +121,7 @@ export const useEditorUpdates = ( // if the browser history was changed manually we need to reflect changes in the editor if ( + savedVis && !isEqual( { ...services.visualizations.convertFromSerializedVis(vis.serialize()).visState, @@ -160,14 +164,7 @@ export const useEditorUpdates = ( unsubscribeStateUpdates(); }; } - }, [ - appState, - eventEmitter, - savedVisInstance, - services, - setHasUnsavedChanges, - visEditorController, - ]); + }, [appState, eventEmitter, visInstance, services, setHasUnsavedChanges, visEditorController]); return { isEmbeddableRendered, currentAppState }; }; diff --git a/src/plugins/visualize/public/application/utils/use/use_linked_search_updates.ts b/src/plugins/visualize/public/application/utils/use/use_linked_search_updates.ts index e257b72ee751b..7bc38ba6e2842 100644 --- a/src/plugins/visualize/public/application/utils/use/use_linked_search_updates.ts +++ b/src/plugins/visualize/public/application/utils/use/use_linked_search_updates.ts @@ -22,24 +22,23 @@ import { i18n } from '@kbn/i18n'; import { EventEmitter } from 'events'; import { Filter } from 'src/plugins/data/public'; -import { VisualizeServices, VisualizeAppStateContainer, SavedVisInstance } from '../../types'; +import { + VisualizeServices, + VisualizeAppStateContainer, + VisualizeEditorVisInstance, +} from '../../types'; export const useLinkedSearchUpdates = ( services: VisualizeServices, eventEmitter: EventEmitter, appState: VisualizeAppStateContainer | null, - savedVisInstance: SavedVisInstance | undefined + visInstance: VisualizeEditorVisInstance | undefined ) => { useEffect(() => { - if ( - appState && - savedVisInstance && - savedVisInstance.savedSearch && - savedVisInstance.vis.data.searchSource - ) { - const { savedSearch } = savedVisInstance; + if (appState && visInstance && visInstance.savedSearch && visInstance.vis.data.searchSource) { + const { savedSearch } = visInstance; // SearchSource is a promise-based stream of search results that can inherit from other search sources. - const { searchSource } = savedVisInstance.vis.data; + const { searchSource } = visInstance.vis.data; const unlinkFromSavedSearch = () => { const searchSourceParent = savedSearch.searchSource; @@ -70,5 +69,5 @@ export const useLinkedSearchUpdates = ( eventEmitter.off('unlinkFromSavedSearch', unlinkFromSavedSearch); }; } - }, [appState, eventEmitter, savedVisInstance, services.toastNotifications]); + }, [appState, eventEmitter, visInstance, services.toastNotifications]); }; diff --git a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts index 764bcb4a327c0..ec815b8cfcbee 100644 --- a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts +++ b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts @@ -59,7 +59,6 @@ export const useSavedVisInstance = ( const getSavedVisInstance = async () => { try { let savedVisInstance: SavedVisInstance; - if (history.location.pathname === '/create') { const searchParams = parse(history.location.search); const visTypes = services.visualizations.all(); diff --git a/src/plugins/visualize/public/application/utils/use/use_vis_byvalue.ts b/src/plugins/visualize/public/application/utils/use/use_vis_byvalue.ts new file mode 100644 index 0000000000000..f2758d0cc01a4 --- /dev/null +++ b/src/plugins/visualize/public/application/utils/use/use_vis_byvalue.ts @@ -0,0 +1,95 @@ +/* + * 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 { EventEmitter } from 'events'; +import { useEffect, useRef, useState } from 'react'; +import { VisualizeInput } from 'src/plugins/visualizations/public'; +import { ByValueVisInstance, IEditorController, VisualizeServices } from '../../types'; +import { getVisualizationInstanceFromInput } from '../get_visualization_instance'; +import { getBreadcrumbsPrefixedWithApp, getEditBreadcrumbs } from '../breadcrumbs'; +import { DefaultEditorController } from '../../../../../vis_default_editor/public'; + +export const useVisByValue = ( + services: VisualizeServices, + eventEmitter: EventEmitter, + isChromeVisible: boolean | undefined, + valueInput?: VisualizeInput, + originatingApp?: string +) => { + const [state, setState] = useState<{ + byValueVisInstance?: ByValueVisInstance; + visEditorController?: IEditorController; + }>({}); + const visEditorRef = useRef(null); + const loaded = useRef(false); + useEffect(() => { + const { chrome } = services; + const getVisInstance = async () => { + if (!valueInput || loaded.current) { + return; + } + const byValueVisInstance = await getVisualizationInstanceFromInput(services, valueInput); + const { embeddableHandler, vis } = byValueVisInstance; + const Editor = vis.type.editor || DefaultEditorController; + const visEditorController = new Editor( + visEditorRef.current, + vis, + eventEmitter, + embeddableHandler + ); + + if (chrome && originatingApp) { + chrome.setBreadcrumbs(getBreadcrumbsPrefixedWithApp(originatingApp)); + } else if (chrome) { + chrome.setBreadcrumbs(getEditBreadcrumbs()); + } + + loaded.current = true; + setState({ + byValueVisInstance, + visEditorController, + }); + }; + + getVisInstance(); + }, [ + eventEmitter, + isChromeVisible, + services, + state.byValueVisInstance, + state.visEditorController, + valueInput, + originatingApp, + ]); + + useEffect(() => { + return () => { + if (state.visEditorController) { + state.visEditorController.destroy(); + } else if (state.byValueVisInstance?.embeddableHandler) { + state.byValueVisInstance.embeddableHandler.destroy(); + } + }; + }, [state]); + + return { + ...state, + visEditorRef, + }; +}; diff --git a/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts b/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts index 8bde9a049c492..39a2db12ffad1 100644 --- a/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts +++ b/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts @@ -90,6 +90,7 @@ describe('useVisualizeAppState', () => { expect(createVisualizeAppState).toHaveBeenCalledWith({ stateDefaults: visualizeAppStateStub, kbnUrlStateStorage: undefined, + byValue: false, }); expect(mockServices.data.query.filterManager.setAppFilters).toHaveBeenCalledWith( visualizeAppStateStub.filters diff --git a/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.tsx b/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.tsx index c44f67df3729f..935d4b26c98c9 100644 --- a/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.tsx +++ b/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.tsx @@ -26,11 +26,14 @@ import { i18n } from '@kbn/i18n'; import { MarkdownSimple, toMountPoint } from '../../../../../kibana_react/public'; import { migrateLegacyQuery } from '../../../../../kibana_legacy/public'; import { esFilters, connectToQueryState } from '../../../../../data/public'; -import { VisualizeServices, VisualizeAppStateContainer, SavedVisInstance } from '../../types'; +import { + VisualizeServices, + VisualizeAppStateContainer, + VisualizeEditorVisInstance, +} from '../../types'; import { visStateToEditorState } from '../utils'; import { createVisualizeAppState } from '../create_visualize_app_state'; import { VisualizeConstants } from '../../visualize_constants'; - /** * This effect is responsible for instantiating the visualize app state container, * which is in sync with "_a" url param @@ -38,7 +41,7 @@ import { VisualizeConstants } from '../../visualize_constants'; export const useVisualizeAppState = ( services: VisualizeServices, eventEmitter: EventEmitter, - instance?: SavedVisInstance + instance?: VisualizeEditorVisInstance ) => { const [hasUnappliedChanges, setHasUnappliedChanges] = useState(false); const [appState, setAppState] = useState(null); @@ -46,10 +49,11 @@ export const useVisualizeAppState = ( useEffect(() => { if (instance) { const stateDefaults = visStateToEditorState(instance, services); - + const byValue = !('savedVis' in instance); const { stateContainer, stopStateSync } = createVisualizeAppState({ stateDefaults, kbnUrlStateStorage: services.kbnUrlStateStorage, + byValue, }); const onDirtyStateChange = ({ isDirty }: { isDirty: boolean }) => { diff --git a/src/plugins/visualize/public/application/utils/utils.ts b/src/plugins/visualize/public/application/utils/utils.ts index 532d87985a0b6..3d8d443d714a5 100644 --- a/src/plugins/visualize/public/application/utils/utils.ts +++ b/src/plugins/visualize/public/application/utils/utils.ts @@ -21,7 +21,7 @@ import { i18n } from '@kbn/i18n'; import { ChromeStart, DocLinksStart } from 'kibana/public'; import { Filter } from '../../../../data/public'; -import { VisualizeServices, SavedVisInstance } from '../types'; +import { VisualizeServices, VisualizeEditorVisInstance } from '../types'; export const addHelpMenuToAppChrome = (chrome: ChromeStart, docLinks: DocLinksStart) => { chrome.setHelpExtension({ @@ -54,15 +54,18 @@ export const getDefaultQuery = ({ data }: VisualizeServices) => { }; export const visStateToEditorState = ( - { vis, savedVis }: SavedVisInstance, + visInstance: VisualizeEditorVisInstance, services: VisualizeServices ) => { + const vis = visInstance.vis; const savedVisState = services.visualizations.convertFromSerializedVis(vis.serialize()); + const savedVis = 'savedVis' in visInstance ? visInstance.savedVis : undefined; return { - uiState: savedVis.uiStateJSON ? JSON.parse(savedVis.uiStateJSON) : vis.uiState.toJSON(), + uiState: + savedVis && savedVis.uiStateJSON ? JSON.parse(savedVis.uiStateJSON) : vis.uiState.toJSON(), query: vis.data.searchSource?.getOwnField('query') || getDefaultQuery(services), filters: (vis.data.searchSource?.getOwnField('filter') as Filter[]) || [], vis: { ...savedVisState.visState, title: vis.title }, - linked: !!savedVis.savedSearchId, + linked: savedVis && savedVis.id ? !!savedVis.savedSearchId : !!savedVisState.savedSearchId, }; }; diff --git a/src/plugins/visualize/public/application/visualize_constants.ts b/src/plugins/visualize/public/application/visualize_constants.ts index adcf27f17dc25..1950fff2733d4 100644 --- a/src/plugins/visualize/public/application/visualize_constants.ts +++ b/src/plugins/visualize/public/application/visualize_constants.ts @@ -25,4 +25,5 @@ export const VisualizeConstants = { WIZARD_STEP_2_PAGE_PATH: '/new/configure', CREATE_PATH: '/create', EDIT_PATH: '/edit', + EDIT_BY_VALUE_PATH: '/edit_by_value', }; diff --git a/test/functional/apps/context/_context_navigation.js b/test/functional/apps/context/_context_navigation.js index babefe488d7bc..c5af2fcb79296 100644 --- a/test/functional/apps/context/_context_navigation.js +++ b/test/functional/apps/context/_context_navigation.js @@ -18,11 +18,11 @@ */ const TEST_FILTER_COLUMN_NAMES = [ - ['extension', 'jpg'], [ 'agent', 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/534.24 (KHTML, like Gecko) Chrome/11.0.696.50 Safari/534.24', ], + ['extension', 'jpg'], ]; export default function ({ getService, getPageObjects }) { diff --git a/test/functional/apps/visualize/_tsvb_chart.ts b/test/functional/apps/visualize/_tsvb_chart.ts index 18d6e93090e8b..74d5798d127c3 100644 --- a/test/functional/apps/visualize/_tsvb_chart.ts +++ b/test/functional/apps/visualize/_tsvb_chart.ts @@ -47,7 +47,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualBuilder.checkVisualBuilderIsPresent(); }); - describe('metric', () => { + // FLAKY: https://github.com/elastic/kibana/issues/75127 + describe.skip('metric', () => { beforeEach(async () => { await PageObjects.visualBuilder.resetPage(); await PageObjects.visualBuilder.clickMetric(); diff --git a/test/functional/apps/visualize/input_control_vis/chained_controls.js b/test/functional/apps/visualize/input_control_vis/chained_controls.js index e1a58e1da34f3..035245b50d436 100644 --- a/test/functional/apps/visualize/input_control_vis/chained_controls.js +++ b/test/functional/apps/visualize/input_control_vis/chained_controls.js @@ -26,7 +26,8 @@ export default function ({ getService, getPageObjects }) { const find = getService('find'); const comboBox = getService('comboBox'); - describe('chained controls', function () { + // FLAKY: https://github.com/elastic/kibana/issues/68472 + describe.skip('chained controls', function () { this.tags('includeFirefox'); before(async () => { diff --git a/test/functional/services/listing_table.ts b/test/functional/services/listing_table.ts index fa42eb60fa410..3f9775d7a75f3 100644 --- a/test/functional/services/listing_table.ts +++ b/test/functional/services/listing_table.ts @@ -182,7 +182,7 @@ export function ListingTableProvider({ getService, getPageObjects }: FtrProvider await retry.tryForTime(20000, async () => { // newItemButton button is only visible when there are items in the listing table is displayed. const isnNewItemButtonPresent = await testSubjects.exists('newItemButton', { - timeout: 5000, + timeout: 10000, }); if (isnNewItemButtonPresent) { await testSubjects.click('newItemButton'); diff --git a/test/plugin_functional/config.ts b/test/plugin_functional/config.ts index c611300eade10..9ec82e291e537 100644 --- a/test/plugin_functional/config.ts +++ b/test/plugin_functional/config.ts @@ -31,6 +31,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { testFiles: [ + require.resolve('./test_suites/core'), require.resolve('./test_suites/custom_visualizations'), require.resolve('./test_suites/panel_actions'), require.resolve('./test_suites/core_plugins'), diff --git a/test/plugin_functional/plugins/core_plugin_route_timeouts/kibana.json b/test/plugin_functional/plugins/core_plugin_route_timeouts/kibana.json new file mode 100644 index 0000000000000..6fbddad22b764 --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_route_timeouts/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "core_plugin_route_timeouts", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["core_plugin_route_timeouts"], + "server": true, + "ui": false +} diff --git a/test/plugin_functional/plugins/core_plugin_route_timeouts/package.json b/test/plugin_functional/plugins/core_plugin_route_timeouts/package.json new file mode 100644 index 0000000000000..a9c520338457b --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_route_timeouts/package.json @@ -0,0 +1,17 @@ +{ + "name": "core_plugin_route_timeouts", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/core_plugin_route_timeouts", + "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.9.5" + } +} diff --git a/test/plugin_functional/plugins/core_plugin_route_timeouts/server/index.ts b/test/plugin_functional/plugins/core_plugin_route_timeouts/server/index.ts new file mode 100644 index 0000000000000..4fdd6ef0ab04a --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_route_timeouts/server/index.ts @@ -0,0 +1,23 @@ +/* + * 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 { CorePluginRouteTimeoutsPlugin } from './plugin'; +export { PluginARequestContext } from './plugin'; + +export const plugin = () => new CorePluginRouteTimeoutsPlugin(); diff --git a/test/plugin_functional/plugins/core_plugin_route_timeouts/server/plugin.ts b/test/plugin_functional/plugins/core_plugin_route_timeouts/server/plugin.ts new file mode 100644 index 0000000000000..c5bdf6cce084c --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_route_timeouts/server/plugin.ts @@ -0,0 +1,124 @@ +/* + * 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 } from 'kibana/server'; +import { schema } from '@kbn/config-schema'; + +export interface PluginARequestContext { + ping: () => Promise; +} + +declare module 'kibana/server' { + interface RequestHandlerContext { + pluginA?: PluginARequestContext; + } +} + +export class CorePluginRouteTimeoutsPlugin implements Plugin { + public setup(core: CoreSetup, deps: {}) { + const { http } = core; + + const router = http.createRouter(); + + router.post( + { + options: { + body: { + accepts: ['application/json'], + }, + timeout: { payload: 100 }, + }, + path: '/short_payload_timeout', + validate: false, + }, + async (context, req, res) => { + return res.ok({}); + } + ); + + router.post( + { + options: { + body: { + accepts: ['application/json'], + }, + timeout: { payload: 10000 }, + }, + path: '/longer_payload_timeout', + validate: false, + }, + async (context, req, res) => { + return res.ok({}); + } + ); + + router.post( + { + options: { + body: { + accepts: ['application/json'], + }, + timeout: { idleSocket: 10 }, + }, + path: '/short_idle_socket_timeout', + validate: { + body: schema.maybe( + schema.object({ + responseDelay: schema.maybe(schema.number()), + }) + ), + }, + }, + async (context, req, res) => { + if (req.body?.responseDelay) { + await new Promise((resolve) => setTimeout(resolve, req.body!.responseDelay)); + } + return res.ok({}); + } + ); + + router.post( + { + options: { + body: { + accepts: ['application/json'], + }, + timeout: { idleSocket: 5000 }, + }, + path: '/longer_idle_socket_timeout', + validate: { + body: schema.maybe( + schema.object({ + responseDelay: schema.maybe(schema.number()), + }) + ), + }, + }, + async (context, req, res) => { + if (req.body?.responseDelay) { + await new Promise((resolve) => setTimeout(resolve, req.body!.responseDelay)); + } + return res.ok({}); + } + ); + } + + public start() {} + public stop() {} +} diff --git a/test/plugin_functional/plugins/core_plugin_route_timeouts/tsconfig.json b/test/plugin_functional/plugins/core_plugin_route_timeouts/tsconfig.json new file mode 100644 index 0000000000000..d0751f31ecc5e --- /dev/null +++ b/test/plugin_functional/plugins/core_plugin_route_timeouts/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "server/**/*.ts", + "../../../../typings/**/*" + ], + "exclude": [] +} diff --git a/test/plugin_functional/test_suites/core/index.ts b/test/plugin_functional/test_suites/core/index.ts new file mode 100644 index 0000000000000..5852e21fc3b39 --- /dev/null +++ b/test/plugin_functional/test_suites/core/index.ts @@ -0,0 +1,25 @@ +/* + * 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 { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ loadTestFile }: PluginFunctionalProviderContext) { + describe('core', function () { + loadTestFile(require.resolve('./route')); + }); +} diff --git a/test/plugin_functional/test_suites/core/route.ts b/test/plugin_functional/test_suites/core/route.ts new file mode 100644 index 0000000000000..becde49a69714 --- /dev/null +++ b/test/plugin_functional/test_suites/core/route.ts @@ -0,0 +1,174 @@ +/* + * 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 { Test } from 'supertest'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + + describe('route', function () { + describe('timeouts', function () { + const writeBodyCharAtATime = (request: Test, body: string, interval: number) => { + return new Promise((resolve, reject) => { + let i = 0; + const intervalId = setInterval(() => { + if (i < body.length) { + request.write(body[i++]); + } else { + clearInterval(intervalId); + request.end((err, res) => { + resolve(res); + }); + } + }, interval); + request.on('error', (err) => { + clearInterval(intervalId); + reject(err); + }); + }); + }; + + describe('payload', function () { + it(`should timeout if POST payload sending is too slow`, async () => { + // start the request + const request = supertest + .post('/short_payload_timeout') + .set('Content-Type', 'application/json') + .set('Transfer-Encoding', 'chunked') + .set('kbn-xsrf', 'true'); + + const result = writeBodyCharAtATime(request, '{"foo":"bar"}', 10); + + await result.then( + (res) => { + expect(res).to.be(undefined); + }, + (err) => { + expect(err.message).to.be('Request Timeout'); + } + ); + }); + + it(`should not timeout if POST payload sending is quick`, async () => { + // start the request + const request = supertest + .post('/longer_payload_timeout') + .set('Content-Type', 'application/json') + .set('Transfer-Encoding', 'chunked') + .set('kbn-xsrf', 'true'); + + const result = writeBodyCharAtATime(request, '{"foo":"bar"}', 10); + + await result.then( + (res) => { + expect(res).to.have.property('statusCode', 200); + }, + (err) => { + expect(err).to.be(undefined); + } + ); + }); + }); + + describe('idle socket', function () { + it('should timeout if payload sending has too long of an idle period', async function () { + // start the request + const request = supertest + .post('/short_idle_socket_timeout') + .set('Content-Type', 'application/json') + .set('Transfer-Encoding', 'chunked') + .set('kbn-xsrf', 'true'); + + const result = writeBodyCharAtATime(request, '{"responseDelay":100}', 20); + + await result.then( + (res) => { + expect(res).to.be(undefined); + }, + (err) => { + expect(err.message).to.be('socket hang up'); + } + ); + }); + + it('should not timeout if payload sending does not have too long of an idle period', async function () { + // start the request + const request = supertest + .post('/longer_idle_socket_timeout') + .set('Content-Type', 'application/json') + .set('Transfer-Encoding', 'chunked') + .set('kbn-xsrf', 'true'); + + const result = writeBodyCharAtATime(request, '{"responseDelay":0}', 10); + + await result.then( + (res) => { + expect(res).to.have.property('statusCode', 200); + }, + (err) => { + expect(err).to.be(undefined); + } + ); + }); + + it('should timeout if servers response is too slow', async function () { + // start the request + const request = supertest + .post('/short_idle_socket_timeout') + .set('Content-Type', 'application/json') + .set('Transfer-Encoding', 'chunked') + .set('kbn-xsrf', 'true'); + + const result = writeBodyCharAtATime(request, '{"responseDelay":100}', 0); + + await result.then( + (res) => { + expect(res).to.be(undefined); + }, + (err) => { + expect(err.message).to.be('socket hang up'); + } + ); + }); + + it('should not timeout if servers response is fast enough', async function () { + // start the request + const request = supertest + .post('/longer_idle_socket_timeout') + .set('Content-Type', 'application/json') + .set('Transfer-Encoding', 'chunked') + .set('kbn-xsrf', 'true'); + + const result = writeBodyCharAtATime(request, '{"responseDelay":100}', 0); + + await result.then( + (res) => { + expect(res).to.have.property('statusCode', 200); + }, + (err) => { + expect(err).to.be(undefined); + } + ); + }); + }); + }); + }); +} diff --git a/vars/prChanges.groovy b/vars/prChanges.groovy index a7fe46e7bf014..5bdd62946cafc 100644 --- a/vars/prChanges.groovy +++ b/vars/prChanges.groovy @@ -11,6 +11,7 @@ def getSkippablePaths() { /^.ci\/.+\.yml$/, /^.ci\/es-snapshots\//, /^.ci\/pipeline-library\//, + /^.ci\/Jenkinsfile_[^\/]+$/, /^\.github\//, /\.md$/, ] diff --git a/x-pack/examples/ui_actions_enhanced_examples/kibana.json b/x-pack/examples/ui_actions_enhanced_examples/kibana.json index 160352a9afd66..1bae09b488a2e 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/kibana.json +++ b/x-pack/examples/ui_actions_enhanced_examples/kibana.json @@ -5,7 +5,7 @@ "configPath": ["ui_actions_enhanced_examples"], "server": false, "ui": true, - "requiredPlugins": ["uiActionsEnhanced", "data", "discover"], + "requiredPlugins": ["uiActions","uiActionsEnhanced", "data", "discover"], "optionalPlugins": [], "requiredBundles": [ "kibanaUtils", diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx index 2598d66c4976f..fd782f5468c85 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx @@ -10,6 +10,10 @@ import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/publ import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public'; import { ChartActionContext } from '../../../../../src/plugins/embeddable/public'; import { CollectConfigProps } from '../../../../../src/plugins/kibana_utils/public'; +import { + SELECT_RANGE_TRIGGER, + VALUE_CLICK_TRIGGER, +} from '../../../../../src/plugins/ui_actions/public'; export type ActionContext = ChartActionContext; @@ -19,7 +23,8 @@ export interface Config { const SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN = 'SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN'; -export class DashboardHelloWorldDrilldown implements Drilldown { +export class DashboardHelloWorldDrilldown + implements Drilldown { public readonly id = SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN; public readonly order = 6; @@ -28,9 +33,14 @@ export class DashboardHelloWorldDrilldown implements Drilldown { + return [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER]; + } + private readonly ReactCollectConfig: React.FC> = ({ config, onConfig, + context, }) => ( { + public readonly id = SAMPLE_DASHBOARD_HELLO_WORLD_DRILLDOWN_ONLY_RANGE_SELECT; + + public readonly order = 7; + + public readonly getDisplayName = () => 'Say hello only for range select'; + + public readonly euiIcon = 'cheer'; + + supportedTriggers(): Array { + return [SELECT_RANGE_TRIGGER]; + } + + private readonly ReactCollectConfig: React.FC> = ({ + config, + onConfig, + }) => ( + + onConfig({ ...config, name: event.target.value })} + /> + + ); + + public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); + + public readonly createConfig = () => ({ + name: '', + }); + + public readonly isConfigValid = ( + config: Config, + context: BaseActionFactoryContext + ): config is Config => { + // eslint-disable-next-line no-console + console.log('Showcasing, that can access action factory context:', context); + + return !!config.name; + }; + + /** + * Showcase isCompatible. Disabled drilldown action in case if range.length === 0 + */ + isCompatible(config: Config, context: RangeSelectContext): Promise { + if (context.data.range.length === 0) return Promise.resolve(false); + return Promise.resolve(true); + } + + public readonly execute = async (config: Config, context: RangeSelectContext) => { + alert(`Hello, ${config.name}, your selected range: ${JSON.stringify(context.data.range)}`); + }; +} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx index ba88f49861ffe..ba8d7f395e738 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/drilldown.tsx @@ -13,6 +13,7 @@ import { CollectConfigContainer } from './collect_config_container'; import { SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN } from './constants'; import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public'; import { txtGoToDiscover } from './i18n'; +import { APPLY_FILTER_TRIGGER } from '../../../../../src/plugins/ui_actions/public'; const isOutputWithIndexPatterns = ( output: unknown @@ -25,7 +26,8 @@ export interface Params { start: StartServicesGetter>; } -export class DashboardToDiscoverDrilldown implements Drilldown { +export class DashboardToDiscoverDrilldown + implements Drilldown { constructor(protected readonly params: Params) {} public readonly id = SAMPLE_DASHBOARD_TO_DISCOVER_DRILLDOWN; @@ -36,6 +38,10 @@ export class DashboardToDiscoverDrilldown implements Drilldown { + return [APPLY_FILTER_TRIGGER]; + } + private readonly ReactCollectConfig: React.FC = (props) => ( ); diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts index d8147827ed473..a10e8ad707e97 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ChartActionContext } from '../../../../../src/plugins/embeddable/public'; import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public'; +import { ApplyGlobalFilterActionContext } from '../../../../../src/plugins/data/public'; -export type ActionContext = ChartActionContext; +export type ActionContext = ApplyGlobalFilterActionContext; export interface Config { /** diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx index 67599687dd881..7d915ea23c66f 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx @@ -5,11 +5,15 @@ */ import React from 'react'; -import { EuiFormRow, EuiSwitch, EuiFieldText, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiFieldText, EuiFormRow, EuiSpacer, EuiSwitch } from '@elastic/eui'; import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public'; import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public'; import { ChartActionContext } from '../../../../../src/plugins/embeddable/public'; import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public'; +import { + SELECT_RANGE_TRIGGER, + VALUE_CLICK_TRIGGER, +} from '../../../../../src/plugins/ui_actions/public'; import { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/public'; function isValidUrl(url: string) { @@ -28,11 +32,13 @@ export interface Config { openInNewTab: boolean; } -export type CollectConfigProps = CollectConfigPropsBase; +type UrlTrigger = typeof VALUE_CLICK_TRIGGER | typeof SELECT_RANGE_TRIGGER; + +export type CollectConfigProps = CollectConfigPropsBase; const SAMPLE_DASHBOARD_TO_URL_DRILLDOWN = 'SAMPLE_DASHBOARD_TO_URL_DRILLDOWN'; -export class DashboardToUrlDrilldown implements Drilldown { +export class DashboardToUrlDrilldown implements Drilldown { public readonly id = SAMPLE_DASHBOARD_TO_URL_DRILLDOWN; public readonly order = 8; @@ -43,7 +49,15 @@ export class DashboardToUrlDrilldown implements Drilldown public readonly euiIcon = 'link'; - private readonly ReactCollectConfig: React.FC = ({ config, onConfig }) => ( + supportedTriggers(): UrlTrigger[] { + return [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER]; + } + + private readonly ReactCollectConfig: React.FC = ({ + config, + onConfig, + context, + }) => ( <>

@@ -79,6 +93,11 @@ export class DashboardToUrlDrilldown implements Drilldown onChange={() => onConfig({ ...config, openInNewTab: !config.openInNewTab })} /> + + + {/* just demo how can access selected triggers*/} +

Will be attached to triggers: {JSON.stringify(context.triggers)}

+
); diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts b/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts index 8034c378cc64f..7f2c9a9b3bbc8 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts @@ -15,6 +15,7 @@ import { DashboardToUrlDrilldown } from './dashboard_to_url_drilldown'; import { DashboardToDiscoverDrilldown } from './dashboard_to_discover_drilldown'; import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public'; import { DiscoverSetup, DiscoverStart } from '../../../../src/plugins/discover/public'; +import { DashboardHelloWorldOnlyRangeSelectDrilldown } from './dashboard_hello_world_only_range_select_drilldown'; export interface SetupDependencies { data: DataPublicPluginSetup; @@ -37,6 +38,7 @@ export class UiActionsEnhancedExamplesPlugin const start = createStartServicesGetter(core.getStartServices); uiActions.registerDrilldown(new DashboardHelloWorldDrilldown()); + uiActions.registerDrilldown(new DashboardHelloWorldOnlyRangeSelectDrilldown()); uiActions.registerDrilldown(new DashboardToUrlDrilldown()); uiActions.registerDrilldown(new DashboardToDiscoverDrilldown({ start })); } diff --git a/x-pack/index.js b/x-pack/index.js index 66fe05e8f035e..b984782df3986 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -7,9 +7,8 @@ import { xpackMain } from './legacy/plugins/xpack_main'; import { monitoring } from './legacy/plugins/monitoring'; import { security } from './legacy/plugins/security'; -import { beats } from './legacy/plugins/beats_management'; import { spaces } from './legacy/plugins/spaces'; module.exports = function (kibana) { - return [xpackMain(kibana), monitoring(kibana), spaces(kibana), security(kibana), beats(kibana)]; + return [xpackMain(kibana), monitoring(kibana), spaces(kibana), security(kibana)]; }; diff --git a/x-pack/legacy/plugins/beats_management/index.ts b/x-pack/legacy/plugins/beats_management/index.ts deleted file mode 100644 index 1f04f342f9ca0..0000000000000 --- a/x-pack/legacy/plugins/beats_management/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import Joi from 'joi'; -import { PLUGIN } from './common/constants'; -import { CONFIG_PREFIX } from './common/constants/plugin'; -import { initServerWithKibana } from './server/kibana.index'; -import { KibanaLegacyServer } from './server/lib/adapters/framework/adapter_types'; - -const DEFAULT_ENROLLMENT_TOKENS_TTL_S = 10 * 60; // 10 minutes - -export const config = Joi.object({ - enabled: Joi.boolean().default(true), - defaultUserRoles: Joi.array().items(Joi.string()).default(['superuser']), - encryptionKey: Joi.string().default('xpack_beats_default_encryptionKey'), - enrollmentTokensTtlInSeconds: Joi.number() - .integer() - .min(1) - .max(10 * 60 * 14) // No more then 2 weeks for security reasons - .default(DEFAULT_ENROLLMENT_TOKENS_TTL_S), -}).default(); - -export function beats(kibana: any) { - return new kibana.Plugin({ - id: PLUGIN.ID, - require: ['kibana', 'elasticsearch', 'xpack_main'], - config: () => config, - configPrefix: CONFIG_PREFIX, - init(server: KibanaLegacyServer) { - initServerWithKibana(server); - }, - }); -} diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/kibana_framework_adapter.ts b/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/kibana_framework_adapter.ts deleted file mode 100644 index 3b29e50e4465b..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/kibana_framework_adapter.ts +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ResponseToolkit } from 'hapi'; -import { PathReporter } from 'io-ts/lib/PathReporter'; -import { get } from 'lodash'; -import { isLeft } from 'fp-ts/lib/Either'; -import { KibanaRequest, LegacyRequest } from '../../../../../../../../src/core/server'; -// @ts-ignore -import { mirrorPluginStatus } from '../../../../../../server/lib/mirror_plugin_status'; -import { - BackendFrameworkAdapter, - FrameworkInfo, - FrameworkRequest, - FrameworkResponse, - FrameworkRouteOptions, - internalAuthData, - internalUser, - KibanaLegacyServer, - KibanaServerRequest, - KibanaUser, - RuntimeFrameworkInfo, - RuntimeKibanaUser, - XpackInfo, -} from './adapter_types'; - -export class KibanaBackendFrameworkAdapter implements BackendFrameworkAdapter { - public readonly internalUser = internalUser; - public info: null | FrameworkInfo = null; - - constructor( - private readonly PLUGIN_ID: string, - private readonly server: KibanaLegacyServer, - private readonly CONFIG_PREFIX?: string - ) { - const xpackMainPlugin = this.server.plugins.xpack_main; - const thisPlugin = this.server.plugins.beats_management; - - mirrorPluginStatus(xpackMainPlugin, thisPlugin); - - xpackMainPlugin.status.on('green', () => { - this.xpackInfoWasUpdatedHandler(xpackMainPlugin.info); - // Register a function that is called whenever the xpack info changes, - // to re-compute the license check results for this plugin - xpackMainPlugin.info - .feature(this.PLUGIN_ID) - .registerLicenseCheckResultsGenerator(this.xpackInfoWasUpdatedHandler); - }); - } - - public on(event: 'xpack.status.green' | 'elasticsearch.status.green', cb: () => void) { - switch (event) { - case 'xpack.status.green': - this.server.plugins.xpack_main.status.on('green', cb); - case 'elasticsearch.status.green': - this.server.plugins.elasticsearch.status.on('green', cb); - } - } - - public getSetting(settingPath: string) { - return this.server.config().get(settingPath); - } - - public log(text: string) { - this.server.log(text); - } - - public registerRoute< - RouteRequest extends FrameworkRequest, - RouteResponse extends FrameworkResponse - >(route: FrameworkRouteOptions) { - this.server.route({ - handler: async (request: KibanaServerRequest, h: ResponseToolkit) => { - // Note, RuntimeKibanaServerRequest is avalaible to validate request, and its type *is* KibanaServerRequest - // but is not used here for perf reasons. It's value here is not high enough... - return await route.handler(await this.wrapRequest(request), h); - }, - method: route.method, - path: route.path, - config: route.config, - }); - } - - private async wrapRequest( - req: KibanaServerRequest - ): Promise> { - const { params, payload, query, headers, info } = req; - - let isAuthenticated = headers.authorization != null; - let user; - if (isAuthenticated) { - user = await this.getUser(req); - if (!user) { - isAuthenticated = false; - } - } - return { - user: - isAuthenticated && user - ? { - kind: 'authenticated', - [internalAuthData]: headers, - ...user, - } - : { - kind: 'unauthenticated', - }, - headers, - info, - params, - payload, - query, - }; - } - - private async getUser(request: KibanaServerRequest): Promise { - const user = this.server.newPlatform.setup.plugins.security?.authc.getCurrentUser( - KibanaRequest.from((request as unknown) as LegacyRequest) - ); - if (!user) { - return null; - } - const assertKibanaUser = RuntimeKibanaUser.decode(user); - if (isLeft(assertKibanaUser)) { - throw new Error( - `Error parsing user info in ${this.PLUGIN_ID}, ${ - PathReporter.report(assertKibanaUser)[0] - }` - ); - } - - return user; - } - - private xpackInfoWasUpdatedHandler = (xpackInfo: XpackInfo) => { - let xpackInfoUnpacked: FrameworkInfo; - - // If, for some reason, we cannot get the license information - // from Elasticsearch, assume worst case and disable - if (!xpackInfo || !xpackInfo.isAvailable()) { - this.info = null; - return; - } - - try { - xpackInfoUnpacked = { - kibana: { - version: get(this.server, 'plugins.kibana.status.plugin.version', 'unknown'), - }, - license: { - type: xpackInfo.license.getType(), - expired: !xpackInfo.license.isActive(), - expiry_date_in_millis: - xpackInfo.license.getExpiryDateInMillis() !== undefined - ? xpackInfo.license.getExpiryDateInMillis() - : -1, - }, - security: { - enabled: !!xpackInfo.feature('security') && xpackInfo.feature('security').isEnabled(), - available: !!xpackInfo.feature('security'), - }, - watcher: { - enabled: !!xpackInfo.feature('watcher') && xpackInfo.feature('watcher').isEnabled(), - available: !!xpackInfo.feature('watcher'), - }, - }; - } catch (e) { - this.server.log(`Error accessing required xPackInfo in ${this.PLUGIN_ID} Kibana adapter`); - throw e; - } - - const assertData = RuntimeFrameworkInfo.decode(xpackInfoUnpacked); - if (isLeft(assertData)) { - throw new Error( - `Error parsing xpack info in ${this.PLUGIN_ID}, ${PathReporter.report(assertData)[0]}` - ); - } - this.info = xpackInfoUnpacked; - - return { - security: xpackInfoUnpacked.security, - settings: this.getSetting(this.CONFIG_PREFIX || this.PLUGIN_ID), - }; - }; -} diff --git a/x-pack/legacy/plugins/beats_management/server/lib/framework.ts b/x-pack/legacy/plugins/beats_management/server/lib/framework.ts deleted file mode 100644 index 96a06929073e5..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/lib/framework.ts +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ResponseObject, ResponseToolkit } from 'hapi'; -import { difference } from 'lodash'; -import { BaseReturnType } from '../../common/return_types'; -import { - BackendFrameworkAdapter, - FrameworkRequest, - FrameworkResponse, -} from './adapters/framework/adapter_types'; - -export class BackendFrameworkLib { - public log = this.adapter.log; - public on = this.adapter.on.bind(this.adapter); - public internalUser = this.adapter.internalUser; - constructor(private readonly adapter: BackendFrameworkAdapter) { - this.validateConfig(); - } - - public registerRoute< - RouteRequest extends FrameworkRequest, - RouteResponse extends FrameworkResponse - >(route: { - path: string; - method: string | string[]; - licenseRequired?: string[]; - requiredRoles?: string[]; - handler: (request: FrameworkRequest) => Promise; - config?: {}; - }) { - this.adapter.registerRoute({ - ...route, - handler: this.wrapErrors( - this.wrapRouteWithSecurity(route.handler, route.licenseRequired || [], route.requiredRoles) - ), - }); - } - - public getSetting(setting: 'encryptionKey'): string; - public getSetting(setting: 'enrollmentTokensTtlInSeconds'): number; - public getSetting(setting: 'defaultUserRoles'): string[]; - public getSetting( - setting: 'encryptionKey' | 'enrollmentTokensTtlInSeconds' | 'defaultUserRoles' - ) { - return this.adapter.getSetting(`xpack.beats.${setting}`); - } - - /** - * Expired `null` happens when we have no xpack info - */ - public get license() { - return { - type: this.adapter.info ? this.adapter.info.license.type : 'unknown', - expired: this.adapter.info ? this.adapter.info.license.expired : null, - }; - } - - public get securityIsEnabled() { - return this.adapter.info ? this.adapter.info.security.enabled : false; - } - - private validateConfig() { - const encryptionKey = this.adapter.getSetting('xpack.beats.encryptionKey'); - - if (!encryptionKey) { - this.adapter.log( - 'Using a default encryption key for xpack.beats.encryptionKey. It is recommended that you set xpack.beats.encryptionKey in kibana.yml with a unique token' - ); - } - } - - private wrapRouteWithSecurity( - handler: (request: FrameworkRequest) => Promise, - requiredLicense: string[], - requiredRoles?: string[] - ): (request: FrameworkRequest) => Promise { - return async (request: FrameworkRequest) => { - if ( - requiredLicense.length > 0 && - (this.license.expired || !requiredLicense.includes(this.license.type)) - ) { - return { - error: { - message: `Your ${this.license.type} license does not support this API or is expired. Please upgrade your license.`, - code: 403, - }, - success: false, - }; - } - - if (requiredRoles) { - if (request.user.kind !== 'authenticated') { - return { - error: { - message: `Request must be authenticated`, - code: 403, - }, - success: false, - }; - } - - if ( - request.user.kind === 'authenticated' && - !request.user.roles.includes('superuser') && - difference(requiredRoles, request.user.roles).length !== 0 - ) { - return { - error: { - message: `Request must be authenticated by a user with one of the following user roles: ${requiredRoles.join( - ',' - )}`, - code: 403, - }, - success: false, - }; - } - } - return await handler(request); - }; - } - private wrapErrors( - handler: (request: FrameworkRequest) => Promise - ): (request: FrameworkRequest, h: ResponseToolkit) => Promise { - return async (request: FrameworkRequest, h: ResponseToolkit) => { - try { - const result = await handler(request); - if (!result.error) { - return h.response(result); - } - return h - .response({ - error: result.error, - success: false, - }) - .code(result.error.code || 400); - } catch (err) { - let statusCode = err.statusCode; - - // This is the only known non-status code error in the system, but just in case we have an else - if (!statusCode && (err.message as string).includes('Invalid user type')) { - statusCode = 403; - } else { - statusCode = 500; - } - - if (statusCode === 403) { - return h - .response({ - error: { - message: 'Insufficient user permissions for managing Beats configuration', - code: 403, - }, - success: false, - }) - .code(403); - } - - return h - .response({ - error: { - message: err.message, - code: statusCode, - }, - success: false, - }) - .code(statusCode); - } - }; - } -} diff --git a/x-pack/legacy/plugins/beats_management/server/management_server.ts b/x-pack/legacy/plugins/beats_management/server/management_server.ts deleted file mode 100644 index 1073251949028..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/management_server.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { INDEX_NAMES } from '../common/constants/index_names'; -import { beatsIndexTemplate } from './index_templates'; -import { CMServerLibs } from './lib/types'; -import { createGetBeatConfigurationRoute } from './rest_api/beats/configuration'; -import { createBeatEnrollmentRoute } from './rest_api/beats/enroll'; -import { beatEventsRoute } from './rest_api/beats/events'; -import { createGetBeatRoute } from './rest_api/beats/get'; -import { createListAgentsRoute } from './rest_api/beats/list'; -import { createTagAssignmentsRoute } from './rest_api/beats/tag_assignment'; -import { createTagRemovalsRoute } from './rest_api/beats/tag_removal'; -import { createBeatUpdateRoute } from './rest_api/beats/update'; -import { createDeleteConfidurationsRoute } from './rest_api/configurations/delete'; -import { createGetConfigurationBlocksRoute } from './rest_api/configurations/get'; -import { upsertConfigurationRoute } from './rest_api/configurations/upsert'; -import { createAssignableTagsRoute } from './rest_api/tags/assignable'; -import { createDeleteTagsWithIdsRoute } from './rest_api/tags/delete'; -import { createGetTagsWithIdsRoute } from './rest_api/tags/get'; -import { createListTagsRoute } from './rest_api/tags/list'; -import { createSetTagRoute } from './rest_api/tags/set'; -import { createTokensRoute } from './rest_api/tokens/create'; - -export const initManagementServer = (libs: CMServerLibs) => { - if (libs.database) { - libs.framework.on('elasticsearch.status.green', async () => { - await libs.database!.putTemplate(INDEX_NAMES.BEATS, beatsIndexTemplate); - }); - } - - libs.framework.registerRoute(createGetBeatRoute(libs)); - libs.framework.registerRoute(createGetTagsWithIdsRoute(libs)); - libs.framework.registerRoute(createListTagsRoute(libs)); - libs.framework.registerRoute(createDeleteTagsWithIdsRoute(libs)); - libs.framework.registerRoute(createGetBeatConfigurationRoute(libs)); - libs.framework.registerRoute(createTagAssignmentsRoute(libs)); - libs.framework.registerRoute(createListAgentsRoute(libs)); - libs.framework.registerRoute(createTagRemovalsRoute(libs)); - libs.framework.registerRoute(createBeatEnrollmentRoute(libs)); - libs.framework.registerRoute(createSetTagRoute(libs)); - libs.framework.registerRoute(createTokensRoute(libs)); - libs.framework.registerRoute(createBeatUpdateRoute(libs)); - libs.framework.registerRoute(createDeleteConfidurationsRoute(libs)); - libs.framework.registerRoute(createGetConfigurationBlocksRoute(libs)); - libs.framework.registerRoute(upsertConfigurationRoute(libs)); - libs.framework.registerRoute(createAssignableTagsRoute(libs)); - libs.framework.registerRoute(beatEventsRoute(libs)); -}; diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/beats/configuration.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/beats/configuration.ts deleted file mode 100644 index f279a51b2bc1b..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/beats/configuration.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import Joi from 'joi'; -import { ConfigurationBlock } from '../../../common/domain_types'; -import { BaseReturnType, ReturnTypeList } from '../../../common/return_types'; -import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; -import { CMServerLibs } from '../../lib/types'; - -export const createGetBeatConfigurationRoute = (libs: CMServerLibs) => ({ - method: 'GET', - path: '/api/beats/agent/{beatId}/configuration', - config: { - validate: { - headers: Joi.object({ - 'kbn-beats-access-token': Joi.string().required(), - }).options({ allowUnknown: true }), - }, - auth: false, - }, - handler: async ( - request: FrameworkRequest - ): Promise> => { - const beatId = request.params.beatId; - const accessToken = request.headers['kbn-beats-access-token']; - - let configurationBlocks: ConfigurationBlock[]; - const beat = await libs.beats.getById(libs.framework.internalUser, beatId); - if (beat === null) { - return { error: { message: `Beat "${beatId}" not found`, code: 404 }, success: false }; - } - - const isAccessTokenValid = beat.access_token === accessToken; - if (!isAccessTokenValid) { - return { error: { message: 'Invalid access token', code: 401 }, success: false }; - } - - await libs.beats.update(libs.framework.internalUser, beat.id, { - last_checkin: new Date(), - }); - - if (beat.tags) { - const result = await libs.configurationBlocks.getForTags( - libs.framework.internalUser, - beat.tags, - -1 - ); - - configurationBlocks = result.blocks; - } else { - configurationBlocks = []; - } - - return { - list: configurationBlocks, - success: true, - }; - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/beats/enroll.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/beats/enroll.ts deleted file mode 100644 index 916cfad4102d0..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/beats/enroll.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import Joi from 'joi'; -import { omit } from 'lodash'; -import { REQUIRED_LICENSES } from '../../../common/constants/security'; -import { BaseReturnType, ReturnTypeCreate } from '../../../common/return_types'; -import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; -import { BeatEnrollmentStatus, CMServerLibs } from '../../lib/types'; - -// TODO: write to Kibana audit log file https://github.com/elastic/kibana/issues/26024 -export const createBeatEnrollmentRoute = (libs: CMServerLibs) => ({ - method: 'POST', - path: '/api/beats/agent/{beatId}', - licenseRequired: REQUIRED_LICENSES, - config: { - auth: false, - validate: { - headers: Joi.object({ - 'kbn-beats-enrollment-token': Joi.string().required(), - }).options({ - allowUnknown: true, - }), - payload: Joi.object({ - host_name: Joi.string().required(), - name: Joi.string().required(), - type: Joi.string().required(), - version: Joi.string().required(), - }).required(), - }, - }, - handler: async ( - request: FrameworkRequest - ): Promise> => { - const { beatId } = request.params; - const enrollmentToken = request.headers['kbn-beats-enrollment-token']; - - const { status, accessToken } = await libs.beats.enrollBeat( - enrollmentToken, - beatId, - request.info.remoteAddress, - omit(request.payload, 'enrollment_token') - ); - - switch (status) { - case BeatEnrollmentStatus.ExpiredEnrollmentToken: - return { - error: { message: BeatEnrollmentStatus.ExpiredEnrollmentToken, code: 400 }, - success: false, - }; - - case BeatEnrollmentStatus.InvalidEnrollmentToken: - return { - error: { message: BeatEnrollmentStatus.InvalidEnrollmentToken, code: 400 }, - success: false, - }; - case BeatEnrollmentStatus.Success: - default: - return { - item: accessToken, - action: 'created', - success: true, - }; - } - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/beats/events.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/beats/events.ts deleted file mode 100644 index 65d7e9979b9ca..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/beats/events.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import Joi from 'joi'; -import { BaseReturnType, ReturnTypeBulkAction } from '../../../common/return_types'; -import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; -import { CMServerLibs } from '../../lib/types'; - -export const beatEventsRoute = (libs: CMServerLibs) => ({ - method: 'POST', - path: '/api/beats/{beatId}/events', - config: { - validate: { - headers: Joi.object({ - 'kbn-beats-access-token': Joi.string().required(), - }).options({ allowUnknown: true }), - }, - auth: false, - }, - handler: async (request: FrameworkRequest): Promise => { - const beatId = request.params.beatId; - const events = request.payload; - const accessToken = request.headers['kbn-beats-access-token']; - - const beat = await libs.beats.getById(libs.framework.internalUser, beatId); - if (beat === null) { - return { error: { message: `Beat "${beatId}" not found`, code: 400 }, success: false }; - } - - const isAccessTokenValid = beat.access_token === accessToken; - if (!isAccessTokenValid) { - return { error: { message: `Invalid access token`, code: 401 }, success: false }; - } - - const results = await libs.beatEvents.log(libs.framework.internalUser, beat.id, events); - - return { - results, - success: true, - }; - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/beats/get.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/beats/get.ts deleted file mode 100644 index 874e66bb8a533..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/beats/get.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { CMBeat } from '../../../common/domain_types'; -import { BaseReturnType, ReturnTypeGet } from '../../../common/return_types'; -import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; -import { CMServerLibs } from '../../lib/types'; - -export const createGetBeatRoute = (libs: CMServerLibs) => ({ - method: 'GET', - path: '/api/beats/agent/{beatId}/{token?}', - requiredRoles: ['beats_admin'], - handler: async (request: FrameworkRequest): Promise> => { - const beatId = request.params.beatId; - - let beat: CMBeat | null; - if (beatId === 'unknown') { - beat = await libs.beats.getByEnrollmentToken(request.user, request.params.token); - if (beat === null) { - return { success: false }; - } - } else { - beat = await libs.beats.getById(request.user, beatId); - if (beat === null) { - return { error: { message: 'Beat not found', code: 404 }, success: false }; - } - } - - delete beat.access_token; - - return { - item: beat, - success: true, - }; - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/beats/list.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/beats/list.ts deleted file mode 100644 index 74fb98fc877cc..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/beats/list.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as Joi from 'joi'; -import { REQUIRED_LICENSES } from '../../../common/constants/security'; -import { CMBeat } from '../../../common/domain_types'; -import { ReturnTypeList } from '../../../common/return_types'; -import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; -import { CMServerLibs } from '../../lib/types'; - -export const createListAgentsRoute = (libs: CMServerLibs) => ({ - method: 'GET', - path: '/api/beats/agents/{listByAndValue*}', - requiredRoles: ['beats_admin'], - licenseRequired: REQUIRED_LICENSES, - - validate: { - headers: Joi.object({ - 'kbn-beats-enrollment-token': Joi.string().required(), - }).options({ - allowUnknown: true, - }), - query: Joi.object({ - ESQuery: Joi.string(), - }), - }, - handler: async (request: FrameworkRequest): Promise> => { - const listByAndValueParts = request.params.listByAndValue - ? request.params.listByAndValue.split('/') - : []; - let listBy: 'tag' | null = null; - let listByValue: string | null = null; - - if (listByAndValueParts.length === 2) { - listBy = listByAndValueParts[0]; - listByValue = listByAndValueParts[1]; - } - - let beats: CMBeat[]; - - switch (listBy) { - case 'tag': - beats = await libs.beats.getAllWithTag(request.user, listByValue || ''); - break; - - default: - beats = await libs.beats.getAll( - request.user, - request.query && request.query.ESQuery ? JSON.parse(request.query.ESQuery) : undefined - ); - - break; - } - - return { list: beats, success: true, page: -1, total: -1 }; - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/beats/tag_assignment.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/beats/tag_assignment.ts deleted file mode 100644 index 974b2822fbd92..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/beats/tag_assignment.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Joi from 'joi'; -import { REQUIRED_LICENSES } from '../../../common/constants/security'; -import { ReturnTypeBulkAction } from '../../../common/return_types'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { BeatsTagAssignment } from '../../../../../../plugins/beats_management/public/lib/adapters/beats/adapter_types'; -import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; -import { CMServerLibs } from '../../lib/types'; - -// TODO: write to Kibana audit log file https://github.com/elastic/kibana/issues/26024 -export const createTagAssignmentsRoute = (libs: CMServerLibs) => ({ - method: 'POST', - path: '/api/beats/agents_tags/assignments', - licenseRequired: REQUIRED_LICENSES, - requiredRoles: ['beats_admin'], - config: { - validate: { - payload: Joi.object({ - assignments: Joi.array().items( - Joi.object({ - beatId: Joi.string().required(), - tag: Joi.string().required(), - }) - ), - }).required(), - }, - }, - handler: async (request: FrameworkRequest): Promise => { - const { assignments }: { assignments: BeatsTagAssignment[] } = request.payload; - - const response = await libs.beats.assignTagsToBeats(request.user, assignments); - - return { - success: true, - results: response.assignments.map((assignment) => ({ - success: assignment.status && assignment.status >= 200 && assignment.status < 300, - error: - !assignment.status || assignment.status >= 300 - ? { - code: assignment.status || 400, - message: assignment.result, - } - : undefined, - result: - assignment.status && assignment.status >= 200 && assignment.status < 300 - ? { - message: assignment.result, - } - : undefined, - })), - } as ReturnTypeBulkAction; - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/beats/tag_removal.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/beats/tag_removal.ts deleted file mode 100644 index 3bbc32dc5748b..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/beats/tag_removal.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Joi from 'joi'; -import { REQUIRED_LICENSES } from '../../../common/constants/security'; -import { ReturnTypeBulkAction } from '../../../common/return_types'; -import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; -import { CMServerLibs } from '../../lib/types'; - -// TODO: write to Kibana audit log file https://github.com/elastic/kibana/issues/26024 -export const createTagRemovalsRoute = (libs: CMServerLibs) => ({ - method: 'POST', - path: '/api/beats/agents_tags/removals', - licenseRequired: REQUIRED_LICENSES, - requiredRoles: ['beats_admin'], - config: { - validate: { - payload: Joi.object({ - removals: Joi.array().items( - Joi.object({ - beatId: Joi.string().required(), - tag: Joi.string().required(), - }) - ), - }).required(), - }, - }, - handler: async (request: FrameworkRequest): Promise => { - const { removals } = request.payload; - - const response = await libs.beats.removeTagsFromBeats(request.user, removals); - - return { - success: true, - results: response.removals.map((removal) => ({ - success: removal.status && removal.status >= 200 && removal.status < 300, - error: - !removal.status || removal.status >= 300 - ? { - code: removal.status || 400, - message: removal.result, - } - : undefined, - result: - removal.status && removal.status >= 200 && removal.status < 300 - ? { - message: removal.result, - } - : undefined, - })), - } as ReturnTypeBulkAction; - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/beats/update.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/beats/update.ts deleted file mode 100644 index 2859083e83386..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/beats/update.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Joi from 'joi'; -import { REQUIRED_LICENSES } from '../../../common/constants/security'; -import { CMBeat } from '../../../common/domain_types'; -import { BaseReturnType, ReturnTypeUpdate } from '../../../common/return_types'; -import { FrameworkRequest, internalUser } from '../../lib/adapters/framework/adapter_types'; -import { CMServerLibs } from '../../lib/types'; - -// TODO: write to Kibana audit log file (include who did the verification as well) https://github.com/elastic/kibana/issues/26024 -export const createBeatUpdateRoute = (libs: CMServerLibs) => ({ - method: 'PUT', - path: '/api/beats/agent/{beatId}', - licenseRequired: REQUIRED_LICENSES, - requiredRoles: ['beats_admin'], - config: { - validate: { - headers: Joi.object({ - 'kbn-beats-access-token': Joi.string(), - }).options({ - allowUnknown: true, - }), - params: Joi.object({ - beatId: Joi.string(), - }), - payload: Joi.object({ - active: Joi.bool(), - ephemeral_id: Joi.string(), - host_name: Joi.string(), - local_configuration_yml: Joi.string(), - metadata: Joi.object(), - name: Joi.string(), - type: Joi.string(), - version: Joi.string(), - }), - }, - }, - handler: async ( - request: FrameworkRequest - ): Promise> => { - const { beatId } = request.params; - const accessToken = request.headers['kbn-beats-access-token']; - const remoteAddress = request.info.remoteAddress; - const userOrToken = accessToken || request.user; - - if (request.user.kind === 'unauthenticated' && request.payload.active !== undefined) { - return { - error: { - message: 'access-token is not a valid auth type to change beat status', - code: 401, - }, - success: false, - }; - } - - const status = await libs.beats.update(userOrToken, beatId, { - ...request.payload, - host_ip: remoteAddress, - }); - - switch (status) { - case 'beat-not-found': - return { - error: { - message: 'Beat not found', - code: 404, - }, - success: false, - }; - case 'invalid-access-token': - return { - error: { - message: 'Invalid access token', - code: 401, - }, - success: false, - }; - } - - const beat = await libs.beats.getById(internalUser, beatId); - - if (!beat) { - return { - error: { - message: 'Beat not found', - code: 404, - }, - success: false, - }; - } - - return { - item: beat, - action: 'updated', - success: true, - }; - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/configurations/delete.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/configurations/delete.ts deleted file mode 100644 index b7d430fb18c01..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/configurations/delete.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { REQUIRED_LICENSES } from '../../../common/constants/security'; -import { ReturnTypeBulkDelete } from '../../../common/return_types'; -import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; -import { CMServerLibs } from '../../lib/types'; - -export const createDeleteConfidurationsRoute = (libs: CMServerLibs) => ({ - method: 'DELETE', - path: '/api/beats/configurations/{ids}', - requiredRoles: ['beats_admin'], - licenseRequired: REQUIRED_LICENSES, - handler: async (request: FrameworkRequest): Promise => { - const idString: string = request.params.ids; - const ids = idString.split(',').filter((id: string) => id.length > 0); - - const results = await libs.configurationBlocks.delete(request.user, ids); - - return { - success: true, - results: results.map((result) => ({ - success: result.success, - action: 'deleted', - error: result.success ? undefined : { message: result.reason }, - })), - } as ReturnTypeBulkDelete; - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/configurations/get.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/configurations/get.ts deleted file mode 100644 index df534f74239ed..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/configurations/get.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { REQUIRED_LICENSES } from '../../../common/constants/security'; -import { ConfigurationBlock } from '../../../common/domain_types'; -import { ReturnTypeList } from '../../../common/return_types'; -import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; -import { CMServerLibs } from '../../lib/types'; - -export const createGetConfigurationBlocksRoute = (libs: CMServerLibs) => ({ - method: 'GET', - path: '/api/beats/configurations/{tagIds}/{page?}', - requiredRoles: ['beats_admin'], - licenseRequired: REQUIRED_LICENSES, - handler: async (request: FrameworkRequest): Promise> => { - const tagIdString: string = request.params.tagIds; - const tagIds = tagIdString.split(',').filter((id: string) => id.length > 0); - - const result = await libs.configurationBlocks.getForTags( - request.user, - tagIds, - parseInt(request.params.page, 10), - 5 - ); - - return { page: result.page, total: result.total, list: result.blocks, success: true }; - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/configurations/upsert.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/configurations/upsert.ts deleted file mode 100644 index fb62800594d0a..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/configurations/upsert.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { PathReporter } from 'io-ts/lib/PathReporter'; -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import Joi from 'joi'; -import { isLeft } from 'fp-ts/lib/Either'; -import { REQUIRED_LICENSES } from '../../../common/constants'; -import { - ConfigurationBlock, - createConfigurationBlockInterface, -} from '../../../common/domain_types'; -import { ReturnTypeBulkUpsert } from '../../../common/return_types'; -import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; -import { CMServerLibs } from '../../lib/types'; - -// TODO: write to Kibana audit log file -export const upsertConfigurationRoute = (libs: CMServerLibs) => ({ - method: 'PUT', - path: '/api/beats/configurations', - licenseRequired: REQUIRED_LICENSES, - requiredRoles: ['beats_admin'], - config: { - validate: { - payload: Joi.array().items(Joi.object({}).unknown(true)), - }, - }, - handler: async (request: FrameworkRequest): Promise => { - const result = await Promise.all( - request.payload.map(async (block: ConfigurationBlock) => { - const assertData = createConfigurationBlockInterface().decode(block); - if (isLeft(assertData)) { - return { - error: `Error parsing block info, ${PathReporter.report(assertData)[0]}`, - }; - } - - const { blockID, success, error } = await libs.configurationBlocks.save( - request.user, - block - ); - if (error) { - return { success, error }; - } - - return { success, blockID }; - }) - ); - - return { - results: result.map((r) => ({ - success: r.success as boolean, - // TODO: we need to surface this data, not hard coded - action: 'created' as 'created' | 'updated', - })), - success: true, - }; - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/tags/assignable.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/tags/assignable.ts deleted file mode 100644 index 88a322c03790f..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/tags/assignable.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { flatten } from 'lodash'; -import { REQUIRED_LICENSES } from '../../../common/constants/security'; -import { BeatTag } from '../../../common/domain_types'; -import { ReturnTypeBulkGet } from '../../../common/return_types'; -import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; -import { CMServerLibs } from '../../lib/types'; - -export const createAssignableTagsRoute = (libs: CMServerLibs) => ({ - method: 'GET', - path: '/api/beats/tags/assignable/{beatIds}', - requiredRoles: ['beats_admin'], - licenseRequired: REQUIRED_LICENSES, - handler: async (request: FrameworkRequest): Promise> => { - const beatIdString: string = request.params.beatIds; - const beatIds = beatIdString.split(',').filter((id: string) => id.length > 0); - - const beats = await libs.beats.getByIds(request.user, beatIds); - const tags = await libs.tags.getNonConflictingTags( - request.user, - flatten(beats.map((beat) => beat.tags)) - ); - - return { - items: tags, - success: true, - }; - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/tags/delete.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/tags/delete.ts deleted file mode 100644 index 3e65f271c0fb0..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/tags/delete.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { REQUIRED_LICENSES } from '../../../common/constants/security'; -import { ReturnTypeBulkDelete } from '../../../common/return_types'; -import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; -import { CMServerLibs } from '../../lib/types'; - -export const createDeleteTagsWithIdsRoute = (libs: CMServerLibs) => ({ - method: 'DELETE', - path: '/api/beats/tags/{tagIds}', - requiredRoles: ['beats_admin'], - licenseRequired: REQUIRED_LICENSES, - handler: async (request: FrameworkRequest): Promise => { - const tagIdString: string = request.params.tagIds; - const tagIds = tagIdString.split(',').filter((id: string) => id.length > 0); - - const success = await libs.tags.delete(request.user, tagIds); - - return { - results: tagIds.map(() => ({ - success, - action: 'deleted', - })), - success, - } as ReturnTypeBulkDelete; - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/tags/get.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/tags/get.ts deleted file mode 100644 index 37f8e1169fa6c..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/tags/get.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { REQUIRED_LICENSES } from '../../../common/constants/security'; -import { BeatTag } from '../../../common/domain_types'; -import { ReturnTypeBulkGet } from '../../../common/return_types'; -import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; -import { CMServerLibs } from '../../lib/types'; - -export const createGetTagsWithIdsRoute = (libs: CMServerLibs) => ({ - method: 'GET', - path: '/api/beats/tags/{tagIds}', - requiredRoles: ['beats_admin'], - licenseRequired: REQUIRED_LICENSES, - handler: async (request: FrameworkRequest): Promise> => { - const tagIdString: string = request.params.tagIds; - const tagIds = tagIdString.split(',').filter((id: string) => id.length > 0); - - const tags = await libs.tags.getWithIds(request.user, tagIds); - - return { - items: tags, - success: true, - }; - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/tags/list.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/tags/list.ts deleted file mode 100644 index eb5570273960f..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/tags/list.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as Joi from 'joi'; -import { REQUIRED_LICENSES } from '../../../common/constants/security'; -import { BeatTag } from '../../../common/domain_types'; -import { ReturnTypeList } from '../../../common/return_types'; -import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; -import { CMServerLibs } from '../../lib/types'; - -export const createListTagsRoute = (libs: CMServerLibs) => ({ - method: 'GET', - path: '/api/beats/tags', - requiredRoles: ['beats_admin'], - licenseRequired: REQUIRED_LICENSES, - validate: { - headers: Joi.object({ - 'kbn-beats-enrollment-token': Joi.string().required(), - }).options({ - allowUnknown: true, - }), - query: Joi.object({ - ESQuery: Joi.string(), - }), - }, - handler: async (request: FrameworkRequest): Promise> => { - const tags = await libs.tags.getAll( - request.user, - request.query && request.query.ESQuery ? JSON.parse(request.query.ESQuery) : undefined - ); - - return { list: tags, success: true, page: -1, total: -1 }; - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/tags/set.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/tags/set.ts deleted file mode 100644 index f2c4b17007f10..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/tags/set.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Joi from 'joi'; -import { get } from 'lodash'; -import { REQUIRED_LICENSES } from '../../../common/constants'; -import { BeatTag } from '../../../common/domain_types'; -import { ReturnTypeUpsert } from '../../../common/return_types'; -import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; -import { CMServerLibs } from '../../lib/types'; - -// TODO: write to Kibana audit log file -export const createSetTagRoute = (libs: CMServerLibs) => ({ - method: 'PUT', - path: '/api/beats/tag/{tagId}', - licenseRequired: REQUIRED_LICENSES, - requiredRoles: ['beats_admin'], - config: { - validate: { - params: Joi.object({ - tagId: Joi.string(), - }), - payload: Joi.object({ - color: Joi.string(), - name: Joi.string(), - }), - }, - }, - handler: async (request: FrameworkRequest): Promise> => { - const defaultConfig = { - id: request.params.tagId, - name: request.params.tagId, - color: '#DD0A73', - hasConfigurationBlocksTypes: [], - }; - const config = { ...defaultConfig, ...get(request, 'payload', {}) }; - - const id = await libs.tags.upsertTag(request.user, config); - const tag = await libs.tags.getWithIds(request.user, [id]); - - // TODO the action needs to be surfaced - return { success: true, item: tag[0], action: 'created' }; - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/server/rest_api/tokens/create.ts b/x-pack/legacy/plugins/beats_management/server/rest_api/tokens/create.ts deleted file mode 100644 index 571d2b4a4947c..0000000000000 --- a/x-pack/legacy/plugins/beats_management/server/rest_api/tokens/create.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Joi from 'joi'; -import { get } from 'lodash'; -import { REQUIRED_LICENSES } from '../../../common/constants/security'; -import { BaseReturnType, ReturnTypeBulkCreate } from '../../../common/return_types'; -import { FrameworkRequest } from '../../lib/adapters/framework/adapter_types'; -import { CMServerLibs } from '../../lib/types'; - -// TODO: write to Kibana audit log file -const DEFAULT_NUM_TOKENS = 1; -export const createTokensRoute = (libs: CMServerLibs) => ({ - method: 'POST', - path: '/api/beats/enrollment_tokens', - licenseRequired: REQUIRED_LICENSES, - requiredRoles: ['beats_admin'], - config: { - validate: { - payload: Joi.object({ - num_tokens: Joi.number().optional().default(DEFAULT_NUM_TOKENS).min(1), - }).allow(null), - }, - }, - handler: async ( - request: FrameworkRequest - ): Promise> => { - const numTokens = get(request, 'payload.num_tokens', DEFAULT_NUM_TOKENS); - - try { - const tokens = await libs.tokens.createEnrollmentTokens(request.user, numTokens); - return { - results: tokens.map((token) => ({ - item: token, - success: true, - action: 'created', - })), - success: true, - }; - } catch (err) { - libs.framework.log(err.message); - return { - error: { - message: 'An error occured, please check your Kibana logs', - code: 500, - }, - success: false, - }; - } - }, -}); diff --git a/x-pack/legacy/plugins/beats_management/tsconfig.json b/x-pack/legacy/plugins/beats_management/tsconfig.json deleted file mode 100644 index 7ade047bad32e..0000000000000 --- a/x-pack/legacy/plugins/beats_management/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../../tsconfig.json", - "exclude": ["**/node_modules/**"], - "paths": { - "react": ["../../../node_modules/@types/react"] - } -} diff --git a/x-pack/package.json b/x-pack/package.json index 57a0b88f8c2a5..5333c67f6ac0f 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -83,7 +83,7 @@ "@types/jsdom": "^16.2.3", "@types/json-stable-stringify": "^1.0.32", "@types/jsonwebtoken": "^7.2.8", - "@types/lodash": "^4.14.155", + "@types/lodash": "^4.14.159", "@types/mapbox-gl": "^1.9.1", "@types/memoize-one": "^4.1.0", "@types/mime": "^2.0.1", diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx index 20f6f92f9995f..55a0bddcc7818 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx @@ -27,16 +27,18 @@ storiesOf('app/ServiceMap/Popover', module) avgCpuUsage: 0.32809666568309237, avgErrorRate: 0.556068173242986, avgMemoryUsage: 0.5504868173242986, - avgRequestsPerMinute: 164.47222031860858, - avgTransactionDuration: 61634.38905590272, + transactionStats: { + avgRequestsPerMinute: 164.47222031860858, + avgTransactionDuration: 61634.38905590272, + }, }), } as unknown) as HttpSetup; createCallApmApi(httpMock); - setImmediate(() => { - cy.$('example service').select(); - }); + setTimeout(() => { + cy.$id('example service').select(); + }, 0); return ( @@ -59,9 +61,10 @@ storiesOf('app/ServiceMap/Popover', module) info: { propTablesExclude: [ CytoscapeContext.Provider, + EuiThemeProvider, MockApmPluginContextWrapper, MockUrlParamsContextProvider, - EuiThemeProvider, + Popover, ], source: false, }, diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx index aee392b53298a..2a7d11bb57ca5 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/Cytoscape.stories.tsx @@ -288,43 +288,51 @@ storiesOf('app/ServiceMap/Cytoscape', module).add( } ); -storiesOf('app/ServiceMap/Cytoscape', module).add( - 'node severity', - () => { - const elements = [ - { data: { id: 'undefined', 'service.name': 'severity: undefined' } }, - { - data: { - id: 'warning', - 'service.name': 'severity: warning', - severity: 'warning', +storiesOf('app/ServiceMap/Cytoscape', module) + .addDecorator((storyFn) => {storyFn()}) + .add( + 'node severity', + () => { + const elements = [ + { + data: { + id: 'undefined', + 'service.name': 'severity: undefined', + serviceAnomalyStats: { anomalyScore: undefined }, + }, }, - }, - { - data: { - id: 'minor', - 'service.name': 'severity: minor', - severity: 'minor', + { + data: { + id: 'warning', + 'service.name': 'severity: warning', + serviceAnomalyStats: { anomalyScore: 0 }, + }, }, - }, - { - data: { - id: 'major', - 'service.name': 'severity: major', - severity: 'major', + { + data: { + id: 'minor', + 'service.name': 'severity: minor', + serviceAnomalyStats: { anomalyScore: 40 }, + }, }, - }, - { - data: { - id: 'critical', - 'service.name': 'severity: critical', - severity: 'critical', + { + data: { + id: 'major', + 'service.name': 'severity: major', + serviceAnomalyStats: { anomalyScore: 60 }, + }, }, - }, - ]; - return ; - }, - { - info: { propTables: false, source: false }, - } -); + { + data: { + id: 'critical', + 'service.name': 'severity: critical', + serviceAnomalyStats: { anomalyScore: 80 }, + }, + }, + ]; + return ; + }, + { + info: { propTables: false, source: false }, + } + ); diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx index 920ef39e84ca3..db3f2c374a1ae 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx @@ -69,6 +69,11 @@ storiesOf( }, { info: { + propTablesExclude: [ + AgentConfigurationCreateEdit, + ApmPluginContext.Provider, + EuiThemeProvider, + ], source: false, }, } diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx index 31d0d5891fca4..a5393995f0864 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.stories.tsx @@ -4,31 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ +import { boolean, withKnobs } from '@storybook/addon-knobs'; import { storiesOf } from '@storybook/react'; import React from 'react'; import { SyncBadge } from './SyncBadge'; storiesOf('app/TransactionDetails/SyncBadge', module) + .addDecorator(withKnobs) .add( - 'sync=true', + 'example', () => { - return ; + return ; }, { - info: { - source: false, - }, - } - ) - .add( - 'sync=false', - () => { - return ; - }, - { - info: { - source: false, - }, + showPanel: true, + info: { source: false }, } ) .add( @@ -36,9 +26,5 @@ storiesOf('app/TransactionDetails/SyncBadge', module) () => { return ; }, - { - info: { - source: false, - }, - } + { info: { source: false } } ); diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx index 69cb091e76880..8e3d0effb97a6 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/WaterfallWithSummmary/WaterfallContainer/WaterfallContainer.stories.tsx @@ -18,79 +18,88 @@ import { inferredSpans, } from './waterfallContainer.stories.data'; import { getWaterfall } from './Waterfall/waterfall_helpers/waterfall_helpers'; +import { EuiThemeProvider } from '../../../../../../../observability/public'; -storiesOf('app/TransactionDetails/Waterfall', module).add( - 'simple', - () => { - const waterfall = getWaterfall( - simpleTrace as TraceAPIResponse, - '975c8d5bfd1dd20b' - ); - return ( - - ); - }, - { info: { source: false } } -); +storiesOf('app/TransactionDetails/Waterfall', module) + .addDecorator((storyFn) => {storyFn()}) + .add( + 'example', + () => { + const waterfall = getWaterfall( + simpleTrace as TraceAPIResponse, + '975c8d5bfd1dd20b' + ); + return ( + + ); + }, + { info: { propTablesExclude: [EuiThemeProvider], source: false } } + ); -storiesOf('app/TransactionDetails/Waterfall', module).add( - 'with errors', - () => { - const waterfall = getWaterfall( - (traceWithErrors as unknown) as TraceAPIResponse, - '975c8d5bfd1dd20b' - ); - return ( - - ); - }, - { info: { source: false } } -); +storiesOf('app/TransactionDetails/Waterfall', module) + .addDecorator((storyFn) => {storyFn()}) + .add( + 'with errors', + () => { + const waterfall = getWaterfall( + (traceWithErrors as unknown) as TraceAPIResponse, + '975c8d5bfd1dd20b' + ); + return ( + + ); + }, + { info: { propTablesExclude: [EuiThemeProvider], source: false } } + ); -storiesOf('app/TransactionDetails/Waterfall', module).add( - 'child starts before parent', - () => { - const waterfall = getWaterfall( - traceChildStartBeforeParent as TraceAPIResponse, - '975c8d5bfd1dd20b' - ); - return ( - - ); - }, - { info: { source: false } } -); +storiesOf('app/TransactionDetails/Waterfall', module) + .addDecorator((storyFn) => {storyFn()}) + .add( + 'child starts before parent', + () => { + const waterfall = getWaterfall( + traceChildStartBeforeParent as TraceAPIResponse, + '975c8d5bfd1dd20b' + ); + return ( + + ); + }, + { info: { propTablesExclude: [EuiThemeProvider], source: false } } + ); -storiesOf('app/TransactionDetails/Waterfall', module).add( - 'inferred spans', - () => { - const waterfall = getWaterfall( - inferredSpans as TraceAPIResponse, - 'f2387d37260d00bd' - ); - return ( - - ); - }, - { info: { source: false } } -); +storiesOf('app/TransactionDetails/Waterfall', module) + .addDecorator((storyFn) => {storyFn()}) + .add( + 'inferred spans', + () => { + const waterfall = getWaterfall( + inferredSpans as TraceAPIResponse, + 'f2387d37260d00bd' + ); + return ( + + ); + }, + { info: { propTablesExclude: [EuiThemeProvider], source: false } } + ); diff --git a/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx b/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx index ebcb1627984ad..632d53a9c63b6 100644 --- a/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/ErrorRateAlertTrigger/index.stories.tsx @@ -13,23 +13,32 @@ import { MockApmPluginContextWrapper, } from '../../../context/ApmPluginContext/MockApmPluginContext'; -storiesOf('app/ErrorRateAlertTrigger', module).add('example', () => { - const params = { - threshold: 2, - window: '5m', - }; +storiesOf('app/ErrorRateAlertTrigger', module).add( + 'example', + () => { + const params = { + threshold: 2, + window: '5m', + }; - return ( - -
- undefined} - setAlertProperty={() => undefined} - /> -
-
- ); -}); + return ( + +
+ undefined} + setAlertProperty={() => undefined} + /> +
+
+ ); + }, + { + info: { + propTablesExclude: [ErrorRateAlertTrigger, MockApmPluginContextWrapper], + source: false, + }, + } +); diff --git a/x-pack/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx b/x-pack/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx index 89f5bf28a4938..45fa3dd382266 100644 --- a/x-pack/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/LicensePrompt/LicensePrompt.stories.tsx @@ -27,6 +27,7 @@ storiesOf('app/LicensePrompt', module).add( }, { info: { + propTablesExclude: [ApmPluginContext.Provider, LicensePrompt], source: false, }, } diff --git a/x-pack/legacy/plugins/beats_management/common/config_schemas.ts b/x-pack/plugins/beats_management/common/config_schemas.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/common/config_schemas.ts rename to x-pack/plugins/beats_management/common/config_schemas.ts diff --git a/x-pack/legacy/plugins/beats_management/common/config_schemas_translations_map.ts b/x-pack/plugins/beats_management/common/config_schemas_translations_map.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/common/config_schemas_translations_map.ts rename to x-pack/plugins/beats_management/common/config_schemas_translations_map.ts diff --git a/x-pack/legacy/plugins/beats_management/common/constants/configuration_blocks.ts b/x-pack/plugins/beats_management/common/constants/configuration_blocks.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/common/constants/configuration_blocks.ts rename to x-pack/plugins/beats_management/common/constants/configuration_blocks.ts diff --git a/x-pack/legacy/plugins/beats_management/common/constants/index.ts b/x-pack/plugins/beats_management/common/constants/index.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/common/constants/index.ts rename to x-pack/plugins/beats_management/common/constants/index.ts diff --git a/x-pack/legacy/plugins/beats_management/common/constants/index_names.ts b/x-pack/plugins/beats_management/common/constants/index_names.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/common/constants/index_names.ts rename to x-pack/plugins/beats_management/common/constants/index_names.ts diff --git a/x-pack/legacy/plugins/beats_management/common/constants/plugin.ts b/x-pack/plugins/beats_management/common/constants/plugin.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/common/constants/plugin.ts rename to x-pack/plugins/beats_management/common/constants/plugin.ts diff --git a/x-pack/legacy/plugins/beats_management/common/constants/security.ts b/x-pack/plugins/beats_management/common/constants/security.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/common/constants/security.ts rename to x-pack/plugins/beats_management/common/constants/security.ts diff --git a/x-pack/legacy/plugins/beats_management/common/constants/table.ts b/x-pack/plugins/beats_management/common/constants/table.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/common/constants/table.ts rename to x-pack/plugins/beats_management/common/constants/table.ts diff --git a/x-pack/legacy/plugins/beats_management/common/domain_types.ts b/x-pack/plugins/beats_management/common/domain_types.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/common/domain_types.ts rename to x-pack/plugins/beats_management/common/domain_types.ts diff --git a/x-pack/legacy/plugins/beats_management/common/io_ts_types.ts b/x-pack/plugins/beats_management/common/io_ts_types.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/common/io_ts_types.ts rename to x-pack/plugins/beats_management/common/io_ts_types.ts diff --git a/x-pack/legacy/plugins/beats_management/common/return_types.ts b/x-pack/plugins/beats_management/common/return_types.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/common/return_types.ts rename to x-pack/plugins/beats_management/common/return_types.ts diff --git a/x-pack/plugins/beats_management/kibana.json b/x-pack/plugins/beats_management/kibana.json index 1b431216ef992..3fd1ab6fd8701 100644 --- a/x-pack/plugins/beats_management/kibana.json +++ b/x-pack/plugins/beats_management/kibana.json @@ -1,5 +1,5 @@ { - "id": "beats_management", + "id": "beatsManagement", "configPath": ["xpack", "beats_management"], "ui": true, "server": true, diff --git a/x-pack/plugins/beats_management/public/components/config_list.tsx b/x-pack/plugins/beats_management/public/components/config_list.tsx index 5f200df1e3c65..285ce0afdf2fc 100644 --- a/x-pack/plugins/beats_management/public/components/config_list.tsx +++ b/x-pack/plugins/beats_management/public/components/config_list.tsx @@ -8,9 +8,9 @@ import { EuiBasicTable, EuiLink } from '@elastic/eui'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import React from 'react'; -import { configBlockSchemas } from '../../../../legacy/plugins/beats_management/common/config_schemas'; -import { translateConfigSchema } from '../../../../legacy/plugins/beats_management/common/config_schemas_translations_map'; -import { ConfigurationBlock } from '../../../../legacy/plugins/beats_management/common/domain_types'; +import { configBlockSchemas } from '../../common/config_schemas'; +import { translateConfigSchema } from '../../common/config_schemas_translations_map'; +import { ConfigurationBlock } from '../../common/domain_types'; interface ComponentProps { configs: { diff --git a/x-pack/plugins/beats_management/public/components/enroll_beats.tsx b/x-pack/plugins/beats_management/public/components/enroll_beats.tsx index 5bf0f51f48355..38f4a9a307014 100644 --- a/x-pack/plugins/beats_management/public/components/enroll_beats.tsx +++ b/x-pack/plugins/beats_management/public/components/enroll_beats.tsx @@ -20,7 +20,7 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { upperFirst } from 'lodash'; import React from 'react'; -import { CMBeat } from '../../../../legacy/plugins/beats_management/common/domain_types'; +import { CMBeat } from '../../common/domain_types'; interface ComponentProps { /** Such as kibanas basePath, for use to generate command */ diff --git a/x-pack/plugins/beats_management/public/components/navigation/breadcrumb/breadcrumb.tsx b/x-pack/plugins/beats_management/public/components/navigation/breadcrumb/breadcrumb.tsx index c13ab69a19090..cd6435a52f294 100644 --- a/x-pack/plugins/beats_management/public/components/navigation/breadcrumb/breadcrumb.tsx +++ b/x-pack/plugins/beats_management/public/components/navigation/breadcrumb/breadcrumb.tsx @@ -5,7 +5,7 @@ */ import React, { Component } from 'react'; import { RouteProps } from 'react-router-dom'; -import { BASE_PATH } from '../../../../../../legacy/plugins/beats_management/common/constants'; +import { BASE_PATH } from '../../../../common/constants'; import { BreadcrumbConsumer } from './consumer'; import { Breadcrumb as BreadcrumbData, BreadcrumbContext } from './types'; diff --git a/x-pack/plugins/beats_management/public/components/table/controls/tag_badge_list.tsx b/x-pack/plugins/beats_management/public/components/table/controls/tag_badge_list.tsx index a99c8b9ef8f9f..550c57d11d79c 100644 --- a/x-pack/plugins/beats_management/public/components/table/controls/tag_badge_list.tsx +++ b/x-pack/plugins/beats_management/public/components/table/controls/tag_badge_list.tsx @@ -13,7 +13,7 @@ import { EuiPopover, } from '@elastic/eui'; import React from 'react'; -import { TABLE_CONFIG } from '../../../../../../legacy/plugins/beats_management/common/constants/table'; +import { TABLE_CONFIG } from '../../../../common/constants/table'; import { TagBadge } from '../../tag/tag_badge'; import { AssignmentActionType } from '../index'; diff --git a/x-pack/plugins/beats_management/public/components/table/table.tsx b/x-pack/plugins/beats_management/public/components/table/table.tsx index 997ad13246d39..eecba050766b5 100644 --- a/x-pack/plugins/beats_management/public/components/table/table.tsx +++ b/x-pack/plugins/beats_management/public/components/table/table.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import styled from 'styled-components'; import { QuerySuggestion } from '../../../../../../src/plugins/data/public'; -import { TABLE_CONFIG } from '../../../../../legacy/plugins/beats_management/common/constants'; +import { TABLE_CONFIG } from '../../../common/constants'; import { AutocompleteField } from '../autocomplete_field/index'; import { ControlSchema } from './action_schema'; import { OptionControl } from './controls/option_control'; diff --git a/x-pack/plugins/beats_management/public/components/table/table_type_configs.tsx b/x-pack/plugins/beats_management/public/components/table/table_type_configs.tsx index 6bbf269711fbd..3c31a86ffd8c6 100644 --- a/x-pack/plugins/beats_management/public/components/table/table_type_configs.tsx +++ b/x-pack/plugins/beats_management/public/components/table/table_type_configs.tsx @@ -9,10 +9,7 @@ import { i18n } from '@kbn/i18n'; import { sortBy, uniqBy } from 'lodash'; import moment from 'moment'; import React from 'react'; -import { - BeatTag, - CMBeat, -} from '../../../../../legacy/plugins/beats_management/common/domain_types'; +import { BeatTag, CMBeat } from '../../../common/domain_types'; import { ConnectedLink } from '../navigation/connected_link'; import { TagBadge } from '../tag'; diff --git a/x-pack/plugins/beats_management/public/components/tag/config_view/config_form.tsx b/x-pack/plugins/beats_management/public/components/tag/config_view/config_form.tsx index 30c4d79d4c6bc..de06e676355d8 100644 --- a/x-pack/plugins/beats_management/public/components/tag/config_view/config_form.tsx +++ b/x-pack/plugins/beats_management/public/components/tag/config_view/config_form.tsx @@ -8,10 +8,7 @@ import { i18n } from '@kbn/i18n'; import Formsy from 'formsy-react'; import { get } from 'lodash'; import React from 'react'; -import { - ConfigBlockSchema, - ConfigurationBlock, -} from '../../../../../../legacy/plugins/beats_management/common/domain_types'; +import { ConfigBlockSchema, ConfigurationBlock } from '../../../../common/domain_types'; import { FormsyEuiCodeEditor, FormsyEuiFieldText, diff --git a/x-pack/plugins/beats_management/public/components/tag/config_view/index.tsx b/x-pack/plugins/beats_management/public/components/tag/config_view/index.tsx index 773ca1719d5fa..183620dfa934f 100644 --- a/x-pack/plugins/beats_management/public/components/tag/config_view/index.tsx +++ b/x-pack/plugins/beats_management/public/components/tag/config_view/index.tsx @@ -22,9 +22,9 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { configBlockSchemas } from '../../../../../../legacy/plugins/beats_management/common/config_schemas'; -import { translateConfigSchema } from '../../../../../../legacy/plugins/beats_management/common/config_schemas_translations_map'; -import { ConfigurationBlock } from '../../../../../../legacy/plugins/beats_management/common/domain_types'; +import { configBlockSchemas } from '../../../../common/config_schemas'; +import { translateConfigSchema } from '../../../../common/config_schemas_translations_map'; +import { ConfigurationBlock } from '../../../../common/domain_types'; import { ConfigForm } from './config_form'; interface ComponentProps { diff --git a/x-pack/plugins/beats_management/public/components/tag/tag_badge.tsx b/x-pack/plugins/beats_management/public/components/tag/tag_badge.tsx index 5880871da9f13..7fa0231cf3409 100644 --- a/x-pack/plugins/beats_management/public/components/tag/tag_badge.tsx +++ b/x-pack/plugins/beats_management/public/components/tag/tag_badge.tsx @@ -6,7 +6,7 @@ import { EuiBadge, EuiBadgeProps } from '@elastic/eui'; import React from 'react'; -import { TABLE_CONFIG } from '../../../../../legacy/plugins/beats_management/common/constants'; +import { TABLE_CONFIG } from '../../../common/constants'; type TagBadgeProps = EuiBadgeProps & { maxIdRenderSize?: number; diff --git a/x-pack/plugins/beats_management/public/components/tag/tag_edit.tsx b/x-pack/plugins/beats_management/public/components/tag/tag_edit.tsx index 9fca9d3add5e7..d76d5fcd476d5 100644 --- a/x-pack/plugins/beats_management/public/components/tag/tag_edit.tsx +++ b/x-pack/plugins/beats_management/public/components/tag/tag_edit.tsx @@ -24,11 +24,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import 'brace/mode/yaml'; import 'brace/theme/github'; import React from 'react'; -import { - BeatTag, - CMBeat, - ConfigurationBlock, -} from '../../../../../legacy/plugins/beats_management/common/domain_types'; +import { BeatTag, CMBeat, ConfigurationBlock } from '../../../common/domain_types'; import { ConfigList } from '../config_list'; import { AssignmentActionType, BeatsTableType, Table, tagConfigActions } from '../table'; import { ConfigView } from './config_view'; diff --git a/x-pack/plugins/beats_management/public/containers/beats.ts b/x-pack/plugins/beats_management/public/containers/beats.ts index 874447deec321..1454dacbf2a0f 100644 --- a/x-pack/plugins/beats_management/public/containers/beats.ts +++ b/x-pack/plugins/beats_management/public/containers/beats.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import { Container } from 'unstated'; -import { CMBeat } from '../../../../legacy/plugins/beats_management/common/domain_types'; -import { BeatsTagAssignment } from '../../../../legacy/plugins/beats_management/server/lib/adapters/beats/adapter_types'; +import { CMBeat } from '../../common/domain_types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import type { BeatsTagAssignment } from '../../server/lib/adapters/beats/adapter_types'; import { FrontendLibs } from './../lib/types'; interface ContainerState { diff --git a/x-pack/plugins/beats_management/public/containers/tags.ts b/x-pack/plugins/beats_management/public/containers/tags.ts index e7c73d26a1d02..56161f35c79e5 100644 --- a/x-pack/plugins/beats_management/public/containers/tags.ts +++ b/x-pack/plugins/beats_management/public/containers/tags.ts @@ -5,7 +5,7 @@ */ import { Container } from 'unstated'; -import { BeatTag } from '../../../../legacy/plugins/beats_management/common/domain_types'; +import { BeatTag } from '../../common/domain_types'; import { FrontendLibs } from '../lib/types'; interface ContainerState { diff --git a/x-pack/plugins/beats_management/public/lib/__tests__/config_blocks.test.ts b/x-pack/plugins/beats_management/public/lib/__tests__/config_blocks.test.ts index 6851020cdf97e..6776fb0b6f5ad 100644 --- a/x-pack/plugins/beats_management/public/lib/__tests__/config_blocks.test.ts +++ b/x-pack/plugins/beats_management/public/lib/__tests__/config_blocks.test.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { configBlockSchemas } from '../../../../../legacy/plugins/beats_management/common/config_schemas'; -import { translateConfigSchema } from '../../../../../legacy/plugins/beats_management/common/config_schemas_translations_map'; +import { configBlockSchemas } from '../../../common/config_schemas'; +import { translateConfigSchema } from '../../../common/config_schemas_translations_map'; import { ConfigBlocksLib } from '../configuration_blocks'; import { MemoryConfigBlocksAdapter } from './../adapters/configuration_blocks/memory_config_blocks_adapter'; diff --git a/x-pack/plugins/beats_management/public/lib/adapters/beats/adapter_types.ts b/x-pack/plugins/beats_management/public/lib/adapters/beats/adapter_types.ts index 1366894f78ddb..815b80e55fa11 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/beats/adapter_types.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/beats/adapter_types.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CMBeat } from '../../../../../../legacy/plugins/beats_management/common/domain_types'; -import { ReturnTypeBulkAction } from '../../../../../../legacy/plugins/beats_management/common/return_types'; +import { CMBeat } from '../../../../common/domain_types'; +import { ReturnTypeBulkAction } from '../../../../common/return_types'; export interface CMBeatsAdapter { get(id: string): Promise; diff --git a/x-pack/plugins/beats_management/public/lib/adapters/beats/memory_beats_adapter.ts b/x-pack/plugins/beats_management/public/lib/adapters/beats/memory_beats_adapter.ts index 24a7e5c3af8fa..099c568b90f9e 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/beats/memory_beats_adapter.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/beats/memory_beats_adapter.ts @@ -5,8 +5,8 @@ */ import { omit } from 'lodash'; -import { CMBeat } from '../../../../../../legacy/plugins/beats_management/common/domain_types'; -import { ReturnTypeBulkAction } from '../../../../../../legacy/plugins/beats_management/common/return_types'; +import { CMBeat } from '../../../../common/domain_types'; +import { ReturnTypeBulkAction } from '../../../../common/return_types'; import { BeatsTagAssignment, CMBeatsAdapter } from './adapter_types'; export class MemoryBeatsAdapter implements CMBeatsAdapter { diff --git a/x-pack/plugins/beats_management/public/lib/adapters/beats/rest_beats_adapter.ts b/x-pack/plugins/beats_management/public/lib/adapters/beats/rest_beats_adapter.ts index 880af83bfb3f2..d37ceeb5dfcdd 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/beats/rest_beats_adapter.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/beats/rest_beats_adapter.ts @@ -4,15 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CMBeat } from '../../../../../../legacy/plugins/beats_management/common/domain_types'; +import { CMBeat } from '../../../../common/domain_types'; import { ReturnTypeBulkAction, ReturnTypeGet, ReturnTypeList, ReturnTypeUpdate, -} from '../../../../../../legacy/plugins/beats_management/common/return_types'; +} from '../../../../common/return_types'; import { RestAPIAdapter } from '../rest_api/adapter_types'; import { BeatsTagAssignment, CMBeatsAdapter } from './adapter_types'; + export class RestBeatsAdapter implements CMBeatsAdapter { constructor(private readonly REST: RestAPIAdapter) {} diff --git a/x-pack/plugins/beats_management/public/lib/adapters/configuration_blocks/adapter_types.ts b/x-pack/plugins/beats_management/public/lib/adapters/configuration_blocks/adapter_types.ts index 413cf32529b2f..e8e2d5cd6b430 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/configuration_blocks/adapter_types.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/configuration_blocks/adapter_types.ts @@ -3,11 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { ConfigurationBlock } from '../../../../../../legacy/plugins/beats_management/common/domain_types'; -import { - ReturnTypeBulkUpsert, - ReturnTypeList, -} from '../../../../../../legacy/plugins/beats_management/common/return_types'; +import { ConfigurationBlock } from '../../../../common/domain_types'; +import { ReturnTypeBulkUpsert, ReturnTypeList } from '../../../../common/return_types'; export interface FrontendConfigBlocksAdapter { upsert(blocks: ConfigurationBlock[]): Promise; diff --git a/x-pack/plugins/beats_management/public/lib/adapters/configuration_blocks/memory_config_blocks_adapter.ts b/x-pack/plugins/beats_management/public/lib/adapters/configuration_blocks/memory_config_blocks_adapter.ts index ccb1f27bf67eb..455a31a0b4659 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/configuration_blocks/memory_config_blocks_adapter.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/configuration_blocks/memory_config_blocks_adapter.ts @@ -4,11 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ConfigurationBlock } from '../../../../../../legacy/plugins/beats_management/common/domain_types'; -import { - ReturnTypeBulkUpsert, - ReturnTypeList, -} from '../../../../../../legacy/plugins/beats_management/common/return_types'; +import { ConfigurationBlock } from '../../../../common/domain_types'; +import { ReturnTypeBulkUpsert, ReturnTypeList } from '../../../../common/return_types'; import { FrontendConfigBlocksAdapter } from './adapter_types'; export class MemoryConfigBlocksAdapter implements FrontendConfigBlocksAdapter { diff --git a/x-pack/plugins/beats_management/public/lib/adapters/configuration_blocks/rest_config_blocks_adapter.ts b/x-pack/plugins/beats_management/public/lib/adapters/configuration_blocks/rest_config_blocks_adapter.ts index 640da59ea572d..be501a5e951ba 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/configuration_blocks/rest_config_blocks_adapter.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/configuration_blocks/rest_config_blocks_adapter.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ConfigurationBlock } from '../../../../../../legacy/plugins/beats_management/common/domain_types'; +import { ConfigurationBlock } from '../../../../common/domain_types'; import { ReturnTypeBulkDelete, ReturnTypeBulkUpsert, ReturnTypeList, -} from '../../../../../../legacy/plugins/beats_management/common/return_types'; +} from '../../../../common/return_types'; import { RestAPIAdapter } from '../rest_api/adapter_types'; import { FrontendConfigBlocksAdapter } from './adapter_types'; diff --git a/x-pack/plugins/beats_management/public/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/beats_management/public/lib/adapters/framework/adapter_types.ts index ce663650409fa..cb4fa830949bc 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/framework/adapter_types.ts @@ -7,7 +7,7 @@ /* eslint-disable @typescript-eslint/no-empty-interface */ import * as t from 'io-ts'; -import { LICENSES } from '../../../../../../legacy/plugins/beats_management/common/constants/security'; +import { LICENSES } from '../../../../common/constants/security'; import { RegisterManagementAppArgs } from '../../../../../../../src/plugins/management/public'; export interface FrameworkAdapter { diff --git a/x-pack/plugins/beats_management/public/lib/adapters/tags/adapter_types.ts b/x-pack/plugins/beats_management/public/lib/adapters/tags/adapter_types.ts index b0ddf0309232a..5030b1704b1d7 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/tags/adapter_types.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/tags/adapter_types.ts @@ -3,10 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { - BeatTag, - CMBeat, -} from '../../../../../../legacy/plugins/beats_management/common/domain_types'; +import { BeatTag, CMBeat } from '../../../../common/domain_types'; export interface CMTagsAdapter { getTagsWithIds(tagIds: string[]): Promise; diff --git a/x-pack/plugins/beats_management/public/lib/adapters/tags/memory_tags_adapter.ts b/x-pack/plugins/beats_management/public/lib/adapters/tags/memory_tags_adapter.ts index 699880bd303ec..1e7cae5aed3d8 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/tags/memory_tags_adapter.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/tags/memory_tags_adapter.ts @@ -4,10 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - BeatTag, - CMBeat, -} from '../../../../../../legacy/plugins/beats_management/common/domain_types'; +import { BeatTag, CMBeat } from '../../../../common/domain_types'; import { CMTagsAdapter } from './adapter_types'; export class MemoryTagsAdapter implements CMTagsAdapter { diff --git a/x-pack/plugins/beats_management/public/lib/adapters/tags/rest_tags_adapter.ts b/x-pack/plugins/beats_management/public/lib/adapters/tags/rest_tags_adapter.ts index a31f81b8e5ab4..9a2cebefc8f8f 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/tags/rest_tags_adapter.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/tags/rest_tags_adapter.ts @@ -5,16 +5,13 @@ */ import { uniq } from 'lodash'; -import { - BeatTag, - CMBeat, -} from '../../../../../../legacy/plugins/beats_management/common/domain_types'; +import { BeatTag, CMBeat } from '../../../../common/domain_types'; import { ReturnTypeBulkDelete, ReturnTypeBulkGet, ReturnTypeList, ReturnTypeUpsert, -} from '../../../../../../legacy/plugins/beats_management/common/return_types'; +} from '../../../../common/return_types'; import { RestAPIAdapter } from '../rest_api/adapter_types'; import { CMTagsAdapter } from './adapter_types'; diff --git a/x-pack/plugins/beats_management/public/lib/adapters/tokens/rest_tokens_adapter.ts b/x-pack/plugins/beats_management/public/lib/adapters/tokens/rest_tokens_adapter.ts index 4b2beabd35280..7b71d654d5e1f 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/tokens/rest_tokens_adapter.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/tokens/rest_tokens_adapter.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ReturnTypeBulkCreate } from '../../../../../../legacy/plugins/beats_management/common/return_types'; +import { ReturnTypeBulkCreate } from '../../../../common/return_types'; import { RestAPIAdapter } from '../rest_api/adapter_types'; import { CMTokensAdapter } from './adapter_types'; diff --git a/x-pack/plugins/beats_management/public/lib/beats.ts b/x-pack/plugins/beats_management/public/lib/beats.ts index a6cfe0b88dcf8..0dc9a241f2f07 100644 --- a/x-pack/plugins/beats_management/public/lib/beats.ts +++ b/x-pack/plugins/beats_management/public/lib/beats.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ReturnTypeBulkAction } from '../../../../legacy/plugins/beats_management/common/return_types'; -import { CMBeat } from '../../../../legacy/plugins/beats_management/common/domain_types'; +import { ReturnTypeBulkAction } from '../../common/return_types'; +import { CMBeat } from '../../common/domain_types'; import { BeatsTagAssignment, CMBeatsAdapter } from './adapters/beats/adapter_types'; import { ElasticsearchLib } from './elasticsearch'; diff --git a/x-pack/plugins/beats_management/public/lib/compose/kibana.ts b/x-pack/plugins/beats_management/public/lib/compose/kibana.ts index d750417640f8c..68640469a7656 100644 --- a/x-pack/plugins/beats_management/public/lib/compose/kibana.ts +++ b/x-pack/plugins/beats_management/public/lib/compose/kibana.ts @@ -5,9 +5,9 @@ */ import { camelCase } from 'lodash'; -import { configBlockSchemas } from '../../../../../legacy/plugins/beats_management/common/config_schemas'; -import { translateConfigSchema } from '../../../../../legacy/plugins/beats_management/common/config_schemas_translations_map'; -import { INDEX_NAMES } from '../../../../../legacy/plugins/beats_management/common/constants/index_names'; +import { configBlockSchemas } from '../../../common/config_schemas'; +import { translateConfigSchema } from '../../../common/config_schemas_translations_map'; +import { INDEX_NAMES } from '../../../common/constants/index_names'; import { RestBeatsAdapter } from '../adapters/beats/rest_beats_adapter'; import { RestConfigBlocksAdapter } from '../adapters/configuration_blocks/rest_config_blocks_adapter'; import { RestElasticsearchAdapter } from '../adapters/elasticsearch/rest'; @@ -20,7 +20,7 @@ import { ConfigBlocksLib } from '../configuration_blocks'; import { ElasticsearchLib } from '../elasticsearch'; import { TagsLib } from '../tags'; import { FrontendLibs } from '../types'; -import { PLUGIN } from '../../../../../legacy/plugins/beats_management/common/constants/plugin'; +import { PLUGIN } from '../../../common/constants/plugin'; import { FrameworkLib } from './../framework'; import { ManagementSetup } from '../../../../../../src/plugins/management/public'; import { SecurityPluginSetup } from '../../../../security/public'; diff --git a/x-pack/plugins/beats_management/public/lib/compose/scripts.ts b/x-pack/plugins/beats_management/public/lib/compose/scripts.ts index 093d618ba8d8b..83129384a77df 100644 --- a/x-pack/plugins/beats_management/public/lib/compose/scripts.ts +++ b/x-pack/plugins/beats_management/public/lib/compose/scripts.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { configBlockSchemas } from '../../../../../legacy/plugins/beats_management/common/config_schemas'; -import { translateConfigSchema } from '../../../../../legacy/plugins/beats_management/common/config_schemas_translations_map'; +import { configBlockSchemas } from '../../../common/config_schemas'; +import { translateConfigSchema } from '../../../common/config_schemas_translations_map'; import { RestBeatsAdapter } from '../adapters/beats/rest_beats_adapter'; import { RestConfigBlocksAdapter } from '../adapters/configuration_blocks/rest_config_blocks_adapter'; import { MemoryElasticsearchAdapter } from '../adapters/elasticsearch/memory'; diff --git a/x-pack/plugins/beats_management/public/lib/configuration_blocks.ts b/x-pack/plugins/beats_management/public/lib/configuration_blocks.ts index b486ba82689e8..09c079ea129e6 100644 --- a/x-pack/plugins/beats_management/public/lib/configuration_blocks.ts +++ b/x-pack/plugins/beats_management/public/lib/configuration_blocks.ts @@ -7,10 +7,7 @@ import yaml from 'js-yaml'; import { set } from '@elastic/safer-lodash-set'; import { get, has, omit } from 'lodash'; -import { - ConfigBlockSchema, - ConfigurationBlock, -} from '../../../../legacy/plugins/beats_management/common/domain_types'; +import { ConfigBlockSchema, ConfigurationBlock } from '../../common/domain_types'; import { FrontendConfigBlocksAdapter } from './adapters/configuration_blocks/adapter_types'; export class ConfigBlocksLib { diff --git a/x-pack/plugins/beats_management/public/lib/framework.ts b/x-pack/plugins/beats_management/public/lib/framework.ts index 63a81e0895348..33fa975a99493 100644 --- a/x-pack/plugins/beats_management/public/lib/framework.ts +++ b/x-pack/plugins/beats_management/public/lib/framework.ts @@ -5,10 +5,7 @@ */ import { difference, get } from 'lodash'; -import { - LICENSES, - LicenseType, -} from '../../../../legacy/plugins/beats_management/common/constants/security'; +import { LICENSES, LicenseType } from '../../common/constants/security'; import { FrameworkAdapter } from './adapters/framework/adapter_types'; export class FrameworkLib { diff --git a/x-pack/plugins/beats_management/public/lib/tags.ts b/x-pack/plugins/beats_management/public/lib/tags.ts index 2d67edf7e347e..86562be3ff989 100644 --- a/x-pack/plugins/beats_management/public/lib/tags.ts +++ b/x-pack/plugins/beats_management/public/lib/tags.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import uuidv4 from 'uuid/v4'; -import { BeatTag, CMBeat } from '../../../../legacy/plugins/beats_management/common/domain_types'; +import { BeatTag, CMBeat } from '../../common/domain_types'; import { CMTagsAdapter } from './adapters/tags/adapter_types'; import { ElasticsearchLib } from './elasticsearch'; diff --git a/x-pack/plugins/beats_management/public/pages/beat/details.tsx b/x-pack/plugins/beats_management/public/pages/beat/details.tsx index 4466a1ecba97d..cfe60aff67b1b 100644 --- a/x-pack/plugins/beats_management/public/pages/beat/details.tsx +++ b/x-pack/plugins/beats_management/public/pages/beat/details.tsx @@ -19,14 +19,10 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; import { get } from 'lodash'; import React from 'react'; -import { configBlockSchemas } from '../../../../../legacy/plugins/beats_management/common/config_schemas'; -import { translateConfigSchema } from '../../../../../legacy/plugins/beats_management/common/config_schemas_translations_map'; -import { TABLE_CONFIG } from '../../../../../legacy/plugins/beats_management/common/constants'; -import { - BeatTag, - CMBeat, - ConfigurationBlock, -} from '../../../../../legacy/plugins/beats_management/common/domain_types'; +import { configBlockSchemas } from '../../../common/config_schemas'; +import { translateConfigSchema } from '../../../common/config_schemas_translations_map'; +import { TABLE_CONFIG } from '../../../common/constants'; +import { BeatTag, CMBeat, ConfigurationBlock } from '../../../common/domain_types'; import { Breadcrumb } from '../../components/navigation/breadcrumb'; import { ConnectedLink } from '../../components/navigation/connected_link'; import { TagBadge } from '../../components/tag'; diff --git a/x-pack/plugins/beats_management/public/pages/beat/index.tsx b/x-pack/plugins/beats_management/public/pages/beat/index.tsx index bea84377d2ab8..80590febc95be 100644 --- a/x-pack/plugins/beats_management/public/pages/beat/index.tsx +++ b/x-pack/plugins/beats_management/public/pages/beat/index.tsx @@ -17,7 +17,7 @@ import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; import moment from 'moment'; import React from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; -import { CMBeat } from '../../../../../legacy/plugins/beats_management/common/domain_types'; +import { CMBeat } from '../../../common/domain_types'; import { PrimaryLayout } from '../../components/layouts/primary'; import { Breadcrumb } from '../../components/navigation/breadcrumb'; import { ChildRoutes } from '../../components/navigation/child_routes'; diff --git a/x-pack/plugins/beats_management/public/pages/beat/tags.tsx b/x-pack/plugins/beats_management/public/pages/beat/tags.tsx index 5a65473b25a24..672c0d89bb002 100644 --- a/x-pack/plugins/beats_management/public/pages/beat/tags.tsx +++ b/x-pack/plugins/beats_management/public/pages/beat/tags.tsx @@ -7,10 +7,7 @@ import { EuiGlobalToastList } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { - BeatTag, - CMBeat, -} from '../../../../../legacy/plugins/beats_management/common/domain_types'; +import { BeatTag, CMBeat } from '../../../common/domain_types'; import { Breadcrumb } from '../../components/navigation/breadcrumb'; import { BeatDetailTagsTable, Table } from '../../components/table'; import { FrontendLibs } from '../../lib/types'; diff --git a/x-pack/plugins/beats_management/public/pages/overview/enrolled_beats.tsx b/x-pack/plugins/beats_management/public/pages/overview/enrolled_beats.tsx index 7c26bb8de3f57..5e0bddbbe5411 100644 --- a/x-pack/plugins/beats_management/public/pages/overview/enrolled_beats.tsx +++ b/x-pack/plugins/beats_management/public/pages/overview/enrolled_beats.tsx @@ -19,10 +19,7 @@ import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; import { flatten, sortBy } from 'lodash'; import moment from 'moment'; import React from 'react'; -import { - BeatTag, - CMBeat, -} from '../../../../../legacy/plugins/beats_management/common/domain_types'; +import { BeatTag, CMBeat } from '../../../common/domain_types'; import { EnrollBeat } from '../../components/enroll_beats'; import { Breadcrumb } from '../../components/navigation/breadcrumb'; import { BeatsTableType, Table } from '../../components/table'; diff --git a/x-pack/plugins/beats_management/public/pages/overview/index.tsx b/x-pack/plugins/beats_management/public/pages/overview/index.tsx index 4df1a7d065469..57b007753491c 100644 --- a/x-pack/plugins/beats_management/public/pages/overview/index.tsx +++ b/x-pack/plugins/beats_management/public/pages/overview/index.tsx @@ -16,7 +16,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { Subscribe } from 'unstated'; -import { CMBeat } from '../../../../../legacy/plugins/beats_management/common/domain_types'; +import { CMBeat } from '../../../common/domain_types'; import { PrimaryLayout } from '../../components/layouts/primary'; import { ChildRoutes } from '../../components/navigation/child_routes'; import { BeatsContainer } from '../../containers/beats'; diff --git a/x-pack/plugins/beats_management/public/pages/tag/create.tsx b/x-pack/plugins/beats_management/public/pages/tag/create.tsx index 881bb433b1d9a..34785e5dce51d 100644 --- a/x-pack/plugins/beats_management/public/pages/tag/create.tsx +++ b/x-pack/plugins/beats_management/public/pages/tag/create.tsx @@ -12,11 +12,8 @@ import 'brace/mode/yaml'; import 'brace/theme/github'; import { isEqual } from 'lodash'; import React from 'react'; -import { UNIQUENESS_ENFORCING_TYPES } from '../../../../../legacy/plugins/beats_management/common/constants/configuration_blocks'; -import { - BeatTag, - ConfigurationBlock, -} from '../../../../../legacy/plugins/beats_management/common/domain_types'; +import { UNIQUENESS_ENFORCING_TYPES } from '../../../common/constants/configuration_blocks'; +import { BeatTag, ConfigurationBlock } from '../../../common/domain_types'; import { PrimaryLayout } from '../../components/layouts/primary'; import { TagEdit } from '../../components/tag'; import { AppPageProps } from '../../frontend_types'; diff --git a/x-pack/plugins/beats_management/public/pages/tag/edit.tsx b/x-pack/plugins/beats_management/public/pages/tag/edit.tsx index 10d7f7bbd7193..e3c2671061d99 100644 --- a/x-pack/plugins/beats_management/public/pages/tag/edit.tsx +++ b/x-pack/plugins/beats_management/public/pages/tag/edit.tsx @@ -10,12 +10,8 @@ import 'brace/mode/yaml'; import 'brace/theme/github'; import { flatten } from 'lodash'; import React from 'react'; -import { UNIQUENESS_ENFORCING_TYPES } from '../../../../../legacy/plugins/beats_management/common/constants'; -import { - BeatTag, - CMBeat, - ConfigurationBlock, -} from '../../../../../legacy/plugins/beats_management/common/domain_types'; +import { UNIQUENESS_ENFORCING_TYPES } from '../../../common/constants'; +import { BeatTag, CMBeat, ConfigurationBlock } from '../../../common/domain_types'; import { PrimaryLayout } from '../../components/layouts/primary'; import { TagEdit } from '../../components/tag'; import { AppPageProps } from '../../frontend_types'; diff --git a/x-pack/plugins/beats_management/public/pages/walkthrough/initial/finish.tsx b/x-pack/plugins/beats_management/public/pages/walkthrough/initial/finish.tsx index 71f0b8161131f..93489f5223ef8 100644 --- a/x-pack/plugins/beats_management/public/pages/walkthrough/initial/finish.tsx +++ b/x-pack/plugins/beats_management/public/pages/walkthrough/initial/finish.tsx @@ -6,7 +6,7 @@ import { EuiButton, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiPageContent } from '@elastic/eui'; import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; import React from 'react'; -import { CMBeat } from '../../../../../../legacy/plugins/beats_management/common/domain_types'; +import { CMBeat } from '../../../../common/domain_types'; import { AppPageProps } from '../../../frontend_types'; interface PageState { diff --git a/x-pack/plugins/beats_management/public/pages/walkthrough/initial/tag.tsx b/x-pack/plugins/beats_management/public/pages/walkthrough/initial/tag.tsx index 8ecdf8bc5c663..01976633d14ea 100644 --- a/x-pack/plugins/beats_management/public/pages/walkthrough/initial/tag.tsx +++ b/x-pack/plugins/beats_management/public/pages/walkthrough/initial/tag.tsx @@ -9,10 +9,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { isEqual } from 'lodash'; import React, { Component } from 'react'; import uuidv4 from 'uuid/v4'; -import { - BeatTag, - ConfigurationBlock, -} from '../../../../../../legacy/plugins/beats_management/common/domain_types'; +import { BeatTag, ConfigurationBlock } from '../../../../common/domain_types'; import { TagEdit } from '../../../components/tag/tag_edit'; import { AppPageProps } from '../../../frontend_types'; interface PageState { diff --git a/x-pack/legacy/plugins/beats_management/readme.md b/x-pack/plugins/beats_management/readme.md similarity index 88% rename from x-pack/legacy/plugins/beats_management/readme.md rename to x-pack/plugins/beats_management/readme.md index 3414f09deed46..36db612f7affd 100644 --- a/x-pack/legacy/plugins/beats_management/readme.md +++ b/x-pack/plugins/beats_management/readme.md @@ -1,7 +1,7 @@ # Beats CM Notes: -Falure to have auth enabled in Kibana will make for a broken UI. UI based errors not yet in place +Failure to have auth enabled in Kibana will make for a broken UI. UI-based errors not yet in place ## Testing diff --git a/x-pack/legacy/plugins/beats_management/scripts/enroll.js b/x-pack/plugins/beats_management/scripts/enroll.js similarity index 100% rename from x-pack/legacy/plugins/beats_management/scripts/enroll.js rename to x-pack/plugins/beats_management/scripts/enroll.js diff --git a/x-pack/legacy/plugins/beats_management/scripts/fake_env.ts b/x-pack/plugins/beats_management/scripts/fake_env.ts similarity index 96% rename from x-pack/legacy/plugins/beats_management/scripts/fake_env.ts rename to x-pack/plugins/beats_management/scripts/fake_env.ts index 65254d24863cd..b9eff4615cd42 100644 --- a/x-pack/legacy/plugins/beats_management/scripts/fake_env.ts +++ b/x-pack/plugins/beats_management/scripts/fake_env.ts @@ -9,8 +9,7 @@ import request from 'request'; import uuidv4 from 'uuid/v4'; import { configBlockSchemas } from '../common/config_schemas'; import { BeatTag } from '../common/domain_types'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { compose } from '../../../../plugins/beats_management/public/lib/compose/scripts'; +import { compose } from '../public/lib/compose/scripts'; const args = process.argv.slice(2); const chance = new Chance(); diff --git a/x-pack/legacy/plugins/beats_management/server/index_templates/beats_template.json b/x-pack/plugins/beats_management/server/index_templates/beats_template.json similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/index_templates/beats_template.json rename to x-pack/plugins/beats_management/server/index_templates/beats_template.json diff --git a/x-pack/legacy/plugins/beats_management/server/index_templates/events_template.json b/x-pack/plugins/beats_management/server/index_templates/events_template.json similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/index_templates/events_template.json rename to x-pack/plugins/beats_management/server/index_templates/events_template.json diff --git a/x-pack/legacy/plugins/beats_management/server/index_templates/index.ts b/x-pack/plugins/beats_management/server/index_templates/index.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/index_templates/index.ts rename to x-pack/plugins/beats_management/server/index_templates/index.ts diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/beats/adapter_types.ts b/x-pack/plugins/beats_management/server/lib/adapters/beats/adapter_types.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/lib/adapters/beats/adapter_types.ts rename to x-pack/plugins/beats_management/server/lib/adapters/beats/adapter_types.ts diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/beats/elasticsearch_beats_adapter.ts b/x-pack/plugins/beats_management/server/lib/adapters/beats/elasticsearch_beats_adapter.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/lib/adapters/beats/elasticsearch_beats_adapter.ts rename to x-pack/plugins/beats_management/server/lib/adapters/beats/elasticsearch_beats_adapter.ts diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/configuration_blocks/adapter_types.ts b/x-pack/plugins/beats_management/server/lib/adapters/configuration_blocks/adapter_types.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/lib/adapters/configuration_blocks/adapter_types.ts rename to x-pack/plugins/beats_management/server/lib/adapters/configuration_blocks/adapter_types.ts diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/configuration_blocks/elasticsearch_configuration_block_adapter.ts b/x-pack/plugins/beats_management/server/lib/adapters/configuration_blocks/elasticsearch_configuration_block_adapter.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/lib/adapters/configuration_blocks/elasticsearch_configuration_block_adapter.ts rename to x-pack/plugins/beats_management/server/lib/adapters/configuration_blocks/elasticsearch_configuration_block_adapter.ts diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/database/adapter_types.ts b/x-pack/plugins/beats_management/server/lib/adapters/database/adapter_types.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/lib/adapters/database/adapter_types.ts rename to x-pack/plugins/beats_management/server/lib/adapters/database/adapter_types.ts diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/database/kibana_database_adapter.ts b/x-pack/plugins/beats_management/server/lib/adapters/database/kibana_database_adapter.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/lib/adapters/database/kibana_database_adapter.ts rename to x-pack/plugins/beats_management/server/lib/adapters/database/kibana_database_adapter.ts diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/beats_management/server/lib/adapters/framework/adapter_types.ts similarity index 57% rename from x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/adapter_types.ts rename to x-pack/plugins/beats_management/server/lib/adapters/framework/adapter_types.ts index 85a8618be5d18..2ef3d98450ae4 100644 --- a/x-pack/legacy/plugins/beats_management/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/beats_management/server/lib/adapters/framework/adapter_types.ts @@ -6,74 +6,19 @@ /* eslint-disable @typescript-eslint/no-empty-interface */ -import { Lifecycle, ResponseToolkit } from 'hapi'; import * as t from 'io-ts'; -import { CoreSetup, CoreStart } from 'src/core/server'; -import { SecurityPluginSetup } from '../../../../../../../plugins/security/server'; -import { LicenseType } from '../../../../common/constants/security'; +import { Headers, KibanaRequest } from 'src/core/server'; export const internalAuthData = Symbol('internalAuthData'); export const internalUser: FrameworkInternalUser = { kind: 'internal', }; -export interface XpackInfo { - license: { - getType: () => LicenseType; - /** Is the license expired */ - isActive: () => boolean; - getExpiryDateInMillis: () => number; - }; - feature: (pluginId: string) => any; - isAvailable: () => boolean; -} - export interface BackendFrameworkAdapter { + getUser(request: KibanaRequest): FrameworkUser; internalUser: FrameworkInternalUser; info: null | FrameworkInfo; log(text: string): void; - on(event: 'xpack.status.green' | 'elasticsearch.status.green', cb: () => void): void; - getSetting(settingPath: string): any; - registerRoute( - route: FrameworkRouteOptions - ): void; -} - -export interface KibanaLegacyServer { - newPlatform: { - setup: { - core: CoreSetup; - plugins: { security: SecurityPluginSetup }; - }; - start: { - core: CoreStart; - }; - }; - plugins: { - xpack_main: { - status: { - on: (status: 'green' | 'yellow' | 'red', callback: () => void) => void; - }; - info: XpackInfo; - }; - kibana: { - status: { - plugin: { - version: string; - }; - }; - }; - elasticsearch: { - status: { - on: (status: 'green' | 'yellow' | 'red', callback: () => void) => void; - }; - getCluster: () => any; - }; - beats_management: {}; - }; - config: () => any; - route: (routeConfig: any) => void; - log: (message: string) => void; } export const RuntimeFrameworkInfo = t.interface( @@ -167,23 +112,3 @@ export interface FrameworkRequest< params: KibanaServerRequestGenaric['params']; query: KibanaServerRequestGenaric['query']; } - -export interface FrameworkRouteOptions< - RouteRequest extends FrameworkRequest = FrameworkRequest, - RouteResponse extends FrameworkResponse = any -> { - path: string; - method: string | string[]; - vhost?: string; - licenseRequired?: string[]; - requiredRoles?: string[]; - handler: FrameworkRouteHandler; - config?: {}; -} - -export type FrameworkRouteHandler< - RouteRequest extends KibanaServerRequest, - RouteResponse extends FrameworkResponse -> = (request: FrameworkRequest, h: ResponseToolkit) => Promise; - -export type FrameworkResponse = Lifecycle.ReturnValue; diff --git a/x-pack/plugins/beats_management/server/lib/adapters/framework/kibana_framework_adapter.ts b/x-pack/plugins/beats_management/server/lib/adapters/framework/kibana_framework_adapter.ts new file mode 100644 index 0000000000000..27387f269a9e4 --- /dev/null +++ b/x-pack/plugins/beats_management/server/lib/adapters/framework/kibana_framework_adapter.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PathReporter } from 'io-ts/lib/PathReporter'; +import { isLeft } from 'fp-ts/lib/Either'; +import { KibanaRequest, Headers, Logger } from 'src/core/server'; +import { + BackendFrameworkAdapter, + FrameworkInfo, + FrameworkUser, + internalAuthData, + internalUser, + RuntimeFrameworkInfo, + RuntimeKibanaUser, +} from './adapter_types'; +import { BeatsManagementConfigType } from '../../../../common'; +import { ILicense, LicensingPluginStart } from '../../../../../licensing/server'; +import { SecurityPluginSetup } from '../../../../../security/server'; + +export class KibanaBackendFrameworkAdapter implements BackendFrameworkAdapter { + public readonly internalUser = internalUser; + public info: null | FrameworkInfo = null; + + constructor( + private readonly PLUGIN_ID: string, + private readonly kibanaVersion: string, + private readonly config: BeatsManagementConfigType, + private readonly logger: Logger, + private readonly licensing: LicensingPluginStart, + private readonly security?: SecurityPluginSetup + ) { + this.licensing.license$.subscribe((license) => this.licenseUpdateHandler(license)); + } + + public log(text: string) { + this.logger.info(text); + } + + getUser(request: KibanaRequest): FrameworkUser { + const user = this.security?.authc.getCurrentUser(request); + if (!user) { + return { + kind: 'unauthenticated', + }; + } + const assertKibanaUser = RuntimeKibanaUser.decode(user); + if (isLeft(assertKibanaUser)) { + throw new Error( + `Error parsing user info in ${this.PLUGIN_ID}, ${ + PathReporter.report(assertKibanaUser)[0] + }` + ); + } + + return { + kind: 'authenticated', + [internalAuthData]: request.headers, + ...user, + }; + } + + private licenseUpdateHandler = (license: ILicense) => { + let xpackInfoUnpacked: FrameworkInfo; + + // If, for some reason, we cannot get the license information + // from Elasticsearch, assume worst case and disable + if (!license.isAvailable) { + this.info = null; + return; + } + + const securityFeature = license.getFeature('security'); + const watcherFeature = license.getFeature('watcher'); + + try { + xpackInfoUnpacked = { + kibana: { + version: this.kibanaVersion, + }, + license: { + type: license.type!, + expired: !license.isActive, + expiry_date_in_millis: license.expiryDateInMillis ?? -1, + }, + security: { + enabled: securityFeature.isEnabled, + available: securityFeature.isAvailable, + }, + watcher: { + enabled: watcherFeature.isEnabled, + available: watcherFeature.isAvailable, + }, + }; + } catch (e) { + this.logger.error(`Error accessing required xPackInfo in ${this.PLUGIN_ID} Kibana adapter`); + throw e; + } + + const assertData = RuntimeFrameworkInfo.decode(xpackInfoUnpacked); + if (isLeft(assertData)) { + throw new Error( + `Error parsing xpack info in ${this.PLUGIN_ID}, ${PathReporter.report(assertData)[0]}` + ); + } + this.info = xpackInfoUnpacked; + + return { + security: xpackInfoUnpacked.security, + settings: { ...this.config }, + }; + }; +} diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/tags/adapter_types.ts b/x-pack/plugins/beats_management/server/lib/adapters/tags/adapter_types.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/lib/adapters/tags/adapter_types.ts rename to x-pack/plugins/beats_management/server/lib/adapters/tags/adapter_types.ts diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/tags/elasticsearch_tags_adapter.ts b/x-pack/plugins/beats_management/server/lib/adapters/tags/elasticsearch_tags_adapter.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/lib/adapters/tags/elasticsearch_tags_adapter.ts rename to x-pack/plugins/beats_management/server/lib/adapters/tags/elasticsearch_tags_adapter.ts diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/tokens/adapter_types.ts b/x-pack/plugins/beats_management/server/lib/adapters/tokens/adapter_types.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/lib/adapters/tokens/adapter_types.ts rename to x-pack/plugins/beats_management/server/lib/adapters/tokens/adapter_types.ts diff --git a/x-pack/legacy/plugins/beats_management/server/lib/adapters/tokens/elasticsearch_tokens_adapter.ts b/x-pack/plugins/beats_management/server/lib/adapters/tokens/elasticsearch_tokens_adapter.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/lib/adapters/tokens/elasticsearch_tokens_adapter.ts rename to x-pack/plugins/beats_management/server/lib/adapters/tokens/elasticsearch_tokens_adapter.ts diff --git a/x-pack/legacy/plugins/beats_management/server/lib/beat_events.ts b/x-pack/plugins/beats_management/server/lib/beat_events.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/lib/beat_events.ts rename to x-pack/plugins/beats_management/server/lib/beat_events.ts diff --git a/x-pack/legacy/plugins/beats_management/server/lib/beats.ts b/x-pack/plugins/beats_management/server/lib/beats.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/lib/beats.ts rename to x-pack/plugins/beats_management/server/lib/beats.ts diff --git a/x-pack/legacy/plugins/beats_management/server/lib/compose/kibana.ts b/x-pack/plugins/beats_management/server/lib/compose/kibana.ts similarity index 68% rename from x-pack/legacy/plugins/beats_management/server/lib/compose/kibana.ts rename to x-pack/plugins/beats_management/server/lib/compose/kibana.ts index b6a645ded8164..bdae4962bd471 100644 --- a/x-pack/legacy/plugins/beats_management/server/lib/compose/kibana.ts +++ b/x-pack/plugins/beats_management/server/lib/compose/kibana.ts @@ -5,12 +5,14 @@ */ import { camelCase } from 'lodash'; +import type { ElasticsearchServiceStart, Logger } from 'src/core/server'; +import { SecurityPluginSetup } from '../../../../security/server'; +import { LicensingPluginStart } from '../../../../licensing/server'; import { PLUGIN } from '../../../common/constants'; -import { CONFIG_PREFIX } from '../../../common/constants/plugin'; +import { BeatsManagementConfigType } from '../../../common'; import { ElasticsearchBeatsAdapter } from '../adapters/beats/elasticsearch_beats_adapter'; import { ElasticsearchConfigurationBlockAdapter } from '../adapters/configuration_blocks/elasticsearch_configuration_block_adapter'; import { KibanaDatabaseAdapter } from '../adapters/database/kibana_database_adapter'; -import { KibanaLegacyServer } from '../adapters/framework/adapter_types'; import { KibanaBackendFrameworkAdapter } from '../adapters/framework/kibana_framework_adapter'; import { ElasticsearchTagsAdapter } from '../adapters/tags/elasticsearch_tags_adapter'; import { ElasticsearchTokensAdapter } from '../adapters/tokens/elasticsearch_tokens_adapter'; @@ -22,11 +24,33 @@ import { CMTokensDomain } from '../tokens'; import { CMServerLibs } from '../types'; import { BackendFrameworkLib } from './../framework'; -export function compose(server: KibanaLegacyServer): CMServerLibs { - const framework = new BackendFrameworkLib( - new KibanaBackendFrameworkAdapter(camelCase(PLUGIN.ID), server, CONFIG_PREFIX) +interface ComposeOptions { + elasticsearch: ElasticsearchServiceStart; + licensing: LicensingPluginStart; + security?: SecurityPluginSetup; + config: BeatsManagementConfigType; + logger: Logger; + kibanaVersion: string; +} + +export function compose({ + elasticsearch, + config, + kibanaVersion, + logger, + licensing, + security, +}: ComposeOptions): CMServerLibs { + const backendAdapter = new KibanaBackendFrameworkAdapter( + camelCase(PLUGIN.ID), + kibanaVersion, + config, + logger, + licensing, + security ); - const database = new KibanaDatabaseAdapter(server.newPlatform.start.core.elasticsearch); + const framework = new BackendFrameworkLib(backendAdapter, config); + const database = new KibanaDatabaseAdapter(elasticsearch); const beatsAdapter = new ElasticsearchBeatsAdapter(database); const configAdapter = new ElasticsearchConfigurationBlockAdapter(database); diff --git a/x-pack/legacy/plugins/beats_management/server/lib/configuration_blocks.ts b/x-pack/plugins/beats_management/server/lib/configuration_blocks.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/lib/configuration_blocks.ts rename to x-pack/plugins/beats_management/server/lib/configuration_blocks.ts diff --git a/x-pack/plugins/beats_management/server/lib/framework.ts b/x-pack/plugins/beats_management/server/lib/framework.ts new file mode 100644 index 0000000000000..abe41df1a279a --- /dev/null +++ b/x-pack/plugins/beats_management/server/lib/framework.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Headers, KibanaRequest } from 'kibana/server'; +import { BackendFrameworkAdapter, FrameworkUser } from './adapters/framework/adapter_types'; +import { BeatsManagementConfigType } from '../../common'; + +export class BackendFrameworkLib { + public log = this.adapter.log; + public internalUser = this.adapter.internalUser; + + constructor( + private readonly adapter: BackendFrameworkAdapter, + private readonly config: BeatsManagementConfigType + ) { + this.validateConfig(); + } + + public getConfig(): BeatsManagementConfigType { + return this.config; + } + + public getUser(request: KibanaRequest): FrameworkUser { + return this.adapter.getUser(request); + } + + /** + * Expired `null` happens when we have no xpack info + */ + public get license() { + return { + type: this.adapter.info ? this.adapter.info.license.type : 'unknown', + expired: this.adapter.info ? this.adapter.info.license.expired : null, + }; + } + + public get securityIsEnabled() { + return this.adapter.info ? this.adapter.info.security.enabled : false; + } + + private validateConfig() { + const encryptionKey = this.config.encryptionKey; + + if (!encryptionKey) { + this.adapter.log( + 'Using a default encryption key for xpack.beats.encryptionKey. It is recommended that you set xpack.beats.encryptionKey in kibana.yml with a unique token' + ); + } + } +} diff --git a/x-pack/legacy/plugins/beats_management/server/lib/tags.ts b/x-pack/plugins/beats_management/server/lib/tags.ts similarity index 100% rename from x-pack/legacy/plugins/beats_management/server/lib/tags.ts rename to x-pack/plugins/beats_management/server/lib/tags.ts diff --git a/x-pack/legacy/plugins/beats_management/server/lib/tokens.ts b/x-pack/plugins/beats_management/server/lib/tokens.ts similarity index 92% rename from x-pack/legacy/plugins/beats_management/server/lib/tokens.ts rename to x-pack/plugins/beats_management/server/lib/tokens.ts index 759868810c0ce..366d3e8097980 100644 --- a/x-pack/legacy/plugins/beats_management/server/lib/tokens.ts +++ b/x-pack/plugins/beats_management/server/lib/tokens.ts @@ -58,7 +58,7 @@ export class CMTokensDomain { let expired = false; if (decode) { - const enrollmentTokenSecret = this.framework.getSetting('encryptionKey'); + const enrollmentTokenSecret = this.framework.getConfig().encryptionKey; try { verifyToken(recivedToken, enrollmentTokenSecret); @@ -98,7 +98,7 @@ export class CMTokensDomain { } public generateAccessToken() { - const enrollmentTokenSecret = this.framework.getSetting('encryptionKey'); + const enrollmentTokenSecret = this.framework.getConfig().encryptionKey; const tokenData = { created: moment().toJSON(), @@ -113,12 +113,12 @@ export class CMTokensDomain { numTokens: number = 1 ): Promise { const tokens = []; - const enrollmentTokensTtlInSeconds = this.framework.getSetting('enrollmentTokensTtlInSeconds'); + const enrollmentTokensTtlInSeconds = this.framework.getConfig().enrollmentTokensTtlInSeconds; const enrollmentTokenExpiration = moment() .add(enrollmentTokensTtlInSeconds, 'seconds') .toJSON(); - const enrollmentTokenSecret = this.framework.getSetting('encryptionKey'); + const enrollmentTokenSecret = this.framework.getConfig().encryptionKey; while (tokens.length < numTokens) { const tokenData = { diff --git a/x-pack/legacy/plugins/beats_management/server/lib/types.ts b/x-pack/plugins/beats_management/server/lib/types.ts similarity index 97% rename from x-pack/legacy/plugins/beats_management/server/lib/types.ts rename to x-pack/plugins/beats_management/server/lib/types.ts index 23894e3ffa6f7..d86aa8652fdbc 100644 --- a/x-pack/legacy/plugins/beats_management/server/lib/types.ts +++ b/x-pack/plugins/beats_management/server/lib/types.ts @@ -17,7 +17,7 @@ export type UserOrToken = FrameworkUser | string; export interface CMServerLibs { framework: BackendFrameworkLib; - database?: DatabaseAdapter; + database: DatabaseAdapter; beats: CMBeatsDomain; tags: CMTagsDomain; beatEvents: BeatEventsLib; diff --git a/x-pack/plugins/beats_management/server/plugin.ts b/x-pack/plugins/beats_management/server/plugin.ts index a82dbcb4a3a6e..92c2278148bc1 100644 --- a/x-pack/plugins/beats_management/server/plugin.ts +++ b/x-pack/plugins/beats_management/server/plugin.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { take } from 'rxjs/operators'; import { CoreSetup, CoreStart, @@ -13,6 +14,11 @@ import { import { SecurityPluginSetup } from '../../security/server'; import { LicensingPluginStart } from '../../licensing/server'; import { BeatsManagementConfigType } from '../common'; +import { CMServerLibs } from './lib/types'; +import { registerRoutes } from './routes'; +import { compose } from './lib/compose/kibana'; +import { INDEX_NAMES } from '../common/constants'; +import { beatsIndexTemplate } from './index_templates'; interface SetupDeps { security?: SecurityPluginSetup; @@ -22,18 +28,49 @@ interface StartDeps { licensing: LicensingPluginStart; } +declare module 'src/core/server' { + interface RequestHandlerContext { + beatsManagement?: CMServerLibs; + } +} + export class BeatsManagementPlugin implements Plugin<{}, {}, SetupDeps, StartDeps> { + private securitySetup?: SecurityPluginSetup; + private beatsLibs?: CMServerLibs; + constructor( private readonly initializerContext: PluginInitializerContext ) {} - public async setup(core: CoreSetup, plugins: SetupDeps) { - this.initializerContext.config.create(); + public async setup(core: CoreSetup, { security }: SetupDeps) { + this.securitySetup = security; + + const router = core.http.createRouter(); + registerRoutes(router); + + core.http.registerRouteHandlerContext('beatsManagement', (_, req) => { + return this.beatsLibs!; + }); return {}; } - public async start(core: CoreStart, { licensing }: StartDeps) { + public async start({ elasticsearch }: CoreStart, { licensing }: StartDeps) { + const config = await this.initializerContext.config.create().pipe(take(1)).toPromise(); + const logger = this.initializerContext.logger.get(); + const kibanaVersion = this.initializerContext.env.packageInfo.version; + + this.beatsLibs = compose({ + elasticsearch, + licensing, + security: this.securitySetup, + config, + logger, + kibanaVersion, + }); + + await this.beatsLibs.database.putTemplate(INDEX_NAMES.BEATS, beatsIndexTemplate); + return {}; } } diff --git a/x-pack/plugins/beats_management/server/routes/beats/configuration.ts b/x-pack/plugins/beats_management/server/routes/beats/configuration.ts new file mode 100644 index 0000000000000..1496e4bbfc99f --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/beats/configuration.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { ConfigurationBlock } from '../../../common/domain_types'; +import { ReturnTypeList } from '../../../common/return_types'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; + +export const registerGetBeatConfigurationRoute = (router: IRouter) => { + router.get( + { + path: '/api/beats/agent/{beatId}/configuration', + validate: { + params: schema.object({ + beatId: schema.string(), + }), + }, + options: { + authRequired: false, + }, + }, + wrapRouteWithSecurity({}, async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + const accessToken = request.headers['kbn-beats-access-token']; + if (!accessToken) { + return response.badRequest({ + body: 'beats access token required', + }); + } + const beatId = request.params.beatId; + + let configurationBlocks: ConfigurationBlock[]; + const beat = await beatsManagement.beats.getById( + beatsManagement.framework.internalUser, + beatId + ); + if (beat === null) { + return response.notFound({ + body: { + message: `Beat "${beatId}" not found`, + }, + }); + } + + const isAccessTokenValid = beat.access_token === accessToken; + if (!isAccessTokenValid) { + return response.unauthorized({ + body: { + message: 'Invalid access token', + }, + }); + } + + await beatsManagement.beats.update(beatsManagement.framework.internalUser, beat.id, { + last_checkin: new Date(), + }); + + if (beat.tags) { + const result = await beatsManagement.configurationBlocks.getForTags( + beatsManagement.framework.internalUser, + beat.tags, + -1 + ); + + configurationBlocks = result.blocks; + } else { + configurationBlocks = []; + } + + return response.ok({ + body: { + list: configurationBlocks, + success: true, + } as ReturnTypeList, + }); + }) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/beats/enroll.ts b/x-pack/plugins/beats_management/server/routes/beats/enroll.ts new file mode 100644 index 0000000000000..be8fff3b7c437 --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/beats/enroll.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ensureRawRequest } from '../../../../../../src/core/server/http/router'; +import { REQUIRED_LICENSES } from '../../../common/constants/security'; +import { BeatEnrollmentStatus } from '../../lib/types'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; + +export const registerBeatEnrollmentRoute = (router: IRouter) => { + // TODO: write to Kibana audit log file https://github.com/elastic/kibana/issues/26024 + router.post( + { + path: '/api/beats/agent/{beatId}', + validate: { + params: schema.object({ + beatId: schema.string(), + }), + body: schema.object( + { + host_name: schema.string(), + name: schema.string(), + type: schema.string(), + version: schema.string(), + }, + { unknowns: 'ignore' } + ), + }, + options: { + authRequired: false, + }, + }, + wrapRouteWithSecurity( + { + requiredLicense: REQUIRED_LICENSES, + }, + async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + + const { beatId } = request.params; + const enrollmentToken = request.headers['kbn-beats-enrollment-token'] as string; + if (!enrollmentToken) { + return response.badRequest({ + body: 'beats enrollment token required', + }); + } + + // TODO: fixme eventually, need to access `info.remoteAddress` from KibanaRequest. + const legacyRequest = ensureRawRequest(request); + + const { status, accessToken } = await beatsManagement.beats.enrollBeat( + enrollmentToken, + beatId, + legacyRequest.info.remoteAddress, + request.body + ); + + switch (status) { + case BeatEnrollmentStatus.ExpiredEnrollmentToken: + return response.badRequest({ + body: { + message: BeatEnrollmentStatus.ExpiredEnrollmentToken, + }, + }); + case BeatEnrollmentStatus.InvalidEnrollmentToken: + return response.badRequest({ + body: { + message: BeatEnrollmentStatus.InvalidEnrollmentToken, + }, + }); + case BeatEnrollmentStatus.Success: + default: + return response.ok({ + body: { + item: accessToken, + action: 'created', + success: true, + }, + }); + } + } + ) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/beats/events.ts b/x-pack/plugins/beats_management/server/routes/beats/events.ts new file mode 100644 index 0000000000000..b87e6d684228a --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/beats/events.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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'kibana/server'; +import { ReturnTypeBulkAction } from '../../../common/return_types'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; + +export const registerBeatEventsRoute = (router: IRouter) => { + router.post( + { + path: '/api/beats/{beatId}/events', + validate: { + params: schema.object({ + beatId: schema.string(), + }), + body: schema.arrayOf(schema.any(), { defaultValue: [] }), + }, + options: { + authRequired: false, + }, + }, + wrapRouteWithSecurity({}, async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + const accessToken = request.headers['kbn-beats-access-token']; + if (!accessToken) { + return response.badRequest({ + body: 'beats access token required', + }); + } + const beatId = request.params.beatId; + const events = request.body; + const internalUser = beatsManagement.framework.internalUser; + + const beat = await beatsManagement.beats.getById(internalUser, beatId); + if (beat === null) { + return response.badRequest({ + body: { + message: `Beat "${beatId}" not found`, + }, + }); + } + + const isAccessTokenValid = beat.access_token === accessToken; + if (!isAccessTokenValid) { + return response.unauthorized({ + body: { + message: `Invalid access token`, + }, + }); + } + + const results = await beatsManagement.beatEvents.log(internalUser, beat.id, events); + + return response.ok({ + body: { + results, + success: true, + } as ReturnTypeBulkAction, + }); + }) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/beats/get.ts b/x-pack/plugins/beats_management/server/routes/beats/get.ts new file mode 100644 index 0000000000000..8762f325e7484 --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/beats/get.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'kibana/server'; +import { CMBeat } from '../../../common/domain_types'; +import { ReturnTypeGet } from '../../../common/return_types'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; + +export const registerGetBeatRoute = (router: IRouter) => { + router.get( + { + path: '/api/beats/agent/{beatId}/{token?}', + validate: { + params: schema.object({ + beatId: schema.string(), + token: schema.string({ defaultValue: '' }), + }), + }, + }, + wrapRouteWithSecurity( + { requiredRoles: ['beats_admin'] }, + async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + const user = beatsManagement.framework.getUser(request); + const beatId = request.params.beatId; + + let beat: CMBeat | null; + if (beatId === 'unknown') { + beat = await beatsManagement.beats.getByEnrollmentToken(user, request.params.token); + if (beat === null) { + return response.ok({ body: { success: false } }); + } + } else { + beat = await beatsManagement.beats.getById(user, beatId); + if (beat === null) { + return response.notFound({ + body: { + message: 'Beat not found', + }, + }); + } + } + + delete beat.access_token; + + return response.ok({ + body: { + item: beat, + success: true, + } as ReturnTypeGet, + }); + } + ) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/beats/index.ts b/x-pack/plugins/beats_management/server/routes/beats/index.ts new file mode 100644 index 0000000000000..0ebdc932142ae --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/beats/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { registerGetBeatConfigurationRoute } from './configuration'; +export { registerBeatEnrollmentRoute } from './enroll'; +export { registerBeatEventsRoute } from './events'; +export { registerGetBeatRoute } from './get'; +export { registerListAgentsRoute } from './list'; +export { registerTagAssignmentsRoute } from './tag_assignment'; +export { registerTagRemovalsRoute } from './tag_removal'; +export { registerBeatUpdateRoute } from './update'; diff --git a/x-pack/plugins/beats_management/server/routes/beats/list.ts b/x-pack/plugins/beats_management/server/routes/beats/list.ts new file mode 100644 index 0000000000000..e4108238e3f2f --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/beats/list.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'kibana/server'; +import { REQUIRED_LICENSES } from '../../../common/constants/security'; +import { CMBeat } from '../../../common/domain_types'; +import { ReturnTypeList } from '../../../common/return_types'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; + +export const registerListAgentsRoute = (router: IRouter) => { + router.get( + { + path: '/api/beats/agents/{listByAndValue*}', + validate: { + params: schema.object({ + listByAndValue: schema.maybe(schema.string()), + }), + query: schema.object( + { + ESQuery: schema.maybe(schema.string()), + }, + { defaultValue: {} } + ), + }, + }, + wrapRouteWithSecurity( + { + requiredRoles: ['beats_admin'], + requiredLicense: REQUIRED_LICENSES, + }, + async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + const user = beatsManagement.framework.getUser(request); + + const listByAndValueParts = request.params.listByAndValue?.split('/') ?? []; + let listBy: string | null = null; + let listByValue: string | null = null; + if (listByAndValueParts.length === 2) { + listBy = listByAndValueParts[0]; + listByValue = listByAndValueParts[1]; + } + + let beats: CMBeat[]; + + switch (listBy) { + case 'tag': + beats = await beatsManagement.beats.getAllWithTag(user, listByValue || ''); + break; + + default: + beats = await beatsManagement.beats.getAll( + user, + request.query.ESQuery ? JSON.parse(request.query.ESQuery) : undefined + ); + + break; + } + + return response.ok({ + body: { list: beats, success: true, page: -1, total: -1 } as ReturnTypeList, + }); + } + ) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/beats/tag_assignment.ts b/x-pack/plugins/beats_management/server/routes/beats/tag_assignment.ts new file mode 100644 index 0000000000000..0397f8ec4398e --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/beats/tag_assignment.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { IRouter } from 'kibana/server'; +import { REQUIRED_LICENSES } from '../../../common/constants/security'; +import { ReturnTypeBulkAction } from '../../../common/return_types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import type { BeatsTagAssignment } from '../../../public/lib/adapters/beats/adapter_types'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; + +export const registerTagAssignmentsRoute = (router: IRouter) => { + // TODO: write to Kibana audit log file https://github.com/elastic/kibana/issues/26024 + router.post( + { + path: '/api/beats/agents_tags/assignments', + validate: { + body: schema.object({ + assignments: schema.arrayOf( + schema.object({ + beatId: schema.string(), + tag: schema.string(), + }), + { defaultValue: [] } + ), + }), + }, + }, + wrapRouteWithSecurity( + { + requiredLicense: REQUIRED_LICENSES, + requiredRoles: ['beats_admin'], + }, + async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + const user = beatsManagement.framework.getUser(request); + const { assignments }: { assignments: BeatsTagAssignment[] } = request.body; + + const result = await beatsManagement.beats.assignTagsToBeats(user, assignments); + + return response.ok({ + body: { + success: true, + results: result.assignments.map((assignment) => ({ + success: assignment.status && assignment.status >= 200 && assignment.status < 300, + error: + !assignment.status || assignment.status >= 300 + ? { + code: assignment.status || 400, + message: assignment.result, + } + : undefined, + result: + assignment.status && assignment.status >= 200 && assignment.status < 300 + ? { + message: assignment.result, + } + : undefined, + })), + } as ReturnTypeBulkAction, + }); + } + ) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/beats/tag_removal.ts b/x-pack/plugins/beats_management/server/routes/beats/tag_removal.ts new file mode 100644 index 0000000000000..a04ed81fb183b --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/beats/tag_removal.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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'kibana/server'; +import { REQUIRED_LICENSES } from '../../../common/constants/security'; +import { ReturnTypeBulkAction } from '../../../common/return_types'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; + +export const registerTagRemovalsRoute = (router: IRouter) => { + // TODO: write to Kibana audit log file https://github.com/elastic/kibana/issues/26024 + router.post( + { + path: '/api/beats/agents_tags/removals', + validate: { + body: schema.object({ + removals: schema.arrayOf( + schema.object({ + beatId: schema.string(), + tag: schema.string(), + }), + { defaultValue: [] } + ), + }), + }, + }, + wrapRouteWithSecurity( + { + requiredLicense: REQUIRED_LICENSES, + requiredRoles: ['beats_admin'], + }, + async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + const user = beatsManagement.framework.getUser(request); + const { removals } = request.body; + + const result = await beatsManagement.beats.removeTagsFromBeats(user, removals); + + return response.ok({ + body: { + success: true, + results: result.removals.map((removal) => ({ + success: removal.status && removal.status >= 200 && removal.status < 300, + error: + !removal.status || removal.status >= 300 + ? { + code: removal.status || 400, + message: removal.result, + } + : undefined, + result: + removal.status && removal.status >= 200 && removal.status < 300 + ? { + message: removal.result, + } + : undefined, + })), + } as ReturnTypeBulkAction, + }); + } + ) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/beats/update.ts b/x-pack/plugins/beats_management/server/routes/beats/update.ts new file mode 100644 index 0000000000000..21bd6555b28dd --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/beats/update.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'kibana/server'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ensureRawRequest } from '../../../../../../src/core/server/http/router'; +import { REQUIRED_LICENSES } from '../../../common/constants/security'; +import { CMBeat } from '../../../common/domain_types'; +import { ReturnTypeUpdate } from '../../../common/return_types'; +import { internalUser } from '../../lib/adapters/framework/adapter_types'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; + +export const registerBeatUpdateRoute = (router: IRouter) => { + // TODO: write to Kibana audit log file (include who did the verification as well) https://github.com/elastic/kibana/issues/26024 + router.put( + { + path: '/api/beats/agent/{beatId}', + validate: { + params: schema.object({ + beatId: schema.string(), + }), + body: schema.object( + { + active: schema.maybe(schema.boolean()), + ephemeral_id: schema.maybe(schema.string()), + host_name: schema.maybe(schema.string()), + local_configuration_yml: schema.maybe(schema.string()), + metadata: schema.maybe(schema.recordOf(schema.string(), schema.any())), + name: schema.maybe(schema.string()), + type: schema.maybe(schema.string()), + version: schema.maybe(schema.string()), + }, + { defaultValue: {} } + ), + }, + }, + wrapRouteWithSecurity( + { + requiredLicense: REQUIRED_LICENSES, + requiredRoles: ['beats_admin'], + }, + async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + const accessToken = request.headers['kbn-beats-access-token'] as string; + const { beatId } = request.params; + const user = beatsManagement.framework.getUser(request); + const userOrToken = accessToken || user; + + // TODO: fixme eventually, need to access `info.remoteAddress` from KibanaRequest. + const legacyRequest = ensureRawRequest(request); + const remoteAddress = legacyRequest.info.remoteAddress; + + if (user.kind === 'unauthenticated' && request.body.active !== undefined) { + return response.unauthorized({ + body: { + message: 'access-token is not a valid auth type to change beat status', + }, + }); + } + + const status = await beatsManagement.beats.update(userOrToken, beatId, { + ...request.body, + host_ip: remoteAddress, + }); + + switch (status) { + case 'beat-not-found': + return response.notFound({ + body: { + message: 'Beat not found', + }, + }); + case 'invalid-access-token': + return response.unauthorized({ + body: { + message: 'Invalid access token', + }, + }); + } + + const beat = await beatsManagement.beats.getById(internalUser, beatId); + if (!beat) { + return response.notFound({ + body: { + message: 'Beat not found', + }, + }); + } + + return response.ok({ + body: { + item: beat, + action: 'updated', + success: true, + } as ReturnTypeUpdate, + }); + } + ) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/configurations/delete.ts b/x-pack/plugins/beats_management/server/routes/configurations/delete.ts new file mode 100644 index 0000000000000..b60d3bd2d5a94 --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/configurations/delete.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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; +import { REQUIRED_LICENSES } from '../../../common/constants/security'; +import { ReturnTypeBulkDelete } from '../../../common/return_types'; + +export const registerDeleteConfigurationBlocksRoute = (router: IRouter) => { + router.delete( + { + path: '/api/beats/configurations/{ids}', + validate: { + params: schema.object({ + ids: schema.string(), + }), + }, + }, + wrapRouteWithSecurity( + { + requiredLicense: REQUIRED_LICENSES, + requiredRoles: ['beats_admin'], + }, + async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + const ids = request.params.ids.split(',').filter((id) => id.length > 0); + const user = beatsManagement.framework.getUser(request); + + const results = await beatsManagement.configurationBlocks.delete(user, ids); + return response.ok({ + body: { + success: true, + results: results.map((result) => ({ + success: result.success, + action: 'deleted', + error: result.success ? undefined : { message: result.reason }, + })), + } as ReturnTypeBulkDelete, + }); + } + ) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/configurations/get.ts b/x-pack/plugins/beats_management/server/routes/configurations/get.ts new file mode 100644 index 0000000000000..6f422ca9ca8bd --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/configurations/get.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; +import { REQUIRED_LICENSES } from '../../../common/constants/security'; +import { ConfigurationBlock } from '../../../common/domain_types'; +import { ReturnTypeList } from '../../../common/return_types'; + +export const registerGetConfigurationBlocksRoute = (router: IRouter) => { + router.get( + { + path: '/api/beats/configurations/{tagIds}/{page?}', + validate: { + params: schema.object({ + tagIds: schema.string(), + page: schema.maybe(schema.number()), + }), + }, + }, + wrapRouteWithSecurity( + { + requiredLicense: REQUIRED_LICENSES, + requiredRoles: ['beats_admin'], + }, + async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + const tagIds = request.params.tagIds.split(',').filter((id) => id.length > 0); + const user = beatsManagement.framework.getUser(request); + const result = await beatsManagement.configurationBlocks.getForTags( + user, + tagIds, + request.params.page, + 5 + ); + + return response.ok({ + body: { + page: result.page, + total: result.total, + list: result.blocks, + success: true, + } as ReturnTypeList, + }); + } + ) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/configurations/index.ts b/x-pack/plugins/beats_management/server/routes/configurations/index.ts new file mode 100644 index 0000000000000..490d4a7cd7328 --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/configurations/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { registerGetConfigurationBlocksRoute } from './get'; +export { registerDeleteConfigurationBlocksRoute } from './delete'; +export { registerUpsertConfigurationBlocksRoute } from './upsert'; diff --git a/x-pack/plugins/beats_management/server/routes/configurations/upsert.ts b/x-pack/plugins/beats_management/server/routes/configurations/upsert.ts new file mode 100644 index 0000000000000..e235b172e7d0b --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/configurations/upsert.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PathReporter } from 'io-ts/lib/PathReporter'; +import { isLeft } from 'fp-ts/lib/Either'; +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { REQUIRED_LICENSES } from '../../../common/constants'; +import { + ConfigurationBlock, + createConfigurationBlockInterface, +} from '../../../common/domain_types'; +import { ReturnTypeBulkUpsert } from '../../../common/return_types'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; + +export const registerUpsertConfigurationBlocksRoute = (router: IRouter) => { + // TODO: write to Kibana audit log file + router.put( + { + path: '/api/beats/configurations', + validate: { + body: schema.arrayOf(schema.recordOf(schema.string(), schema.any()), { defaultValue: [] }), + }, + }, + wrapRouteWithSecurity( + { + requiredLicense: REQUIRED_LICENSES, + requiredRoles: ['beats_admin'], + }, + async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + const user = beatsManagement.framework.getUser(request); + const input = request.body as ConfigurationBlock[]; + + const result = await Promise.all( + input.map(async (block: ConfigurationBlock) => { + const assertData = createConfigurationBlockInterface().decode(block); + if (isLeft(assertData)) { + return { + error: `Error parsing block info, ${PathReporter.report(assertData)[0]}`, + }; + } + + const { blockID, success, error } = await beatsManagement.configurationBlocks.save( + user, + block + ); + if (error) { + return { success, error }; + } + + return { success, blockID }; + }) + ); + + return response.ok({ + body: { + results: result.map((r) => ({ + success: r.success as boolean, + // TODO: we need to surface this data, not hard coded + action: 'created' as 'created' | 'updated', + })), + success: true, + } as ReturnTypeBulkUpsert, + }); + } + ) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/index.ts b/x-pack/plugins/beats_management/server/routes/index.ts new file mode 100644 index 0000000000000..423ecc85a5798 --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/index.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'src/core/server'; +import { + registerDeleteConfigurationBlocksRoute, + registerGetConfigurationBlocksRoute, + registerUpsertConfigurationBlocksRoute, +} from './configurations'; +import { registerCreateTokenRoute } from './tokens'; +import { + registerSetTagRoute, + registerListTagsRoute, + registerGetTagsWithIdsRoute, + registerDeleteTagsWithIdsRoute, + registerAssignableTagsRoute, +} from './tags'; +import { + registerBeatUpdateRoute, + registerTagRemovalsRoute, + registerTagAssignmentsRoute, + registerListAgentsRoute, + registerGetBeatRoute, + registerBeatEventsRoute, + registerBeatEnrollmentRoute, + registerGetBeatConfigurationRoute, +} from './beats'; + +export const registerRoutes = (router: IRouter) => { + // configurations + registerGetConfigurationBlocksRoute(router); + registerDeleteConfigurationBlocksRoute(router); + registerUpsertConfigurationBlocksRoute(router); + // beats + registerBeatUpdateRoute(router); + registerTagRemovalsRoute(router); + registerTagAssignmentsRoute(router); + registerListAgentsRoute(router); + registerGetBeatRoute(router); + registerBeatEventsRoute(router); + registerBeatEnrollmentRoute(router); + registerGetBeatConfigurationRoute(router); + // tags + registerSetTagRoute(router); + registerListTagsRoute(router); + registerGetTagsWithIdsRoute(router); + registerDeleteTagsWithIdsRoute(router); + registerAssignableTagsRoute(router); + // tokens + registerCreateTokenRoute(router); +}; diff --git a/x-pack/plugins/beats_management/server/routes/tags/assignable.ts b/x-pack/plugins/beats_management/server/routes/tags/assignable.ts new file mode 100644 index 0000000000000..60d4748bf1fa6 --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/tags/assignable.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { IRouter } from 'src/core/server'; +import { flatten } from 'lodash'; +import { REQUIRED_LICENSES } from '../../../common/constants/security'; +import { BeatTag } from '../../../common/domain_types'; +import { ReturnTypeBulkGet } from '../../../common/return_types'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; + +export const registerAssignableTagsRoute = (router: IRouter) => { + router.get( + { + path: '/api/beats/tags/assignable/{beatIds}', + validate: { + params: schema.object({ + beatIds: schema.string(), + }), + }, + }, + wrapRouteWithSecurity( + { + requiredLicense: REQUIRED_LICENSES, + requiredRoles: ['beats_admin'], + }, + async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + const user = beatsManagement.framework.getUser(request); + const beatIds = request.params.beatIds.split(',').filter((id) => id.length > 0); + + const beats = await beatsManagement.beats.getByIds(user, beatIds); + const tags = await beatsManagement.tags.getNonConflictingTags( + user, + flatten(beats.map((beat) => beat.tags)) + ); + + return response.ok({ + body: { + items: tags, + success: true, + } as ReturnTypeBulkGet, + }); + } + ) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/tags/delete.ts b/x-pack/plugins/beats_management/server/routes/tags/delete.ts new file mode 100644 index 0000000000000..78d0c80d42060 --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/tags/delete.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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'kibana/server'; +import { REQUIRED_LICENSES } from '../../../common/constants/security'; +import { ReturnTypeBulkDelete } from '../../../common/return_types'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; + +export const registerDeleteTagsWithIdsRoute = (router: IRouter) => { + router.delete( + { + path: '/api/beats/tags/{tagIds}', + validate: { + params: schema.object({ + tagIds: schema.string(), + }), + }, + }, + wrapRouteWithSecurity( + { + requiredLicense: REQUIRED_LICENSES, + requiredRoles: ['beats_admin'], + }, + async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + const user = beatsManagement.framework.getUser(request); + const tagIds = request.params.tagIds.split(',').filter((id) => id.length > 0); + + const success = await beatsManagement.tags.delete(user, tagIds); + + return response.ok({ + body: { + results: tagIds.map(() => ({ + success, + action: 'deleted', + })), + success, + } as ReturnTypeBulkDelete, + }); + } + ) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/tags/get.ts b/x-pack/plugins/beats_management/server/routes/tags/get.ts new file mode 100644 index 0000000000000..48da829aa09e5 --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/tags/get.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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { REQUIRED_LICENSES } from '../../../common/constants/security'; +import { BeatTag } from '../../../common/domain_types'; +import { ReturnTypeBulkGet } from '../../../common/return_types'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; + +export const registerGetTagsWithIdsRoute = (router: IRouter) => { + router.get( + { + path: '/api/beats/tags/{tagIds}', + validate: { + params: schema.object({ + tagIds: schema.string(), + }), + }, + }, + wrapRouteWithSecurity( + { + requiredLicense: REQUIRED_LICENSES, + requiredRoles: ['beats_admin'], + }, + async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + const user = beatsManagement.framework.getUser(request); + const tagIds = request.params.tagIds.split(',').filter((id) => id.length > 0); + + const tags = await beatsManagement.tags.getWithIds(user, tagIds); + + return response.ok({ + body: { + items: tags, + success: true, + } as ReturnTypeBulkGet, + }); + } + ) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/tags/index.ts b/x-pack/plugins/beats_management/server/routes/tags/index.ts new file mode 100644 index 0000000000000..2f0590026ca7e --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/tags/index.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. + */ + +export { registerAssignableTagsRoute } from './assignable'; +export { registerDeleteTagsWithIdsRoute } from './delete'; +export { registerGetTagsWithIdsRoute } from './get'; +export { registerListTagsRoute } from './list'; +export { registerSetTagRoute } from './set'; diff --git a/x-pack/plugins/beats_management/server/routes/tags/list.ts b/x-pack/plugins/beats_management/server/routes/tags/list.ts new file mode 100644 index 0000000000000..ce913cda337c5 --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/tags/list.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'kibana/server'; +import { REQUIRED_LICENSES } from '../../../common/constants/security'; +import { BeatTag } from '../../../common/domain_types'; +import { ReturnTypeList } from '../../../common/return_types'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; + +export const registerListTagsRoute = (router: IRouter) => { + router.get( + { + path: '/api/beats/tags', + validate: { + query: schema.object( + { + ESQuery: schema.maybe(schema.string()), + }, + { defaultValue: {} } + ), + }, + }, + wrapRouteWithSecurity( + { + requiredLicense: REQUIRED_LICENSES, + requiredRoles: ['beats_admin'], + }, + async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + const user = beatsManagement.framework.getUser(request); + + const tags = await beatsManagement.tags.getAll( + user, + request.query && request.query.ESQuery ? JSON.parse(request.query.ESQuery) : undefined + ); + + return response.ok({ + body: { + list: tags, + success: true, + page: -1, + total: -1, + } as ReturnTypeList, + }); + } + ) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/tags/set.ts b/x-pack/plugins/beats_management/server/routes/tags/set.ts new file mode 100644 index 0000000000000..ef9e181514a55 --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/tags/set.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 { IRouter } from 'src/core/server'; +import { REQUIRED_LICENSES } from '../../../common/constants'; +import { BeatTag } from '../../../common/domain_types'; +import { ReturnTypeUpsert } from '../../../common/return_types'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; + +export const registerSetTagRoute = (router: IRouter) => { + // TODO: write to Kibana audit log file + router.put( + { + path: '/api/beats/tag/{tagId}', + validate: { + params: schema.object({ + tagId: schema.string(), + }), + body: schema.object( + { + color: schema.maybe(schema.string()), + name: schema.maybe(schema.string()), + }, + { defaultValue: {} } + ), + }, + }, + wrapRouteWithSecurity( + { + requiredLicense: REQUIRED_LICENSES, + requiredRoles: ['beats_admin'], + }, + async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + const user = beatsManagement.framework.getUser(request); + + const config = { + id: request.params.tagId, + name: request.params.tagId, + color: '#DD0A73', + hasConfigurationBlocksTypes: [], + ...request.body, + }; + const id = await beatsManagement.tags.upsertTag(user, config); + const tag = await beatsManagement.tags.getWithIds(user, [id]); + + // TODO the action needs to be surfaced + return response.ok({ + body: { + success: true, + item: tag[0], + action: 'created', + } as ReturnTypeUpsert, + }); + } + ) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/tokens/create.ts b/x-pack/plugins/beats_management/server/routes/tokens/create.ts new file mode 100644 index 0000000000000..2fd7d4614c570 --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/tokens/create.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { IRouter } from 'src/core/server'; +import { REQUIRED_LICENSES } from '../../../common/constants/security'; +import { ReturnTypeBulkCreate } from '../../../common/return_types'; +import { wrapRouteWithSecurity } from '../wrap_route_with_security'; + +const DEFAULT_NUM_TOKENS = 1; + +export const registerCreateTokenRoute = (router: IRouter) => { + // TODO: write to Kibana audit log file + router.post( + { + path: '/api/beats/enrollment_tokens', + validate: { + body: schema.nullable( + schema.object({ + num_tokens: schema.number({ defaultValue: DEFAULT_NUM_TOKENS, min: 1 }), + }) + ), + }, + }, + wrapRouteWithSecurity( + { + requiredLicense: REQUIRED_LICENSES, + requiredRoles: ['beats_admin'], + }, + async (context, request, response) => { + const beatsManagement = context.beatsManagement!; + const user = beatsManagement.framework.getUser(request); + + const numTokens = request.body?.num_tokens ?? DEFAULT_NUM_TOKENS; + try { + const tokens = await beatsManagement.tokens.createEnrollmentTokens(user, numTokens); + return response.ok({ + body: { + results: tokens.map((token) => ({ + item: token, + success: true, + action: 'created', + })), + success: true, + } as ReturnTypeBulkCreate, + }); + } catch (err) { + beatsManagement.framework.log(err.message); + return response.internalError({ + body: { + message: 'An error occurred, please check your Kibana logs', + }, + }); + } + } + ) + ); +}; diff --git a/x-pack/plugins/beats_management/server/routes/tokens/index.ts b/x-pack/plugins/beats_management/server/routes/tokens/index.ts new file mode 100644 index 0000000000000..3e34fff0a6c6b --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/tokens/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 { registerCreateTokenRoute } from './create'; diff --git a/x-pack/plugins/beats_management/server/routes/wrap_route_with_security.ts b/x-pack/plugins/beats_management/server/routes/wrap_route_with_security.ts new file mode 100644 index 0000000000000..ad4f8080127b2 --- /dev/null +++ b/x-pack/plugins/beats_management/server/routes/wrap_route_with_security.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + KibanaRequest, + KibanaResponseFactory, + RequestHandler, + RequestHandlerContext, + RouteMethod, +} from 'src/core/server'; +import { difference } from 'lodash'; + +export function wrapRouteWithSecurity( + { + requiredLicense = [], + requiredRoles = [], + }: { requiredLicense?: string[]; requiredRoles?: string[] }, + handler: RequestHandler +): RequestHandler { + return async ( + context: RequestHandlerContext, + request: KibanaRequest, + response: KibanaResponseFactory + ) => { + const beatsManagement = context.beatsManagement!; + const license = beatsManagement.framework.license; + const user = beatsManagement.framework.getUser(request); + + if ( + requiredLicense.length > 0 && + (license.expired || !requiredLicense.includes(license.type)) + ) { + return response.forbidden({ + body: { + message: `Your ${license.type} license does not support this API or is expired. Please upgrade your license.`, + }, + }); + } + + if (requiredRoles.length > 0) { + if (user.kind !== 'authenticated') { + return response.forbidden({ + body: { + message: `Request must be authenticated`, + }, + }); + } + + if ( + user.kind === 'authenticated' && + !user.roles.includes('superuser') && + difference(requiredRoles, user.roles).length !== 0 + ) { + return response.forbidden({ + body: { + message: `Request must be authenticated by a user with one of the following user roles: ${requiredRoles.join( + ',' + )}`, + }, + }); + } + } + + return handler(context, request, response); + }; +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/location.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/location.ts index 4a01df3b0ac50..5c0ca74f5225a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/location.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/location.ts @@ -10,7 +10,10 @@ import { getFunctionHelp } from '../../../i18n'; const noop = () => {}; interface Return extends Datatable { - columns: [{ name: 'latitude'; type: 'number' }, { name: 'longitude'; type: 'number' }]; + columns: [ + { id: 'latitude'; name: 'latitude'; meta: { type: 'number' } }, + { id: 'longitude'; name: 'longitude'; meta: { type: 'number' } } + ]; rows: [{ latitude: number; longitude: number }]; } @@ -30,8 +33,8 @@ export function location(): ExpressionFunctionDefinition<'location', null, {}, P return resolve({ type: 'datatable', columns: [ - { name: 'latitude', type: 'number' }, - { name: 'longitude', type: 'number' }, + { id: 'latitude', name: 'latitude', meta: { type: 'number' } }, + { id: 'longitude', name: 'longitude', meta: { type: 'number' } }, ], rows: [{ latitude, longitude }], }); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_tables.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_tables.ts index b5e5836dc5331..ffb76500a35d6 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_tables.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/__tests__/fixtures/test_tables.ts @@ -16,24 +16,29 @@ const testTable: Datatable = { type: 'datatable', columns: [ { + id: 'name', name: 'name', - type: 'string', + meta: { type: 'string' }, }, { + id: 'time', name: 'time', - type: 'date', + meta: { type: 'date' }, }, { + id: 'price', name: 'price', - type: 'number', + meta: { type: 'number' }, }, { + id: 'quantity', name: 'quantity', - type: 'number', + meta: { type: 'number' }, }, { + id: 'in_stock', name: 'in_stock', - type: 'boolean', + meta: { type: 'boolean' }, }, ], rows: [ @@ -107,24 +112,29 @@ const stringTable: Datatable = { type: 'datatable', columns: [ { + id: 'name', name: 'name', - type: 'string', + meta: { type: 'string' }, }, { + id: 'time', name: 'time', - type: 'string', + meta: { type: 'string' }, }, { + id: 'price', name: 'price', - type: 'string', + meta: { type: 'string' }, }, { + id: 'quantity', name: 'quantity', - type: 'string', + meta: { type: 'string' }, }, { + id: 'in_stock', name: 'in_stock', - type: 'string', + meta: { type: 'string' }, }, ], rows: [ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.test.js index c46b2277859d0..a8c01f0b2791f 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.test.js @@ -38,7 +38,7 @@ describe('alterColumn', () => { const arbitraryRowIndex = 6; expect(newColumn.name).not.toBe(originalColumn.name); - expect(newColumn.type).not.toBe(originalColumn.type); + expect(newColumn.meta.type).not.toBe(originalColumn.meta.type); expect(typeof dateToString.rows[arbitraryRowIndex].timeISO).toBe('string'); expect(new Date(dateToString.rows[arbitraryRowIndex].timeISO)).toEqual( new Date(testTable.rows[arbitraryRowIndex].time) @@ -60,7 +60,7 @@ describe('alterColumn', () => { it('converts the column to the specified type', () => { const dateToString = fn(testTable, { column: 'time', type: 'string', name: 'timeISO' }); - expect(typeof dateToString.columns[timeColumnIndex].type).toBe('string'); + expect(typeof dateToString.columns[timeColumnIndex].meta.type).toBe('string'); expect(typeof dateToString.rows[timeColumnIndex].timeISO).toBe('string'); expect(new Date(dateToString.rows[timeColumnIndex].timeISO)).toEqual( new Date(testTable.rows[timeColumnIndex].time) @@ -69,10 +69,10 @@ describe('alterColumn', () => { it('does not change column if type is not specified', () => { const unconvertedColumn = fn(testTable, { column: 'price', name: 'foo' }); - const originalType = testTable.columns[priceColumnIndex].type; + const originalType = testTable.columns[priceColumnIndex].meta.type; const arbitraryRowIndex = 2; - expect(unconvertedColumn.columns[priceColumnIndex].type).toBe(originalType); + expect(unconvertedColumn.columns[priceColumnIndex].meta.type).toBe(originalType); expect(typeof unconvertedColumn.rows[arbitraryRowIndex].foo).toBe(originalType); }); @@ -99,7 +99,7 @@ describe('alterColumn', () => { const arbitraryRowIndex = 5; expect(newColumn.name).not.toBe(originalColumn.name); - expect(newColumn.type).not.toBe(originalColumn.type); + expect(newColumn.meta.type).not.toBe(originalColumn.meta.type); expect(typeof overwriteName.rows[arbitraryRowIndex].name).toBe('string'); expect(new Date(overwriteName.rows[arbitraryRowIndex].name)).toEqual( new Date(testTable.rows[arbitraryRowIndex].time) @@ -122,7 +122,7 @@ describe('alterColumn', () => { const numberToString = fn(testTable, { column: 'price', type: 'string' }); expect(numberToString.columns[priceColumnIndex]).toHaveProperty('name', 'price'); - expect(numberToString.columns[priceColumnIndex]).toHaveProperty('type', 'string'); + expect(numberToString.columns[priceColumnIndex].meta).toHaveProperty('type', 'string'); expect(typeof numberToString.rows[arbitraryRowIndex].price).toBe('string'); expect(numberToString.rows[arbitraryRowIndex].price).toBe( @@ -132,7 +132,7 @@ describe('alterColumn', () => { const stringToNumber = fn(numberToString, { column: 'price', type: 'number' }); expect(stringToNumber.columns[priceColumnIndex]).toHaveProperty('name', 'price'); - expect(stringToNumber.columns[priceColumnIndex]).toHaveProperty('type', 'number'); + expect(stringToNumber.columns[priceColumnIndex].meta).toHaveProperty('type', 'number'); expect(typeof stringToNumber.rows[arbitraryRowIndex].price).toBe('number'); @@ -146,7 +146,7 @@ describe('alterColumn', () => { const dateToString = fn(testTable, { column: 'time', type: 'string' }); expect(dateToString.columns[timeColumnIndex]).toHaveProperty('name', 'time'); - expect(dateToString.columns[timeColumnIndex]).toHaveProperty('type', 'string'); + expect(dateToString.columns[timeColumnIndex].meta).toHaveProperty('type', 'string'); expect(typeof dateToString.rows[arbitraryRowIndex].time).toBe('string'); expect(new Date(dateToString.rows[arbitraryRowIndex].time)).toEqual( @@ -156,7 +156,7 @@ describe('alterColumn', () => { const stringToDate = fn(dateToString, { column: 'time', type: 'date' }); expect(stringToDate.columns[timeColumnIndex]).toHaveProperty('name', 'time'); - expect(stringToDate.columns[timeColumnIndex]).toHaveProperty('type', 'date'); + expect(stringToDate.columns[timeColumnIndex].meta).toHaveProperty('type', 'date'); expect(new Date(stringToDate.rows[timeColumnIndex].time)).toBeInstanceOf(Date); expect(new Date(stringToDate.rows[timeColumnIndex].time)).toEqual( @@ -169,7 +169,7 @@ describe('alterColumn', () => { const arbitraryRowIndex = 1; expect(dateToNumber.columns[timeColumnIndex]).toHaveProperty('name', 'time'); - expect(dateToNumber.columns[timeColumnIndex]).toHaveProperty('type', 'number'); + expect(dateToNumber.columns[timeColumnIndex].meta).toHaveProperty('type', 'number'); expect(typeof dateToNumber.rows[arbitraryRowIndex].time).toBe('number'); expect(dateToNumber.rows[arbitraryRowIndex].time).toEqual( @@ -179,7 +179,7 @@ describe('alterColumn', () => { const numberToDate = fn(dateToNumber, { column: 'time', type: 'date' }); expect(numberToDate.columns[timeColumnIndex]).toHaveProperty('name', 'time'); - expect(numberToDate.columns[timeColumnIndex]).toHaveProperty('type', 'date'); + expect(numberToDate.columns[timeColumnIndex].meta).toHaveProperty('type', 'date'); expect(new Date(numberToDate.rows[arbitraryRowIndex].time)).toBeInstanceOf(Date); expect(new Date(numberToDate.rows[arbitraryRowIndex].time)).toEqual( @@ -192,7 +192,7 @@ describe('alterColumn', () => { const arbitraryRowIndex = 7; expect(booleanToNumber.columns[inStockColumnIndex]).toHaveProperty('name', 'in_stock'); - expect(booleanToNumber.columns[inStockColumnIndex]).toHaveProperty('type', 'number'); + expect(booleanToNumber.columns[inStockColumnIndex].meta).toHaveProperty('type', 'number'); expect(typeof booleanToNumber.rows[arbitraryRowIndex].in_stock).toBe('number'); expect(booleanToNumber.rows[arbitraryRowIndex].in_stock).toEqual( @@ -202,7 +202,7 @@ describe('alterColumn', () => { const numberToBoolean = fn(booleanToNumber, { column: 'in_stock', type: 'boolean' }); expect(numberToBoolean.columns[inStockColumnIndex]).toHaveProperty('name', 'in_stock'); - expect(numberToBoolean.columns[inStockColumnIndex]).toHaveProperty('type', 'boolean'); + expect(numberToBoolean.columns[inStockColumnIndex].meta).toHaveProperty('type', 'boolean'); expect(typeof numberToBoolean.rows[arbitraryRowIndex].in_stock).toBe('boolean'); expect(numberToBoolean.rows[arbitraryRowIndex].in_stock).toEqual( @@ -216,7 +216,7 @@ describe('alterColumn', () => { expect(stringToNull.columns[nameColumnIndex]).toHaveProperty('name', 'name'); - expect(stringToNull.columns[nameColumnIndex]).toHaveProperty('type', 'null'); + expect(stringToNull.columns[nameColumnIndex].meta).toHaveProperty('type', 'null'); expect(stringToNull.rows[arbitraryRowIndex].name).toBe(null); }); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.ts index 68c1957c808a3..531959f6bc63a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/alterColumn.ts @@ -57,14 +57,14 @@ export function alterColumn(): ExpressionFunctionDefinition< } const name = args.name || column.name; - const type = args.type || column.type; + const type = args.type || column.meta.type; const columns = input.columns.reduce((all: DatatableColumn[], col) => { if (col.name !== args.name) { if (col.name !== column.name) { all.push(col); } else { - all.push({ name, type }); + all.push({ id: name, name, meta: { type } }); } } return all; @@ -76,7 +76,7 @@ export function alterColumn(): ExpressionFunctionDefinition< handler = (function getHandler() { switch (type) { case 'string': - if (column.type === 'date') { + if (column.meta.type === 'date') { return (v: string) => new Date(v).toISOString(); } return String; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/as.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/as.test.js index 2fc9491f6b5b7..49d14622e80f0 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/as.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/as.test.js @@ -13,19 +13,19 @@ describe('as', () => { it('returns a datatable with a single column and single row', () => { expect(fn('foo', { name: 'bar' })).toEqual({ type: 'datatable', - columns: [{ name: 'bar', type: 'string' }], + columns: [{ id: 'bar', name: 'bar', meta: { type: 'string' } }], rows: [{ bar: 'foo' }], }); expect(fn(2, { name: 'num' })).toEqual({ type: 'datatable', - columns: [{ name: 'num', type: 'number' }], + columns: [{ id: 'num', name: 'num', meta: { type: 'number' } }], rows: [{ num: 2 }], }); expect(fn(true, { name: 'bool' })).toEqual({ type: 'datatable', - columns: [{ name: 'bool', type: 'boolean' }], + columns: [{ id: 'bool', name: 'bool', meta: { type: 'boolean' } }], rows: [{ bool: true }], }); }); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/as.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/as.ts index 9c10e85227398..d8fd948c12b2e 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/as.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/as.ts @@ -34,8 +34,9 @@ export function asFn(): ExpressionFunctionDefinition<'as', Input, Arguments, Dat type: 'datatable', columns: [ { + id: args.name, name: args.name, - type: getType(input), + meta: { type: getType(input) }, }, ], rows: [ diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.test.js index e5ef06d1503ee..652d61fd77398 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.test.js @@ -14,13 +14,17 @@ describe('mapColumn', () => { const fn = functionWrapper(mapColumn); it('returns a datatable with a new column with the values from mapping a function over each row in a datatable', () => { - return fn(testTable, { name: 'pricePlusTwo', expression: pricePlusTwo }).then((result) => { + return fn(testTable, { + id: 'pricePlusTwo', + name: 'pricePlusTwo', + expression: pricePlusTwo, + }).then((result) => { const arbitraryRowIndex = 2; expect(result.type).toBe('datatable'); expect(result.columns).toEqual([ ...testTable.columns, - { name: 'pricePlusTwo', type: 'number' }, + { id: 'pricePlusTwo', name: 'pricePlusTwo', meta: { type: 'number' } }, ]); expect(result.columns[result.columns.length - 1]).toHaveProperty('name', 'pricePlusTwo'); expect(result.rows[arbitraryRowIndex]).toHaveProperty('pricePlusTwo'); @@ -35,7 +39,7 @@ describe('mapColumn', () => { expect(result.type).toBe('datatable'); expect(result.columns).toHaveLength(testTable.columns.length); expect(result.columns[nameColumnIndex]).toHaveProperty('name', 'name'); - expect(result.columns[nameColumnIndex]).toHaveProperty('type', 'number'); + expect(result.columns[nameColumnIndex].meta).toHaveProperty('type', 'number'); expect(result.rows[arbitraryRowIndex]).toHaveProperty('name', 202); }); }); @@ -45,7 +49,7 @@ describe('mapColumn', () => { expect(result.type).toBe('datatable'); expect(result.columns).toHaveLength(1); expect(result.columns[0]).toHaveProperty('name', 'name'); - expect(result.columns[0]).toHaveProperty('type', 'null'); + expect(result.columns[0].meta).toHaveProperty('type', 'null'); }); }); @@ -56,7 +60,7 @@ describe('mapColumn', () => { const arbitraryRowIndex = 8; expect(result.columns[emptyColumnIndex]).toHaveProperty('name', 'empty'); - expect(result.columns[emptyColumnIndex]).toHaveProperty('type', 'null'); + expect(result.columns[emptyColumnIndex].meta).toHaveProperty('type', 'null'); expect(result.rows[arbitraryRowIndex]).toHaveProperty('empty', null); }); }); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.ts index 7dd309cba5c64..6d6a432e5553e 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/mapColumn.ts @@ -59,7 +59,7 @@ export function mapColumn(): ExpressionFunctionDefinition< return Promise.all(rowPromises).then((rows) => { const existingColumnIndex = columns.findIndex(({ name }) => name === args.name); const type = rows.length ? getType(rows[0][args.name]) : 'null'; - const newColumn = { name: args.name, type }; + const newColumn = { id: args.name, name: args.name, meta: { type } }; if (existingColumnIndex === -1) { columns.push(newColumn); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.test.js index 2dfb9eeea76bc..07d436007c816 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/ply.test.js @@ -16,7 +16,7 @@ const averagePrice = (datatable) => { return Promise.resolve({ type: 'datatable', - columns: [{ name: 'average_price', type: 'number' }], + columns: [{ id: 'average_price', name: 'average_price', meta: { type: 'number' } }], rows: [{ average_price: average }], }); }; @@ -26,7 +26,7 @@ const doublePrice = (datatable) => { return Promise.resolve({ type: 'datatable', - columns: [{ name: 'double_price', type: 'number' }], + columns: [{ id: 'double_price', name: 'double_price', meta: { type: 'number' } }], rows: newRows, }); }; @@ -34,7 +34,7 @@ const doublePrice = (datatable) => { const rowCount = (datatable) => { return Promise.resolve({ type: 'datatable', - columns: [{ name: 'row_count', type: 'number' }], + columns: [{ id: 'row_count', name: 'row_count', meta: { type: 'number' } }], rows: [ { row_count: datatable.rows.length, @@ -53,10 +53,10 @@ describe('ply', () => { (result) => { expect(result.type).toBe('datatable'); expect(result.columns).toEqual([ - { name: 'name', type: 'string' }, - { name: 'in_stock', type: 'boolean' }, - { name: 'average_price', type: 'number' }, - { name: 'row_count', type: 'number' }, + { id: 'name', name: 'name', meta: { type: 'string' } }, + { id: 'in_stock', name: 'in_stock', meta: { type: 'boolean' } }, + { id: 'average_price', name: 'average_price', meta: { type: 'number' } }, + { id: 'row_count', name: 'row_count', meta: { type: 'number' } }, ]); expect(result.rows[arbitaryRowIndex]).toHaveProperty('average_price'); expect(result.rows[arbitaryRowIndex]).toHaveProperty('row_count'); @@ -75,7 +75,7 @@ describe('ply', () => { expect(result).toEqual({ type: 'datatable', rows: [{ row_count: testTable.rows.length }], - columns: [{ name: 'row_count', type: 'number' }], + columns: [{ id: 'row_count', name: 'row_count', meta: { type: 'number' } }], }) ); }); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.test.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.test.js index ff11669db05f7..d137ce05ccc19 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.test.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.test.js @@ -15,7 +15,10 @@ describe('staticColumn', () => { const result = fn(testTable, { name: 'foo', value: 'bar' }); expect(result.type).toBe('datatable'); - expect(result.columns).toEqual([...testTable.columns, { name: 'foo', type: 'string' }]); + expect(result.columns).toEqual([ + ...testTable.columns, + { id: 'foo', name: 'foo', meta: { type: 'string' } }, + ]); expect(result.rows.every((row) => typeof row.foo === 'string')).toBe(true); expect(result.rows.every((row) => row.foo === 'bar')).toBe(true); }); @@ -33,7 +36,10 @@ describe('staticColumn', () => { const result = fn(testTable, { name: 'empty' }); expect(result.type).toBe('datatable'); - expect(result.columns).toEqual([...testTable.columns, { name: 'empty', type: 'null' }]); + expect(result.columns).toEqual([ + ...testTable.columns, + { id: 'empty', name: 'empty', meta: { type: 'null' } }, + ]); expect(result.rows.every((row) => row.empty === null)).toBe(true); }); @@ -41,7 +47,7 @@ describe('staticColumn', () => { const result = fn(emptyTable, { name: 'empty', value: 1 }); expect(result.type).toBe('datatable'); - expect(result.columns).toEqual([{ name: 'empty', type: 'number' }]); + expect(result.columns).toEqual([{ id: 'empty', name: 'empty', meta: { type: 'number' } }]); expect(result.rows.length).toBe(0); }); }); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.ts index 4fa4be0a2f09f..63a115c7e630b 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/staticColumn.ts @@ -48,7 +48,7 @@ export function staticColumn(): ExpressionFunctionDefinition< const type = getType(args.value) as DatatableColumnType; const columns = [...input.columns]; const existingColumnIndex = columns.findIndex(({ name }) => name === args.name); - const newColumn = { name: args.name, type }; + const newColumn = { id: args.name, name: args.name, meta: { type } }; if (existingColumnIndex > -1) { columns[existingColumnIndex] = newColumn; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts index 60d5edeb10483..e29f1f511685e 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/index.ts @@ -50,26 +50,26 @@ export function demodata(): ExpressionFunctionDefinition< if (args.type === DemoRows.CI) { set = { columns: [ - { name: '@timestamp', type: 'date' }, - { name: 'time', type: 'date' }, - { name: 'cost', type: 'number' }, - { name: 'username', type: 'string' }, - { name: 'price', type: 'number' }, - { name: 'age', type: 'number' }, - { name: 'country', type: 'string' }, - { name: 'state', type: 'string' }, - { name: 'project', type: 'string' }, - { name: 'percent_uptime', type: 'number' }, + { id: '@timestamp', name: '@timestamp', meta: { type: 'date' } }, + { id: 'time', name: 'time', meta: { type: 'date' } }, + { id: 'cost', name: 'cost', meta: { type: 'number' } }, + { id: 'username', name: 'username', meta: { type: 'string' } }, + { id: 'price', name: 'price', meta: { type: 'number' } }, + { id: 'age', name: 'age', meta: { type: 'number' } }, + { id: 'country', name: 'country', meta: { type: 'string' } }, + { id: 'state', name: 'state', meta: { type: 'string' } }, + { id: 'project', name: 'project', meta: { type: 'string' } }, + { id: 'percent_uptime', name: 'percent_uptime', meta: { type: 'number' } }, ], rows: sortBy(demoRows, 'time'), }; } else if (args.type === DemoRows.SHIRTS) { set = { columns: [ - { name: 'size', type: 'string' }, - { name: 'color', type: 'string' }, - { name: 'price', type: 'number' }, - { name: 'cut', type: 'string' }, + { id: 'size', name: 'size', meta: { type: 'string' } }, + { id: 'color', name: 'color', meta: { type: 'string' } }, + { id: 'price', name: 'price', meta: { type: 'number' } }, + { id: 'cut', name: 'cut', meta: { type: 'string' } }, ], rows: demoRows, }; diff --git a/x-pack/plugins/canvas/common/lib/constants.ts b/x-pack/plugins/canvas/common/lib/constants.ts index e960a86bd76dc..d514fb53b4a4b 100644 --- a/x-pack/plugins/canvas/common/lib/constants.ts +++ b/x-pack/plugins/canvas/common/lib/constants.ts @@ -42,3 +42,4 @@ export const API_ROUTE_SHAREABLE_RUNTIME = '/public/canvas/runtime'; export const API_ROUTE_SHAREABLE_RUNTIME_DOWNLOAD = `/public/canvas/${SHAREABLE_RUNTIME_NAME}.js`; export const CANVAS_EMBEDDABLE_CLASSNAME = `canvasEmbeddable`; export const CONTEXT_MENU_TOP_BORDER_CLASSNAME = 'canvasContextMenu--topBorder'; +export const API_ROUTE_FUNCTIONS = `${API_ROUTE}/fns`; diff --git a/x-pack/plugins/canvas/common/lib/get_field_type.ts b/x-pack/plugins/canvas/common/lib/get_field_type.ts index db817393a1cdb..d081f8b69956a 100644 --- a/x-pack/plugins/canvas/common/lib/get_field_type.ts +++ b/x-pack/plugins/canvas/common/lib/get_field_type.ts @@ -20,5 +20,5 @@ export function getFieldType(columns: DatatableColumn[], field?: string): string } const realField = unquoteString(field); const column = columns.find((dataTableColumn) => dataTableColumn.name === realField); - return column ? column.type : 'null'; + return column ? column.meta.type : 'null'; } diff --git a/x-pack/plugins/canvas/kibana.json b/x-pack/plugins/canvas/kibana.json index 5f4ea5802cb13..68fbec3e8429d 100644 --- a/x-pack/plugins/canvas/kibana.json +++ b/x-pack/plugins/canvas/kibana.json @@ -5,7 +5,7 @@ "configPath": ["xpack", "canvas"], "server": true, "ui": true, - "requiredPlugins": ["data", "embeddable", "expressions", "features", "home", "inspector", "uiActions"], + "requiredPlugins": ["bfetch", "data", "embeddable", "expressions", "features", "home", "inspector", "uiActions"], "optionalPlugins": ["usageCollection"], "requiredBundles": ["kibanaReact", "maps", "lens", "visualizations", "kibanaUtils", "kibanaLegacy", "discover", "savedObjects", "reporting"] } diff --git a/x-pack/plugins/canvas/public/application.tsx b/x-pack/plugins/canvas/public/application.tsx index 90173a20500e5..482cd04373105 100644 --- a/x-pack/plugins/canvas/public/application.tsx +++ b/x-pack/plugins/canvas/public/application.tsx @@ -86,7 +86,7 @@ export const initializeCanvas = async ( const canvasFunctions = initFunctions({ timefilter: setupPlugins.data.query.timefilter.timefilter, prependBasePath: coreSetup.http.basePath.prepend, - typesRegistry: setupPlugins.expressions.__LEGACY.types, + types: setupPlugins.expressions.getTypes(), }); for (const fn of canvasFunctions) { diff --git a/x-pack/plugins/canvas/public/components/datatable/datatable.tsx b/x-pack/plugins/canvas/public/components/datatable/datatable.tsx index 4f06ac0749aaf..bd343b15758bf 100644 --- a/x-pack/plugins/canvas/public/components/datatable/datatable.tsx +++ b/x-pack/plugins/canvas/public/components/datatable/datatable.tsx @@ -41,7 +41,7 @@ const getIcon = (type: IconType) => { const getColumnName = (col: DatatableColumn) => (typeof col === 'string' ? col : col.name); -const getColumnType = (col: DatatableColumn) => col.type || null; +const getColumnType = (col: DatatableColumn) => col.meta?.type || null; const getFormattedValue = (val: any, type: any) => { if (type === 'date') { diff --git a/x-pack/plugins/canvas/public/functions/filters.ts b/x-pack/plugins/canvas/public/functions/filters.ts index 61fa67dc63316..fdb5d69d35515 100644 --- a/x-pack/plugins/canvas/public/functions/filters.ts +++ b/x-pack/plugins/canvas/public/functions/filters.ts @@ -81,7 +81,7 @@ export function filtersFunctionFactory(initialize: InitializeArguments): () => F const filterAST = fromExpression(filterExpression); return interpretAst(filterAST, getWorkpadVariablesAsObject(getState())); } else { - const filterType = initialize.typesRegistry.get('filter'); + const filterType = initialize.types.filter; return filterType?.from(null, {}); } }, diff --git a/x-pack/plugins/canvas/public/functions/index.ts b/x-pack/plugins/canvas/public/functions/index.ts index 5e098d8f175c5..a7893162be8f8 100644 --- a/x-pack/plugins/canvas/public/functions/index.ts +++ b/x-pack/plugins/canvas/public/functions/index.ts @@ -12,7 +12,7 @@ import { CanvasSetupDeps, CoreSetup } from '../plugin'; export interface InitializeArguments { prependBasePath: CoreSetup['http']['basePath']['prepend']; - typesRegistry: CanvasSetupDeps['expressions']['__LEGACY']['types']; + types: ReturnType; timefilter: CanvasSetupDeps['data']['query']['timefilter']['timefilter']; } diff --git a/x-pack/plugins/canvas/public/functions/timelion.ts b/x-pack/plugins/canvas/public/functions/timelion.ts index 4eb34e838d18a..947972fa310c9 100644 --- a/x-pack/plugins/canvas/public/functions/timelion.ts +++ b/x-pack/plugins/canvas/public/functions/timelion.ts @@ -135,10 +135,13 @@ export function timelionFunctionFactory(initialize: InitializeArguments): () => return { type: 'datatable', + meta: { + source: 'timelion', + }, columns: [ - { name: '@timestamp', type: 'date' }, - { name: 'value', type: 'number' }, - { name: 'label', type: 'string' }, + { id: '@timestamp', name: '@timestamp', meta: { type: 'date' } }, + { id: 'value', name: 'value', meta: { type: 'number' } }, + { id: 'label', name: 'label', meta: { type: 'string' } }, ], rows, }; diff --git a/x-pack/plugins/canvas/public/functions/to.ts b/x-pack/plugins/canvas/public/functions/to.ts index 032873dfa6cf2..36b2d3f9f04c6 100644 --- a/x-pack/plugins/canvas/public/functions/to.ts +++ b/x-pack/plugins/canvas/public/functions/to.ts @@ -38,7 +38,7 @@ export function toFunctionFactory(initialize: InitializeArguments): () => ToFunc throw errors.missingType(); } - return castProvider(initialize.typesRegistry.toJS())(input, args.type); + return castProvider(initialize.types)(input, args.type); }, }; }; diff --git a/x-pack/plugins/canvas/public/plugin.tsx b/x-pack/plugins/canvas/public/plugin.tsx index 4829a94bb0db8..0269774a446c1 100644 --- a/x-pack/plugins/canvas/public/plugin.tsx +++ b/x-pack/plugins/canvas/public/plugin.tsx @@ -24,6 +24,7 @@ import { UiActionsStart } from '../../../../src/plugins/ui_actions/public'; import { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { Start as InspectorStart } from '../../../../src/plugins/inspector/public'; +import { BfetchPublicSetup } from '../../../../src/plugins/bfetch/public'; // @ts-expect-error untyped local import { argTypeSpecs } from './expression_types/arg_types'; import { transitions } from './transitions'; @@ -41,6 +42,7 @@ export interface CanvasSetupDeps { expressions: ExpressionsSetup; home: HomePublicPluginSetup; usageCollection?: UsageCollectionSetup; + bfetch: BfetchPublicSetup; } export interface CanvasStartDeps { diff --git a/x-pack/plugins/canvas/public/services/expressions.ts b/x-pack/plugins/canvas/public/services/expressions.ts index 1376aab0ca8b9..87a657641acfd 100644 --- a/x-pack/plugins/canvas/public/services/expressions.ts +++ b/x-pack/plugins/canvas/public/services/expressions.ts @@ -5,7 +5,11 @@ */ import { CanvasServiceFactory } from '.'; -import { ExpressionsService } from '../../../../../src/plugins/expressions/common'; +import { + ExpressionsService, + serializeProvider, +} from '../../../../../src/plugins/expressions/common'; +import { API_ROUTE_FUNCTIONS } from '../../common/lib/constants'; export const expressionsServiceFactory: CanvasServiceFactory = async ( coreSetup, @@ -13,6 +17,37 @@ export const expressionsServiceFactory: CanvasServiceFactory setupPlugins, startPlugins ) => { - await setupPlugins.expressions.__LEGACY.loadLegacyServerFunctionWrappers(); + const { expressions, bfetch } = setupPlugins; + + let cached: Promise | null = null; + const loadServerFunctionWrappers = async () => { + if (!cached) { + cached = (async () => { + const serverFunctionList = await coreSetup.http.get(API_ROUTE_FUNCTIONS); + const batchedFunction = bfetch.batchedFunction({ url: API_ROUTE_FUNCTIONS }); + const { serialize } = serializeProvider(expressions.getTypes()); + + // For every sever-side function, register a client-side + // function that matches its definition, but which simply + // calls the server-side function endpoint. + Object.keys(serverFunctionList).forEach((functionName) => { + if (expressions.getFunction(functionName)) { + return; + } + const fn = () => ({ + ...serverFunctionList[functionName], + fn: (input: any, args: any) => { + return batchedFunction({ functionName, args, context: serialize(input) }); + }, + }); + expressions.registerFunction(fn); + }); + })(); + } + return cached; + }; + + await loadServerFunctionWrappers(); + return setupPlugins.expressions.fork(); }; diff --git a/x-pack/plugins/canvas/public/store.ts b/x-pack/plugins/canvas/public/store.ts index ef93a34296da2..b1c10f8d46d27 100644 --- a/x-pack/plugins/canvas/public/store.ts +++ b/x-pack/plugins/canvas/public/store.ts @@ -15,6 +15,7 @@ import { import { getInitialState } from './state/initial_state'; import { CoreSetup } from '../../../../src/core/public'; +import { API_ROUTE_FUNCTIONS } from '../common/lib/constants'; import { CanvasSetupDeps } from './plugin'; export async function createStore(core: CoreSetup, plugins: CanvasSetupDeps) { @@ -31,7 +32,7 @@ export async function createFreshStore(core: CoreSetup, plugins: CanvasSetupDeps const basePath = core.http.basePath.get(); // Retrieve server functions - const serverFunctionsResponse = await core.http.get(`/api/interpreter/fns`); + const serverFunctionsResponse = await core.http.get(API_ROUTE_FUNCTIONS); const serverFunctions = Object.values(serverFunctionsResponse); initialState.app = { diff --git a/x-pack/plugins/canvas/server/lib/query_es_sql.test.ts b/x-pack/plugins/canvas/server/lib/query_es_sql.test.ts index c3c122d1e301a..c8bdf01ad7991 100644 --- a/x-pack/plugins/canvas/server/lib/query_es_sql.test.ts +++ b/x-pack/plugins/canvas/server/lib/query_es_sql.test.ts @@ -53,7 +53,11 @@ describe('query_es_sql', () => { const result = await queryEsSQL(api, baseArgs); - const expectedColumns = response.columns.map((c) => ({ name: c.name, type: 'string' })); + const expectedColumns = response.columns.map((c) => ({ + id: c.name, + name: c.name, + meta: { type: 'string' }, + })); const columnNames = expectedColumns.map((c) => c.name); const expectedRows = response.rows.map((r) => zipObject(columnNames, r)); diff --git a/x-pack/plugins/canvas/server/lib/query_es_sql.ts b/x-pack/plugins/canvas/server/lib/query_es_sql.ts index 8639cfa31dca8..941dc244330e8 100644 --- a/x-pack/plugins/canvas/server/lib/query_es_sql.ts +++ b/x-pack/plugins/canvas/server/lib/query_es_sql.ts @@ -53,7 +53,11 @@ export const queryEsSQL = async ( }); const columns = response.columns.map(({ name, type }) => { - return { name: sanitizeName(name), type: normalizeType(type) }; + return { + id: sanitizeName(name), + name: sanitizeName(name), + meta: { type: normalizeType(type) }, + }; }); const columnNames = map(columns, 'name'); let rows = response.rows.map((row) => zipObject(columnNames, row)); @@ -82,6 +86,9 @@ export const queryEsSQL = async ( return { type: 'datatable', + meta: { + type: 'essql', + }, columns, rows, }; diff --git a/x-pack/plugins/canvas/server/plugin.ts b/x-pack/plugins/canvas/server/plugin.ts index 4fa7e2d934647..c822ed86cb01c 100644 --- a/x-pack/plugins/canvas/server/plugin.ts +++ b/x-pack/plugins/canvas/server/plugin.ts @@ -7,6 +7,7 @@ import { first } from 'rxjs/operators'; import { CoreSetup, PluginInitializerContext, Plugin, Logger, CoreStart } from 'src/core/server'; import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; +import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { HomeServerPluginSetup } from 'src/plugins/home/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; @@ -21,6 +22,7 @@ interface PluginsSetup { expressions: ExpressionsServerSetup; features: FeaturesPluginSetup; home: HomeServerPluginSetup; + bfetch: BfetchServerSetup; usageCollection?: UsageCollectionSetup; } @@ -67,7 +69,13 @@ export class CanvasPlugin implements Plugin { const canvasRouter = coreSetup.http.createRouter(); - initRoutes({ router: canvasRouter, logger: this.logger }); + initRoutes({ + router: canvasRouter, + expressions: plugins.expressions, + bfetch: plugins.bfetch, + elasticsearch: coreSetup.elasticsearch, + logger: this.logger, + }); loadSampleData( plugins.home.sampleData.addSavedObjectsToSampleDataset, diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/create.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/create.test.ts index 290175d9062ea..4f6215b811505 100644 --- a/x-pack/plugins/canvas/server/routes/custom_elements/create.test.ts +++ b/x-pack/plugins/canvas/server/routes/custom_elements/create.test.ts @@ -5,15 +5,11 @@ */ import sinon from 'sinon'; -import { - savedObjectsClientMock, - httpServiceMock, - httpServerMock, - loggingSystemMock, -} from 'src/core/server/mocks'; +import { savedObjectsClientMock, httpServerMock } from 'src/core/server/mocks'; import { CUSTOM_ELEMENT_TYPE } from '../../../common/lib/constants'; import { initializeCreateCustomElementRoute } from './create'; import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; +import { getMockedRouterDeps } from '../test_helpers'; const mockRouteContext = ({ core: { @@ -36,15 +32,10 @@ describe('POST custom element', () => { beforeEach(() => { clock = sinon.useFakeTimers(now); - const httpService = httpServiceMock.createSetupContract(); + const routerDeps = getMockedRouterDeps(); + initializeCreateCustomElementRoute(routerDeps); - const router = httpService.createRouter(); - initializeCreateCustomElementRoute({ - router, - logger: loggingSystemMock.create().get(), - }); - - routeHandler = router.post.mock.calls[0][1]; + routeHandler = routerDeps.router.post.mock.calls[0][1]; }); afterEach(() => { diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/delete.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/delete.test.ts index 62ce4b9c3593c..1a72917a6bce9 100644 --- a/x-pack/plugins/canvas/server/routes/custom_elements/delete.test.ts +++ b/x-pack/plugins/canvas/server/routes/custom_elements/delete.test.ts @@ -7,12 +7,8 @@ import { CUSTOM_ELEMENT_TYPE } from '../../../common/lib/constants'; import { initializeDeleteCustomElementRoute } from './delete'; import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; -import { - savedObjectsClientMock, - httpServiceMock, - httpServerMock, - loggingSystemMock, -} from 'src/core/server/mocks'; +import { savedObjectsClientMock, httpServerMock } from 'src/core/server/mocks'; +import { getMockedRouterDeps } from '../test_helpers'; const mockRouteContext = ({ core: { @@ -26,14 +22,10 @@ describe('DELETE custom element', () => { let routeHandler: RequestHandler; beforeEach(() => { - const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter(); - initializeDeleteCustomElementRoute({ - router, - logger: loggingSystemMock.create().get(), - }); + const routerDeps = getMockedRouterDeps(); + initializeDeleteCustomElementRoute(routerDeps); - routeHandler = router.delete.mock.calls[0][1]; + routeHandler = routerDeps.router.delete.mock.calls[0][1]; }); it(`returns 200 ok when the custom element is deleted`, async () => { diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/find.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/find.test.ts index d42c97b62e0f3..10aaa633be53c 100644 --- a/x-pack/plugins/canvas/server/routes/custom_elements/find.test.ts +++ b/x-pack/plugins/canvas/server/routes/custom_elements/find.test.ts @@ -6,12 +6,8 @@ import { initializeFindCustomElementsRoute } from './find'; import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; -import { - savedObjectsClientMock, - httpServiceMock, - httpServerMock, - loggingSystemMock, -} from 'src/core/server/mocks'; +import { savedObjectsClientMock, httpServerMock } from 'src/core/server/mocks'; +import { getMockedRouterDeps } from '../test_helpers'; const mockRouteContext = ({ core: { @@ -25,14 +21,10 @@ describe('Find custom element', () => { let routeHandler: RequestHandler; beforeEach(() => { - const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter(); - initializeFindCustomElementsRoute({ - router, - logger: loggingSystemMock.create().get(), - }); + const routerDeps = getMockedRouterDeps(); + initializeFindCustomElementsRoute(routerDeps); - routeHandler = router.get.mock.calls[0][1]; + routeHandler = routerDeps.router.get.mock.calls[0][1]; }); it(`returns 200 with the found custom elements`, async () => { diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/get.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/get.test.ts index 7b4d0eba37419..a6c3d60ee9096 100644 --- a/x-pack/plugins/canvas/server/routes/custom_elements/get.test.ts +++ b/x-pack/plugins/canvas/server/routes/custom_elements/get.test.ts @@ -7,12 +7,8 @@ import { CUSTOM_ELEMENT_TYPE } from '../../../common/lib/constants'; import { initializeGetCustomElementRoute } from './get'; import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; -import { - savedObjectsClientMock, - httpServiceMock, - httpServerMock, - loggingSystemMock, -} from 'src/core/server/mocks'; +import { savedObjectsClientMock, httpServerMock } from 'src/core/server/mocks'; +import { getMockedRouterDeps } from '../test_helpers'; const mockRouteContext = ({ core: { @@ -26,14 +22,10 @@ describe('GET custom element', () => { let routeHandler: RequestHandler; beforeEach(() => { - const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter(); - initializeGetCustomElementRoute({ - router, - logger: loggingSystemMock.create().get(), - }); + const routerDeps = getMockedRouterDeps(); + initializeGetCustomElementRoute(routerDeps); - routeHandler = router.get.mock.calls[0][1]; + routeHandler = routerDeps.router.get.mock.calls[0][1]; }); it(`returns 200 when the custom element is found`, async () => { diff --git a/x-pack/plugins/canvas/server/routes/custom_elements/update.test.ts b/x-pack/plugins/canvas/server/routes/custom_elements/update.test.ts index 0f954904355ae..6b81cf9e3faa1 100644 --- a/x-pack/plugins/canvas/server/routes/custom_elements/update.test.ts +++ b/x-pack/plugins/canvas/server/routes/custom_elements/update.test.ts @@ -9,13 +9,9 @@ import { CustomElement } from '../../../types'; import { CUSTOM_ELEMENT_TYPE } from '../../../common/lib/constants'; import { initializeUpdateCustomElementRoute } from './update'; import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; -import { - savedObjectsClientMock, - httpServiceMock, - httpServerMock, - loggingSystemMock, -} from 'src/core/server/mocks'; +import { savedObjectsClientMock, httpServerMock } from 'src/core/server/mocks'; import { okResponse } from '../ok_response'; +import { getMockedRouterDeps } from '../test_helpers'; const mockRouteContext = ({ core: { @@ -51,14 +47,10 @@ describe('PUT custom element', () => { beforeEach(() => { clock = sinon.useFakeTimers(now); - const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter(); - initializeUpdateCustomElementRoute({ - router, - logger: loggingSystemMock.create().get(), - }); + const routerDeps = getMockedRouterDeps(); + initializeUpdateCustomElementRoute(routerDeps); - routeHandler = router.put.mock.calls[0][1]; + routeHandler = routerDeps.router.put.mock.calls[0][1]; }); afterEach(() => { diff --git a/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts index c2cff83f85f0d..a3aff029868d7 100644 --- a/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts +++ b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts @@ -6,12 +6,8 @@ import { initializeESFieldsRoute } from './es_fields'; import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; -import { - httpServiceMock, - httpServerMock, - loggingSystemMock, - elasticsearchServiceMock, -} from 'src/core/server/mocks'; +import { httpServerMock, elasticsearchServiceMock } from 'src/core/server/mocks'; +import { getMockedRouterDeps } from '../test_helpers'; const mockRouteContext = ({ core: { @@ -27,14 +23,10 @@ describe('Retrieve ES Fields', () => { let routeHandler: RequestHandler; beforeEach(() => { - const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter(); - initializeESFieldsRoute({ - router, - logger: loggingSystemMock.create().get(), - }); + const routerDeps = getMockedRouterDeps(); + initializeESFieldsRoute(routerDeps); - routeHandler = router.get.mock.calls[0][1]; + routeHandler = routerDeps.router.get.mock.calls[0][1]; }); it(`returns 200 with fields from existing index/index pattern`, async () => { diff --git a/x-pack/plugins/canvas/server/routes/functions/functions.test.ts b/x-pack/plugins/canvas/server/routes/functions/functions.test.ts new file mode 100644 index 0000000000000..755af3eb4ef7e --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/functions/functions.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; +import { ExpressionFunction } from 'src/plugins/expressions/common/expression_functions'; +import { initializeGetFunctionsRoute } from './functions'; +import { getMockedRouterDeps } from '../test_helpers'; +import { API_ROUTE_FUNCTIONS } from '../../../common/lib'; +import { functions } from '../../../canvas_plugin_src/functions/server'; + +const mockRouteContext = {} as RequestHandlerContext; +const routePath = API_ROUTE_FUNCTIONS; + +describe('Get list of serverside expression functions', () => { + let routeHandler: RequestHandler; + let mockFuncs: Record; + + beforeEach(() => { + mockFuncs = { + demodata: new ExpressionFunction(functions[0]()), + }; + + const routerDeps = getMockedRouterDeps(); + + routerDeps.expressions.getFunctions.mockReturnValueOnce(mockFuncs); + + initializeGetFunctionsRoute(routerDeps); + + routeHandler = routerDeps.router.get.mock.calls[0][1]; + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it(`returns 200 with list of functions`, async () => { + const request = httpServerMock.createKibanaRequest({ + method: 'get', + path: routePath, + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(200); + expect(response.payload).toBe(JSON.stringify(mockFuncs)); + }); +}); diff --git a/x-pack/plugins/canvas/server/routes/functions/functions.ts b/x-pack/plugins/canvas/server/routes/functions/functions.ts new file mode 100644 index 0000000000000..dba3d315c5f8b --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/functions/functions.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LegacyAPICaller } from 'src/core/server'; +import { serializeProvider } from '../../../../../../src/plugins/expressions/common'; +import { RouteInitializerDeps } from '../'; +import { API_ROUTE_FUNCTIONS } from '../../../common/lib/constants'; + +interface FunctionCall { + functionName: string; + args: Record; + context: Record; +} + +export function initializeGetFunctionsRoute(deps: RouteInitializerDeps) { + const { router, expressions } = deps; + router.get( + { + path: API_ROUTE_FUNCTIONS, + validate: false, + }, + async (context, request, response) => { + const functions = expressions.getFunctions(); + const body = JSON.stringify(functions); + return response.ok({ + body, + }); + } + ); +} + +export function initializeBatchFunctionsRoute(deps: RouteInitializerDeps) { + const { bfetch, elasticsearch, expressions } = deps; + + async function runFunction( + handlers: { environment: string; elasticsearchClient: LegacyAPICaller }, + fnCall: FunctionCall + ) { + const { functionName, args, context } = fnCall; + const { deserialize } = serializeProvider(expressions.getTypes()); + + const fnDef = expressions.getFunctions()[functionName]; + if (!fnDef) throw new Error(`Function "${functionName}" could not be found.`); + + const deserialized = deserialize(context); + const result = fnDef.fn(deserialized, args, handlers); + + return result; + } + + /** + * Register an endpoint that executes a batch of functions, and streams the + * results back using ND-JSON. + */ + bfetch.addBatchProcessingRoute(API_ROUTE_FUNCTIONS, (request) => { + return { + onBatchItem: async (fnCall: FunctionCall) => { + const handlers = { + environment: 'server', + elasticsearchClient: elasticsearch.legacy.client.asScoped(request).callAsCurrentUser, + }; + const result = await runFunction(handlers, fnCall); + if (typeof result === 'undefined') { + throw new Error(`Function ${fnCall.functionName} did not return anything.`); + } + return result; + }, + }; + }); +} diff --git a/x-pack/plugins/canvas/server/routes/functions/index.ts b/x-pack/plugins/canvas/server/routes/functions/index.ts new file mode 100644 index 0000000000000..768320fdf499a --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/functions/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { initializeGetFunctionsRoute, initializeBatchFunctionsRoute } from './functions'; +import { RouteInitializerDeps } from '..'; + +export function initFunctionsRoutes(deps: RouteInitializerDeps) { + initializeGetFunctionsRoute(deps); + initializeBatchFunctionsRoute(deps); +} diff --git a/x-pack/plugins/canvas/server/routes/index.ts b/x-pack/plugins/canvas/server/routes/index.ts index 56874151530aa..2999f4dc5cfe6 100644 --- a/x-pack/plugins/canvas/server/routes/index.ts +++ b/x-pack/plugins/canvas/server/routes/index.ts @@ -4,16 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IRouter, Logger } from 'src/core/server'; +import { IRouter, Logger, ElasticsearchServiceSetup } from 'src/core/server'; +import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; +import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { initCustomElementsRoutes } from './custom_elements'; import { initESFieldsRoutes } from './es_fields'; import { initShareablesRoutes } from './shareables'; import { initWorkpadRoutes } from './workpad'; import { initTemplateRoutes } from './templates'; +import { initFunctionsRoutes } from './functions'; export interface RouteInitializerDeps { router: IRouter; logger: Logger; + expressions: ExpressionsServerSetup; + bfetch: BfetchServerSetup; + elasticsearch: ElasticsearchServiceSetup; } export function initRoutes(deps: RouteInitializerDeps) { @@ -22,4 +28,5 @@ export function initRoutes(deps: RouteInitializerDeps) { initShareablesRoutes(deps); initWorkpadRoutes(deps); initTemplateRoutes(deps); + initFunctionsRoutes(deps); } diff --git a/x-pack/plugins/canvas/server/routes/shareables/download.test.ts b/x-pack/plugins/canvas/server/routes/shareables/download.test.ts index 0267a695ae9fe..1edf9f52e164a 100644 --- a/x-pack/plugins/canvas/server/routes/shareables/download.test.ts +++ b/x-pack/plugins/canvas/server/routes/shareables/download.test.ts @@ -8,8 +8,9 @@ jest.mock('fs'); import fs from 'fs'; import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; -import { httpServiceMock, httpServerMock, loggingSystemMock } from 'src/core/server/mocks'; +import { httpServerMock } from 'src/core/server/mocks'; import { initializeDownloadShareableWorkpadRoute } from './download'; +import { getMockedRouterDeps } from '../test_helpers'; const mockRouteContext = {} as RequestHandlerContext; const path = `api/canvas/workpad/find`; @@ -19,14 +20,10 @@ describe('Download Canvas shareables runtime', () => { let routeHandler: RequestHandler; beforeEach(() => { - const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter(); - initializeDownloadShareableWorkpadRoute({ - router, - logger: loggingSystemMock.create().get(), - }); + const routerDeps = getMockedRouterDeps(); + initializeDownloadShareableWorkpadRoute(routerDeps); - routeHandler = router.get.mock.calls[0][1]; + routeHandler = routerDeps.router.get.mock.calls[0][1]; }); afterAll(() => { diff --git a/x-pack/plugins/canvas/server/routes/shareables/zip.test.ts b/x-pack/plugins/canvas/server/routes/shareables/zip.test.ts index 0c19886f07e5c..b1649c7524c7e 100644 --- a/x-pack/plugins/canvas/server/routes/shareables/zip.test.ts +++ b/x-pack/plugins/canvas/server/routes/shareables/zip.test.ts @@ -9,8 +9,9 @@ jest.mock('archiver'); // eslint-disable-next-line @typescript-eslint/no-var-requires const archiver = require('archiver') as jest.Mock; import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; -import { httpServiceMock, httpServerMock, loggingSystemMock } from 'src/core/server/mocks'; +import { httpServerMock } from 'src/core/server/mocks'; import { initializeZipShareableWorkpadRoute } from './zip'; +import { getMockedRouterDeps } from '../test_helpers'; import { API_ROUTE_SHAREABLE_ZIP } from '../../../common/lib'; import { SHAREABLE_RUNTIME_FILE, @@ -26,14 +27,9 @@ describe('Zips Canvas shareables runtime together with workpad', () => { let routeHandler: RequestHandler; beforeEach(() => { - const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter(); - initializeZipShareableWorkpadRoute({ - router, - logger: loggingSystemMock.create().get(), - }); - - routeHandler = router.post.mock.calls[0][1]; + const routerDeps = getMockedRouterDeps(); + initializeZipShareableWorkpadRoute(routerDeps); + routeHandler = routerDeps.router.post.mock.calls[0][1]; }); afterAll(() => { diff --git a/x-pack/plugins/canvas/server/routes/templates/list.test.ts b/x-pack/plugins/canvas/server/routes/templates/list.test.ts index 95658e6a7b511..9ceab3b5c9705 100644 --- a/x-pack/plugins/canvas/server/routes/templates/list.test.ts +++ b/x-pack/plugins/canvas/server/routes/templates/list.test.ts @@ -6,18 +6,9 @@ import { badRequest } from 'boom'; import { initializeListTemplates } from './list'; -import { - IRouter, - kibanaResponseFactory, - RequestHandlerContext, - RequestHandler, -} from 'src/core/server'; -import { - savedObjectsClientMock, - httpServiceMock, - httpServerMock, - loggingSystemMock, -} from 'src/core/server/mocks'; +import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; +import { savedObjectsClientMock, httpServerMock } from 'src/core/server/mocks'; +import { getMockedRouterDeps } from '../test_helpers'; const mockRouteContext = ({ core: { @@ -31,14 +22,10 @@ describe('Find workpad', () => { let routeHandler: RequestHandler; beforeEach(() => { - const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter() as jest.Mocked; - initializeListTemplates({ - router, - logger: loggingSystemMock.create().get(), - }); + const routerDeps = getMockedRouterDeps(); + initializeListTemplates(routerDeps); - routeHandler = router.get.mock.calls[0][1]; + routeHandler = routerDeps.router.get.mock.calls[0][1]; }); it(`returns 200 with the found templates`, async () => { diff --git a/x-pack/plugins/canvas/server/routes/test_helpers.ts b/x-pack/plugins/canvas/server/routes/test_helpers.ts new file mode 100644 index 0000000000000..695cc79e513dc --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/test_helpers.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 { + httpServiceMock, + loggingSystemMock, + elasticsearchServiceMock, +} from 'src/core/server/mocks'; +import { bfetchPluginMock } from '../../../../../src/plugins/bfetch/server/mocks'; +import { expressionsPluginMock } from '../../../../../src/plugins/expressions/server/mocks'; + +export function getMockedRouterDeps() { + const httpService = httpServiceMock.createSetupContract(); + const elasticsearch = elasticsearchServiceMock.createSetup(); + const bfetch = bfetchPluginMock.createSetupContract(); + const expressions = expressionsPluginMock.createSetupContract(); + const router = httpService.createRouter(); + + return { + router, + expressions, + elasticsearch, + bfetch, + logger: loggingSystemMock.create().get(), + }; +} diff --git a/x-pack/plugins/canvas/server/routes/workpad/create.test.ts b/x-pack/plugins/canvas/server/routes/workpad/create.test.ts index 4756349a8a5ff..4e927751a026f 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/create.test.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/create.test.ts @@ -5,15 +5,11 @@ */ import sinon from 'sinon'; -import { - savedObjectsClientMock, - httpServiceMock, - httpServerMock, - loggingSystemMock, -} from 'src/core/server/mocks'; +import { savedObjectsClientMock, httpServerMock } from 'src/core/server/mocks'; import { CANVAS_TYPE } from '../../../common/lib/constants'; import { initializeCreateWorkpadRoute } from './create'; import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; +import { getMockedRouterDeps } from '../test_helpers'; let mockRouteContext = ({ core: { @@ -44,15 +40,10 @@ describe('POST workpad', () => { clock = sinon.useFakeTimers(now); - const httpService = httpServiceMock.createSetupContract(); + const routerDeps = getMockedRouterDeps(); + initializeCreateWorkpadRoute(routerDeps); - const router = httpService.createRouter(); - initializeCreateWorkpadRoute({ - router, - logger: loggingSystemMock.create().get(), - }); - - routeHandler = router.post.mock.calls[0][1]; + routeHandler = routerDeps.router.post.mock.calls[0][1]; }); afterEach(() => { diff --git a/x-pack/plugins/canvas/server/routes/workpad/delete.test.ts b/x-pack/plugins/canvas/server/routes/workpad/delete.test.ts index 32ce30325b60a..e66628ffb4d4e 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/delete.test.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/delete.test.ts @@ -7,12 +7,8 @@ import { CANVAS_TYPE } from '../../../common/lib/constants'; import { initializeDeleteWorkpadRoute } from './delete'; import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; -import { - savedObjectsClientMock, - httpServiceMock, - httpServerMock, - loggingSystemMock, -} from 'src/core/server/mocks'; +import { savedObjectsClientMock, httpServerMock } from 'src/core/server/mocks'; +import { getMockedRouterDeps } from '../test_helpers'; const mockRouteContext = ({ core: { @@ -26,14 +22,10 @@ describe('DELETE workpad', () => { let routeHandler: RequestHandler; beforeEach(() => { - const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter(); - initializeDeleteWorkpadRoute({ - router, - logger: loggingSystemMock.create().get(), - }); + const routerDeps = getMockedRouterDeps(); + initializeDeleteWorkpadRoute(routerDeps); - routeHandler = router.delete.mock.calls[0][1]; + routeHandler = routerDeps.router.delete.mock.calls[0][1]; }); it(`returns 200 ok when the workpad is deleted`, async () => { diff --git a/x-pack/plugins/canvas/server/routes/workpad/find.test.ts b/x-pack/plugins/canvas/server/routes/workpad/find.test.ts index a87cf7be57d81..593e4062686f9 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/find.test.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/find.test.ts @@ -6,12 +6,8 @@ import { initializeFindWorkpadsRoute } from './find'; import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; -import { - savedObjectsClientMock, - httpServiceMock, - httpServerMock, - loggingSystemMock, -} from 'src/core/server/mocks'; +import { savedObjectsClientMock, httpServerMock } from 'src/core/server/mocks'; +import { getMockedRouterDeps } from '../test_helpers'; const mockRouteContext = ({ core: { @@ -25,14 +21,10 @@ describe('Find workpad', () => { let routeHandler: RequestHandler; beforeEach(() => { - const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter(); - initializeFindWorkpadsRoute({ - router, - logger: loggingSystemMock.create().get(), - }); + const routerDeps = getMockedRouterDeps(); + initializeFindWorkpadsRoute(routerDeps); - routeHandler = router.get.mock.calls[0][1]; + routeHandler = routerDeps.router.get.mock.calls[0][1]; }); it(`returns 200 with the found workpads`, async () => { diff --git a/x-pack/plugins/canvas/server/routes/workpad/get.test.ts b/x-pack/plugins/canvas/server/routes/workpad/get.test.ts index 8cc190dc6231c..a51cbefd4031e 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/get.test.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/get.test.ts @@ -7,14 +7,10 @@ import { CANVAS_TYPE } from '../../../common/lib/constants'; import { initializeGetWorkpadRoute } from './get'; import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; -import { - savedObjectsClientMock, - httpServiceMock, - httpServerMock, - loggingSystemMock, -} from 'src/core/server/mocks'; +import { savedObjectsClientMock, httpServerMock } from 'src/core/server/mocks'; import { workpadWithGroupAsElement } from '../../../__tests__/fixtures/workpads'; import { CanvasWorkpad } from '../../../types'; +import { getMockedRouterDeps } from '../test_helpers'; const mockRouteContext = ({ core: { @@ -28,14 +24,10 @@ describe('GET workpad', () => { let routeHandler: RequestHandler; beforeEach(() => { - const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter(); - initializeGetWorkpadRoute({ - router, - logger: loggingSystemMock.create().get(), - }); + const routerDeps = getMockedRouterDeps(); + initializeGetWorkpadRoute(routerDeps); - routeHandler = router.get.mock.calls[0][1]; + routeHandler = routerDeps.router.get.mock.calls[0][1]; }); it(`returns 200 when the workpad is found`, async () => { diff --git a/x-pack/plugins/canvas/server/routes/workpad/update.test.ts b/x-pack/plugins/canvas/server/routes/workpad/update.test.ts index 6d7ea06852a5e..0d97145c90298 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/update.test.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/update.test.ts @@ -8,14 +8,10 @@ import sinon from 'sinon'; import { CANVAS_TYPE } from '../../../common/lib/constants'; import { initializeUpdateWorkpadRoute, initializeUpdateWorkpadAssetsRoute } from './update'; import { kibanaResponseFactory, RequestHandlerContext, RequestHandler } from 'src/core/server'; -import { - savedObjectsClientMock, - httpServiceMock, - httpServerMock, - loggingSystemMock, -} from 'src/core/server/mocks'; +import { savedObjectsClientMock, httpServerMock } from 'src/core/server/mocks'; import { workpads } from '../../../__tests__/fixtures/workpads'; import { okResponse } from '../ok_response'; +import { getMockedRouterDeps } from '../test_helpers'; const mockRouteContext = ({ core: { @@ -38,14 +34,10 @@ describe('PUT workpad', () => { beforeEach(() => { clock = sinon.useFakeTimers(now); - const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter(); - initializeUpdateWorkpadRoute({ - router, - logger: loggingSystemMock.create().get(), - }); + const routerDeps = getMockedRouterDeps(); + initializeUpdateWorkpadRoute(routerDeps); - routeHandler = router.put.mock.calls[0][1]; + routeHandler = routerDeps.router.put.mock.calls[0][1]; }); afterEach(() => { @@ -152,14 +144,11 @@ describe('update assets', () => { beforeEach(() => { clock = sinon.useFakeTimers(now); - const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter(); - initializeUpdateWorkpadAssetsRoute({ - router, - logger: loggingSystemMock.create().get(), - }); - routeHandler = router.put.mock.calls[0][1]; + const routerDeps = getMockedRouterDeps(); + initializeUpdateWorkpadAssetsRoute(routerDeps); + + routeHandler = routerDeps.router.put.mock.calls[0][1]; }); afterEach(() => { diff --git a/x-pack/plugins/canvas/server/setup_interpreter.ts b/x-pack/plugins/canvas/server/setup_interpreter.ts index 79bc2f3c1996b..8dc431345e5f5 100644 --- a/x-pack/plugins/canvas/server/setup_interpreter.ts +++ b/x-pack/plugins/canvas/server/setup_interpreter.ts @@ -8,5 +8,5 @@ import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import { functions } from '../canvas_plugin_src/functions/server'; export function setupInterpreter(expressions: ExpressionsServerSetup) { - expressions.__LEGACY.register({ types: [], serverFunctions: functions }); + functions.forEach((f) => expressions.registerFunction(f)); } diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_list.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_list.test.js index 0da241c4c1357..60b5b393c5b07 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_list.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/auto_follow_pattern_list.test.js @@ -338,7 +338,10 @@ describe('', () => { expect(exists('deleteAutoFollowPatternConfirmation')).toBe(true); }); - test('should display the recent errors', async () => { + // This test is failing in CI, skipping for now + // we will need to remove the calls to "await nextTick()""; + // Issue: https://github.com/elastic/kibana/issues/75261 + test.skip('should display the recent errors', async () => { const message = 'bar'; const recentAutoFollowErrors = [ { diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js index e96beda7243d6..91227299e92f9 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js @@ -303,7 +303,8 @@ describe('', () => { }); }); - describe('detail panel', () => { + // FLAKY: https://github.com/elastic/kibana/issues/75124 + describe.skip('detail panel', () => { test('should open a detail panel when clicking on a follower index', () => { expect(exists('followerIndexDetail')).toBe(false); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/drilldown_shared.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/drilldown_shared.ts new file mode 100644 index 0000000000000..d0c597532f6ed --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/drilldown_shared.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + APPLY_FILTER_TRIGGER, + SELECT_RANGE_TRIGGER, + TriggerId, + VALUE_CLICK_TRIGGER, +} from '../../../../../../../src/plugins/ui_actions/public'; + +/** + * We know that VALUE_CLICK_TRIGGER and SELECT_RANGE_TRIGGER are also triggering APPLY_FILTER_TRIGGER + * This function appends APPLY_FILTER_TRIGGER to list of triggers if VALUE_CLICK_TRIGGER or SELECT_RANGE_TRIGGER + * + * TODO: this probably should be part of uiActions infrastructure, + * but dynamic implementation of nested trigger doesn't allow to statically express such relations + * + * @param triggers + */ +export function ensureNestedTriggers(triggers: TriggerId[]): TriggerId[] { + if ( + !triggers.includes(APPLY_FILTER_TRIGGER) && + (triggers.includes(VALUE_CLICK_TRIGGER) || triggers.includes(SELECT_RANGE_TRIGGER)) + ) { + return [...triggers, APPLY_FILTER_TRIGGER]; + } + + return triggers; +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx index 712a46dc32e08..d5547ff8097cc 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx @@ -10,9 +10,13 @@ import { } from './flyout_create_drilldown'; import { coreMock } from '../../../../../../../../src/core/public/mocks'; import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; -import { TriggerContextMapping } from '../../../../../../../../src/plugins/ui_actions/public'; +import { + TriggerContextMapping, + TriggerId, +} from '../../../../../../../../src/plugins/ui_actions/public'; import { MockEmbeddable, enhanceEmbeddable } from '../test_helpers'; import { uiActionsEnhancedPluginMock } from '../../../../../../ui_actions_enhanced/public/mocks'; +import { UiActionsEnhancedActionFactory } from '../../../../../../ui_actions_enhanced/public/'; const overlays = coreMock.createStart().overlays; const uiActionsEnhanced = uiActionsEnhancedPluginMock.createStartContract(); @@ -50,6 +54,7 @@ interface CompatibilityParams { isValueClickTriggerSupported?: boolean; isEmbeddableEnhanced?: boolean; rootType?: string; + actionFactoriesTriggers?: TriggerId[]; } describe('isCompatible', () => { @@ -61,9 +66,16 @@ describe('isCompatible', () => { isValueClickTriggerSupported = true, isEmbeddableEnhanced = true, rootType = 'dashboard', + actionFactoriesTriggers = ['VALUE_CLICK_TRIGGER'], }: CompatibilityParams, expectedResult: boolean = true ): Promise { + uiActionsEnhanced.getActionFactories.mockImplementation(() => [ + ({ + supportedTriggers: () => actionFactoriesTriggers, + } as unknown) as UiActionsEnhancedActionFactory, + ]); + let embeddable = new MockEmbeddable( { id: '', viewMode: isEdit ? ViewMode.EDIT : ViewMode.VIEW }, { @@ -116,6 +128,15 @@ describe('isCompatible', () => { rootType: 'visualization', }); }); + + test('not compatible if no triggers intersection', async () => { + await assertNonCompatibility({ + actionFactoriesTriggers: [], + }); + await assertNonCompatibility({ + actionFactoriesTriggers: ['SELECT_RANGE_TRIGGER'], + }); + }); }); describe('execute', () => { diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx index 2de862a6708a8..b5e5e248eaeb1 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx @@ -6,17 +6,13 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { - ActionByType, - APPLY_FILTER_TRIGGER, - SELECT_RANGE_TRIGGER, - VALUE_CLICK_TRIGGER, -} from '../../../../../../../../src/plugins/ui_actions/public'; +import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public'; import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; import { isEnhancedEmbeddable } from '../../../../../../embeddable_enhanced/public'; import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public'; import { StartDependencies } from '../../../../plugin'; import { StartServicesGetter } from '../../../../../../../../src/plugins/kibana_utils/public'; +import { ensureNestedTriggers } from '../drilldown_shared'; export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; @@ -47,8 +43,18 @@ export class FlyoutCreateDrilldownAction implements ActionByType - [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER, APPLY_FILTER_TRIGGER].includes(trigger) + /** + * Check if there is an intersection between all registered drilldowns possible triggers that they could be attached to + * and triggers that current embeddable supports + */ + const allPossibleTriggers = this.params + .start() + .plugins.uiActionsEnhanced.getActionFactories() + .map((factory) => factory.supportedTriggers()) + .reduce((res, next) => res.concat(next), []); + + return ensureNestedTriggers(supportedTriggers).some((trigger) => + allPossibleTriggers.includes(trigger) ); } @@ -73,6 +79,7 @@ export class FlyoutCreateDrilldownAction implements ActionByType handle.close()} viewMode={'create'} dynamicActionManager={embeddable.enhancements.dynamicActions} + supportedTriggers={ensureNestedTriggers(embeddable.supportedTriggers())} /> ), { diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx index b9ae45c2853c3..2950f5bf7110e 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx @@ -22,6 +22,9 @@ uiActionsPlugin.setup.registerDrilldown({ isConfigValid: () => true, execute: async () => {}, getDisplayName: () => 'test', + supportedTriggers() { + return ['VALUE_CLICK_TRIGGER']; + }, }); const actionParams: FlyoutEditDrilldownParams = { diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx index af1ae67454463..6dfda93db7155 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx @@ -16,6 +16,7 @@ import { MenuItem } from './menu_item'; import { isEnhancedEmbeddable } from '../../../../../../embeddable_enhanced/public'; import { StartDependencies } from '../../../../plugin'; import { StartServicesGetter } from '../../../../../../../../src/plugins/kibana_utils/public'; +import { ensureNestedTriggers } from '../drilldown_shared'; export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; @@ -62,6 +63,7 @@ export class FlyoutEditDrilldownAction implements ActionByType handle.close()} viewMode={'manage'} dynamicActionManager={embeddable.enhancements.dynamicActions} + supportedTriggers={ensureNestedTriggers(embeddable.supportedTriggers())} /> ), { diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/collect_config_container.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/collect_config_container.tsx index 6d6803510a281..5cbf65f7645dd 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/collect_config_container.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/components/collect_config_container.tsx @@ -11,7 +11,7 @@ import { SimpleSavedObject } from '../../../../../../../../src/core/public'; import { DashboardDrilldownConfig } from './dashboard_drilldown_config'; import { txtDestinationDashboardNotFound } from './i18n'; import { CollectConfigProps } from '../../../../../../../../src/plugins/kibana_utils/public'; -import { Config } from '../types'; +import { Config, FactoryContext } from '../types'; import { Params } from '../drilldown'; const mergeDashboards = ( @@ -34,7 +34,7 @@ const dashboardSavedObjectToMenuItem = ( label: savedObject.attributes.title, }); -interface DashboardDrilldownCollectConfigProps extends CollectConfigProps { +interface DashboardDrilldownCollectConfigProps extends CollectConfigProps { params: Params; } diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx index 703acbc8d9d59..a17d95c37c5ce 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { reactToUiComponent } from '../../../../../../../src/plugins/kibana_react/public'; +import { APPLY_FILTER_TRIGGER } from '../../../../../../../src/plugins/ui_actions/public'; import { DashboardUrlGenerator, DashboardUrlGeneratorState, @@ -23,7 +24,7 @@ import { } from '../../../../../../../src/plugins/data/public'; import { StartServicesGetter } from '../../../../../../../src/plugins/kibana_utils/public'; import { StartDependencies } from '../../../plugin'; -import { Config } from './types'; +import { Config, FactoryContext } from './types'; export interface Params { start: StartServicesGetter>; @@ -31,7 +32,7 @@ export interface Params { } export class DashboardToDashboardDrilldown - implements Drilldown { + implements Drilldown { constructor(protected readonly params: Params) {} public readonly id = DASHBOARD_TO_DASHBOARD_DRILLDOWN; @@ -59,6 +60,10 @@ export class DashboardToDashboardDrilldown return true; }; + public supportedTriggers(): Array { + return [APPLY_FILTER_TRIGGER]; + } + public readonly getHref = async ( config: Config, context: ApplyGlobalFilterActionContext diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts index 426e250499de0..c21109f8a596a 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts @@ -4,8 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import { UiActionsEnhancedBaseActionFactoryContext } from '../../../../../ui_actions_enhanced/public'; +import { APPLY_FILTER_TRIGGER } from '../../../../../../../src/plugins/ui_actions/public'; + export interface Config { dashboardId?: string; useCurrentFilters: boolean; useCurrentDateRange: boolean; } + +export type FactoryContext = UiActionsEnhancedBaseActionFactoryContext; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts index b1d7341d51a4c..ef3bf54053b5c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts @@ -5,6 +5,7 @@ */ import { httpServiceMock } from 'src/core/public/mocks'; +import { ExternalUrl } from '../shared/enterprise_search_url'; /** * A set of default Kibana context values to use across component tests. @@ -14,5 +15,6 @@ export const mockKibanaContext = { http: httpServiceMock.createSetupContract(), setBreadcrumbs: jest.fn(), setDocTitle: jest.fn(), - enterpriseSearchUrl: 'http://localhost:3002', + config: { host: 'http://localhost:3002' }, + externalUrl: new ExternalUrl('http://localhost:3002'), }; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx index 1e0df1326c177..9f8fda856eed6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx @@ -21,7 +21,7 @@ import { mockLicenseContext } from './license_context.mock'; * * Example usage: * - * const wrapper = mountWithContext(, { enterpriseSearchUrl: 'someOverride', license: {} }); + * const wrapper = mountWithContext(, { config: { host: 'someOverride' } }); */ export const mountWithContext = (children: React.ReactNode, context?: object) => { return mount( diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts index 2bcdd42c38055..792be49a49c48 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts @@ -35,6 +35,6 @@ jest.mock('react', () => ({ * // ... etc. * * it('some test', () => { - * useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: 'someOverride' })); + * useContext.mockImplementationOnce(() => ({ config: { host: 'someOverride' } })); * }); */ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx index 4d2b790e7fb97..9b0edb423bc52 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_state.tsx @@ -11,16 +11,20 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { sendTelemetry } from '../../../shared/telemetry'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { KibanaContext, IKibanaContext } from '../../../index'; +import { CREATE_ENGINES_PATH } from '../../routes'; import { EngineOverviewHeader } from '../engine_overview_header'; import './empty_states.scss'; export const EmptyState: React.FC = () => { - const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; + const { + externalUrl: { getAppSearchUrl }, + http, + } = useContext(KibanaContext) as IKibanaContext; const buttonProps = { - href: `${enterpriseSearchUrl}/as/engines/new`, + href: getAppSearchUrl(CREATE_ENGINES_PATH), target: '_blank', onClick: () => sendTelemetry({ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx index 1e58d820dc83b..9c6122c88c7d7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx @@ -11,6 +11,7 @@ import { i18n } from '@kbn/i18n'; import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; +import { getEngineRoute } from '../../routes'; import { ENGINES_PAGE_SIZE } from '../../../../../common/constants'; @@ -39,9 +40,13 @@ export const EngineTable: React.FC = ({ data, pagination: { totalEngines, pageIndex, onPaginate }, }) => { - const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; + const { + externalUrl: { getAppSearchUrl }, + http, + } = useContext(KibanaContext) as IKibanaContext; + const engineLinkProps = (name: string) => ({ - href: `${enterpriseSearchUrl}/as/engines/${name}`, + href: getAppSearchUrl(getEngineRoute(name)), target: '_blank', onClick: () => sendTelemetry({ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx index cc480d241ad50..7f67d00f5df91 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.tsx @@ -19,13 +19,16 @@ import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; export const EngineOverviewHeader: React.FC = () => { - const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; + const { + externalUrl: { getAppSearchUrl }, + http, + } = useContext(KibanaContext) as IKibanaContext; const buttonProps = { fill: true, iconType: 'popout', 'data-test-subj': 'launchButton', - href: `${enterpriseSearchUrl}/as`, + href: getAppSearchUrl(), target: '_blank', onClick: () => sendTelemetry({ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 9e660d10053ec..fa9a761a966e1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -20,8 +20,8 @@ describe('AppSearch', () => { expect(wrapper.find(Layout)).toHaveLength(1); }); - it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => { - (useContext as jest.Mock).mockImplementationOnce(() => ({ enterpriseSearchUrl: '' })); + it('redirects to Setup Guide when config.host is not set', () => { + (useContext as jest.Mock).mockImplementationOnce(() => ({ config: { host: '' } })); const wrapper = shallow(); expect(wrapper.find(Redirect)).toHaveLength(1); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index d69b3ba29b0ca..7ebd35ff35ee1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -13,19 +13,29 @@ import { APP_SEARCH_PLUGIN } from '../../../common/constants'; import { KibanaContext, IKibanaContext } from '../index'; import { Layout, SideNav, SideNavLink } from '../shared/layout'; +import { + ROOT_PATH, + SETUP_GUIDE_PATH, + SETTINGS_PATH, + CREDENTIALS_PATH, + ROLE_MAPPINGS_PATH, + ENGINES_PATH, +} from './routes'; + import { SetupGuide } from './components/setup_guide'; import { EngineOverview } from './components/engine_overview'; export const AppSearch: React.FC = () => { - const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; - if (!enterpriseSearchUrl) + const { config } = useContext(KibanaContext) as IKibanaContext; + + if (!config.host) return ( - + - + {/* Kibana displays a blank page on redirect if this isn't included */} @@ -33,17 +43,17 @@ export const AppSearch: React.FC = () => { return ( - + }> - + {/* For some reason a Redirect to /engines just doesn't work here - it shows a blank page */} - + @@ -54,27 +64,28 @@ export const AppSearch: React.FC = () => { }; export const AppSearchNav: React.FC = () => { - const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; - const externalUrl = `${enterpriseSearchUrl}/as#`; + const { + externalUrl: { getAppSearchUrl }, + } = useContext(KibanaContext) as IKibanaContext; return ( - + {i18n.translate('xpack.enterpriseSearch.appSearch.nav.engines', { defaultMessage: 'Engines', })} - + {i18n.translate('xpack.enterpriseSearch.appSearch.nav.settings', { defaultMessage: 'Account Settings', })} - + {i18n.translate('xpack.enterpriseSearch.appSearch.nav.credentials', { defaultMessage: 'Credentials', })} - + {i18n.translate('xpack.enterpriseSearch.appSearch.nav.roleMappings', { defaultMessage: 'Role Mappings', })} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts new file mode 100644 index 0000000000000..51e2497365dd7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.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. + */ + +export const ROOT_PATH = '/'; +export const SETUP_GUIDE_PATH = '/setup_guide'; +export const SETTINGS_PATH = '/settings/account'; +export const CREDENTIALS_PATH = '/credentials'; +export const ROLE_MAPPINGS_PATH = '#/role-mappings'; // This page seems to 404 if the # isn't included + +export const ENGINES_PATH = '/engines'; +export const CREATE_ENGINES_PATH = `${ENGINES_PATH}/new`; + +export const ENGINE_PATH = '/engines/:engineName'; +export const getEngineRoute = (engineName: string) => `${ENGINES_PATH}/${engineName}`; diff --git a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx index 70e16e61846b4..e0cf2814b46b4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx @@ -17,10 +17,11 @@ import { WorkplaceSearch } from './workplace_search'; describe('renderApp', () => { let params: AppMountParameters; const core = coreMock.createStart(); - const config = {}; const plugins = { licensing: licensingMock.createSetup(), } as any; + const config = {}; + const data = {} as any; beforeEach(() => { jest.clearAllMocks(); @@ -30,19 +31,19 @@ describe('renderApp', () => { it('mounts and unmounts UI', () => { const MockApp = () =>
Hello world!
; - const unmount = renderApp(MockApp, core, params, config, plugins); + const unmount = renderApp(MockApp, params, core, plugins, config, data); expect(params.element.querySelector('.hello-world')).not.toBeNull(); unmount(); expect(params.element.innerHTML).toEqual(''); }); it('renders AppSearch', () => { - renderApp(AppSearch, core, params, config, plugins); + renderApp(AppSearch, params, core, plugins, config, data); expect(params.element.querySelector('.setupGuide')).not.toBeNull(); }); it('renders WorkplaceSearch', () => { - renderApp(WorkplaceSearch, core, params, config, plugins); + renderApp(WorkplaceSearch, params, core, plugins, config, data); expect(params.element.querySelector('.setupGuide')).not.toBeNull(); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index 0e43d86f5095d..f3ccbc126ae62 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -10,11 +10,13 @@ import { Router } from 'react-router-dom'; import { I18nProvider } from '@kbn/i18n/react'; import { CoreStart, AppMountParameters, HttpSetup, ChromeBreadcrumb } from 'src/core/public'; -import { ClientConfigType, PluginsSetup } from '../plugin'; +import { ClientConfigType, ClientData, PluginsSetup } from '../plugin'; import { LicenseProvider } from './shared/licensing'; +import { IExternalUrl } from './shared/enterprise_search_url'; export interface IKibanaContext { - enterpriseSearchUrl?: string; + config: { host?: string }; + externalUrl: IExternalUrl; http: HttpSetup; setBreadcrumbs(crumbs: ChromeBreadcrumb[]): void; setDocTitle(title: string): void; @@ -30,17 +32,19 @@ export const KibanaContext = React.createContext({}); export const renderApp = ( App: React.FC, - core: CoreStart, params: AppMountParameters, + core: CoreStart, + plugins: PluginsSetup, config: ClientConfigType, - plugins: PluginsSetup + data: ClientData ) => { ReactDOM.render( { + const externalUrl = new ExternalUrl('http://localhost:3002'); + + it('exposes a public enterpriseSearchUrl string', () => { + expect(externalUrl.enterpriseSearchUrl).toEqual('http://localhost:3002'); + }); + + it('generates a public App Search URL', () => { + expect(externalUrl.getAppSearchUrl()).toEqual('http://localhost:3002/as'); + expect(externalUrl.getAppSearchUrl('/path')).toEqual('http://localhost:3002/as/path'); + }); + + it('generates a public Workplace Search URL', () => { + expect(externalUrl.getWorkplaceSearchUrl()).toEqual('http://localhost:3002/ws'); + expect(externalUrl.getWorkplaceSearchUrl('/path')).toEqual('http://localhost:3002/ws/path'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/generate_external_url.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/generate_external_url.ts new file mode 100644 index 0000000000000..9db48d197f3bc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/generate_external_url.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. + */ + +/** + * Small helper for generating external public-facing URLs + * to the legacy/standalone Enterprise Search app + */ +export interface IExternalUrl { + enterpriseSearchUrl?: string; + getAppSearchUrl(path?: string): string; + getWorkplaceSearchUrl(path?: string): string; +} + +export class ExternalUrl { + public enterpriseSearchUrl: string; + + constructor(externalUrl: string) { + this.enterpriseSearchUrl = externalUrl; + + this.getAppSearchUrl = this.getAppSearchUrl.bind(this); + this.getWorkplaceSearchUrl = this.getWorkplaceSearchUrl.bind(this); + } + + private getExternalUrl(path: string): string { + return this.enterpriseSearchUrl + path; + } + + public getAppSearchUrl(path: string = ''): string { + return this.getExternalUrl('/as' + path); + } + + public getWorkplaceSearchUrl(path: string = ''): string { + return this.getExternalUrl('/ws' + path); + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts index bbbb688b8ea7b..563d19f9fdeb5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/enterprise_search_url/index.ts @@ -5,3 +5,4 @@ */ export { getPublicUrl } from './get_enterprise_search_url'; +export { ExternalUrl, IExternalUrl } from './generate_external_url'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx index ccd5beff66e70..a2cb424dadee8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx @@ -14,7 +14,7 @@ import { KibanaContext, IKibanaContext } from '../../index'; import './error_state_prompt.scss'; export const ErrorStatePrompt: React.FC = () => { - const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + const { config } = useContext(KibanaContext) as IKibanaContext; return ( { id="xpack.enterpriseSearch.errorConnectingState.description1" defaultMessage="We can’t establish a connection to Enterprise Search at the host URL: {enterpriseSearchUrl}" values={{ - enterpriseSearchUrl: {enterpriseSearchUrl}, + enterpriseSearchUrl: {config.host}, }} />

diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index 8f8edc61620ab..9fb627ed09791 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -22,8 +22,9 @@ import { } from '../../routes'; export const WorkplaceSearchNav: React.FC = () => { - const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; - const legacyUrl = (path: string) => `${enterpriseSearchUrl}/ws#${path}`; + const { + externalUrl: { getWorkplaceSearchUrl }, + } = useContext(KibanaContext) as IKibanaContext; // TODO: icons return ( @@ -33,38 +34,38 @@ export const WorkplaceSearchNav: React.FC = () => { defaultMessage: 'Overview', })}
- + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.sources', { defaultMessage: 'Sources', })} - + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.groups', { defaultMessage: 'Groups', })} - + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.roleMappings', { defaultMessage: 'Role Mappings', })} - + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.security', { defaultMessage: 'Security', })} - + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.settings', { defaultMessage: 'Settings', })} - + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.personalDashboard', { defaultMessage: 'View my personal dashboard', })} - + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.search', { defaultMessage: 'Go to search application', })} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx index 288c0be84fa9a..786357358dfa6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx @@ -17,7 +17,6 @@ import { EuiButtonEmptyProps, EuiLinkProps, } from '@elastic/eui'; -import { useRoutes } from '../shared/use_routes'; import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; @@ -40,8 +39,10 @@ export const OnboardingCard: React.FC = ({ actionPath, complete, }) => { - const { http } = useContext(KibanaContext) as IKibanaContext; - const { getWSRoute } = useRoutes(); + const { + http, + externalUrl: { getWorkplaceSearchUrl }, + } = useContext(KibanaContext) as IKibanaContext; const onClick = () => sendTelemetry({ @@ -53,7 +54,7 @@ export const OnboardingCard: React.FC = ({ const buttonActionProps = actionPath ? { onClick, - href: getWSRoute(actionPath), + href: getWorkplaceSearchUrl(actionPath), target: '_blank', 'data-test-subj': testSubj, } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx index 7fe1eae502329..d0f5893bdb88a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx @@ -22,7 +22,6 @@ import { EuiLinkProps, } from '@elastic/eui'; import sharedSourcesIcon from '../shared/assets/share_circle.svg'; -import { useRoutes } from '../shared/use_routes'; import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; import { ORG_SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes'; @@ -133,8 +132,10 @@ export const OnboardingSteps: React.FC = () => { }; export const OrgNameOnboarding: React.FC = () => { - const { http } = useContext(KibanaContext) as IKibanaContext; - const { getWSRoute } = useRoutes(); + const { + http, + externalUrl: { getWorkplaceSearchUrl }, + } = useContext(KibanaContext) as IKibanaContext; const onClick = () => sendTelemetry({ @@ -148,7 +149,7 @@ export const OrgNameOnboarding: React.FC = () => { onClick, target: '_blank', color: 'primary', - href: getWSRoute(ORG_SETTINGS_PATH), + href: getWorkplaceSearchUrl(ORG_SETTINGS_PATH), 'data-test-subj': 'orgNameChangeButton', } as EuiButtonEmptyProps & EuiLinkProps; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx index 2c0fbe1275cbf..0f4f6c65d083c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx @@ -13,7 +13,6 @@ import { EuiEmptyPrompt, EuiLink, EuiPanel, EuiSpacer, EuiLinkProps } from '@ela import { FormattedMessage } from '@kbn/i18n/react'; import { ContentSection } from '../shared/content_section'; -import { useRoutes } from '../shared/use_routes'; import { sendTelemetry } from '../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../index'; import { getSourcePath } from '../../routes'; @@ -92,8 +91,10 @@ export const RecentActivityItem: React.FC = ({ timestamp, sourceId, }) => { - const { http } = useContext(KibanaContext) as IKibanaContext; - const { getWSRoute } = useRoutes(); + const { + http, + externalUrl: { getWorkplaceSearchUrl }, + } = useContext(KibanaContext) as IKibanaContext; const onClick = () => sendTelemetry({ @@ -106,7 +107,7 @@ export const RecentActivityItem: React.FC = ({ const linkProps = { onClick, target: '_blank', - href: getWSRoute(getSourcePath(sourceId)), + href: getWorkplaceSearchUrl(getSourcePath(sourceId)), external: true, color: status === 'error' ? 'danger' : 'primary', 'data-test-subj': 'viewSourceDetailsLink', diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx index 9bc8f4f768073..3e1d285698c0c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useContext } from 'react'; import { EuiCard, EuiFlexItem, EuiTitle, EuiTextColor } from '@elastic/eui'; -import { useRoutes } from '../shared/use_routes'; +import { KibanaContext, IKibanaContext } from '../../../index'; interface IStatisticCardProps { title: string; @@ -17,11 +17,13 @@ interface IStatisticCardProps { } export const StatisticCard: React.FC = ({ title, count = 0, actionPath }) => { - const { getWSRoute } = useRoutes(); + const { + externalUrl: { getWorkplaceSearchUrl }, + } = useContext(KibanaContext) as IKibanaContext; const linkProps = actionPath ? { - href: getWSRoute(actionPath), + href: getWorkplaceSearchUrl(actionPath), target: '_blank', rel: 'noopener', } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx index 5b86e14132e0f..a914000654165 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx @@ -13,14 +13,17 @@ import { sendTelemetry } from '../../../../shared/telemetry'; import { KibanaContext, IKibanaContext } from '../../../../index'; export const ProductButton: React.FC = () => { - const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; + const { + externalUrl: { getWorkplaceSearchUrl }, + http, + } = useContext(KibanaContext) as IKibanaContext; const buttonProps = { fill: true, iconType: 'popout', 'data-test-subj': 'launchButton', } as EuiButtonProps & EuiLinkProps; - buttonProps.href = `${enterpriseSearchUrl}/ws`; + buttonProps.href = getWorkplaceSearchUrl(); buttonProps.target = '_blank'; buttonProps.onClick = () => sendTelemetry({ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx deleted file mode 100644 index 48b8695f82b43..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useContext } from 'react'; - -import { KibanaContext, IKibanaContext } from '../../../../index'; - -export const useRoutes = () => { - const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; - const getWSRoute = (path: string): string => `${enterpriseSearchUrl}/ws${path}`; - return { getWSRoute }; -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx index a4af405247f83..a55ff64014130 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -10,7 +10,6 @@ import React, { useContext } from 'react'; import { Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { SetupGuide } from './components/setup_guide'; import { Overview } from './components/overview'; import { WorkplaceSearch } from './'; @@ -18,16 +17,18 @@ import { WorkplaceSearch } from './'; describe('Workplace Search', () => { describe('/', () => { it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => { - (useContext as jest.Mock).mockImplementationOnce(() => ({ enterpriseSearchUrl: '' })); + (useContext as jest.Mock).mockImplementationOnce(() => ({ + config: { host: '' }, + })); const wrapper = shallow(); expect(wrapper.find(Redirect)).toHaveLength(1); expect(wrapper.find(Overview)).toHaveLength(0); }); - it('renders Engine Overview when enterpriseSearchUrl is set', () => { + it('renders the Overview when enterpriseSearchUrl is set', () => { (useContext as jest.Mock).mockImplementationOnce(() => ({ - enterpriseSearchUrl: 'https://foo.bar', + config: { host: 'https://foo.bar' }, })); const wrapper = shallow(); @@ -35,12 +36,4 @@ describe('Workplace Search', () => { expect(wrapper.find(Redirect)).toHaveLength(0); }); }); - - describe('/setup_guide', () => { - it('renders', () => { - const wrapper = shallow(); - - expect(wrapper.find(SetupGuide)).toHaveLength(1); - }); - }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index 6470a3b78c5f1..ca0d395c0d673 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -24,8 +24,8 @@ import { SetupGuide } from './components/setup_guide'; import { Overview } from './components/overview'; export const WorkplaceSearch: React.FC = () => { - const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; - if (!enterpriseSearchUrl) + const { config } = useContext(KibanaContext) as IKibanaContext; + if (!config.host) return ( diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 42ad7de93b00e..0d392eefe0aa2 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -21,13 +21,21 @@ import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { LicensingPluginSetup } from '../../licensing/public'; import { APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN } from '../common/constants'; -import { getPublicUrl } from './applications/shared/enterprise_search_url'; +import { + getPublicUrl, + ExternalUrl, + IExternalUrl, +} from './applications/shared/enterprise_search_url'; import AppSearchLogo from './applications/app_search/assets/logo.svg'; import WorkplaceSearchLogo from './applications/workplace_search/assets/logo.svg'; export interface ClientConfigType { host?: string; } +export interface ClientData { + externalUrl: IExternalUrl; +} + export interface PluginsSetup { home: HomePublicPluginSetup; licensing: LicensingPluginSetup; @@ -35,15 +43,15 @@ export interface PluginsSetup { export class EnterpriseSearchPlugin implements Plugin { private config: ClientConfigType; - private hasCheckedPublicUrl: boolean = false; + private hasInitialized: boolean = false; + private data: ClientData = {} as ClientData; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.get(); + this.data.externalUrl = new ExternalUrl(this.config.host || ''); } public setup(core: CoreSetup, plugins: PluginsSetup) { - const config = { host: this.config.host }; - core.application.register({ id: APP_SEARCH_PLUGIN.ID, title: APP_SEARCH_PLUGIN.NAME, @@ -54,12 +62,12 @@ export class EnterpriseSearchPlugin implements Plugin { const { chrome } = coreStart; chrome.docTitle.change(APP_SEARCH_PLUGIN.NAME); - await this.setPublicUrl(config, coreStart.http); + await this.getInitialData(coreStart.http); const { renderApp } = await import('./applications'); const { AppSearch } = await import('./applications/app_search'); - return renderApp(AppSearch, coreStart, params, config, plugins); + return renderApp(AppSearch, params, coreStart, plugins, this.config, this.data); }, }); @@ -73,12 +81,12 @@ export class EnterpriseSearchPlugin implements Plugin { const { chrome } = coreStart; chrome.docTitle.change(WORKPLACE_SEARCH_PLUGIN.NAME); - await this.setPublicUrl(config, coreStart.http); + await this.getInitialData(coreStart.http); const { renderApp } = await import('./applications'); const { WorkplaceSearch } = await import('./applications/workplace_search'); - return renderApp(WorkplaceSearch, coreStart, params, config, plugins); + return renderApp(WorkplaceSearch, params, coreStart, plugins, this.config, this.data); }, }); @@ -107,12 +115,14 @@ export class EnterpriseSearchPlugin implements Plugin { public stop() {} - private async setPublicUrl(config: ClientConfigType, http: HttpSetup) { - if (!config.host) return; // No API to check - if (this.hasCheckedPublicUrl) return; // We've already performed the check + private async getInitialData(http: HttpSetup) { + if (!this.config.host) return; // No API to call + if (this.hasInitialized) return; // We've already made an initial call + // TODO: Rename to something more generic once we start fetching more data than just external_url from this endpoint const publicUrl = await getPublicUrl(http); - if (publicUrl) config.host = publicUrl; - this.hasCheckedPublicUrl = true; + + if (publicUrl) this.data.externalUrl = new ExternalUrl(publicUrl); + this.hasInitialized = true; } } diff --git a/x-pack/plugins/file_upload/public/index.ts b/x-pack/plugins/file_upload/public/index.ts index 1e39fb4dc8596..71853488eba89 100644 --- a/x-pack/plugins/file_upload/public/index.ts +++ b/x-pack/plugins/file_upload/public/index.ts @@ -10,4 +10,5 @@ export function plugin() { return new FileUploadPlugin(); } +export { StartContract } from './plugin'; export { FileUploadComponentProps } from './get_file_upload_component'; diff --git a/x-pack/plugins/file_upload/public/plugin.ts b/x-pack/plugins/file_upload/public/plugin.ts index ff74be659aeca..0431e660abe88 100644 --- a/x-pack/plugins/file_upload/public/plugin.ts +++ b/x-pack/plugins/file_upload/public/plugin.ts @@ -4,33 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; import { CoreSetup, CoreStart, Plugin } from 'kibana/server'; -import { getFileUploadComponent } from './get_file_upload_component'; +import { FileUploadComponentProps, getFileUploadComponent } from './get_file_upload_component'; // @ts-ignore import { setupInitServicesAndConstants, startInitServicesAndConstants } from './kibana_services'; import { IDataPluginServices } from '../../../../src/plugins/data/public'; -/** - * These are the interfaces with your public contracts. You should export these - * for other plugins to use in _their_ `SetupDeps`/`StartDeps` interfaces. - * @public - */ - // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface FileUploadPluginSetupDependencies {} -export interface FileUploadPluginStartDependencies { +export interface SetupDependencies {} +export interface StartDependencies { data: IDataPluginServices; } -export type FileUploadPluginSetup = ReturnType; -export type FileUploadPluginStart = ReturnType; +export type SetupContract = ReturnType; +export interface StartContract { + getFileUploadComponent: () => Promise>; +} -export class FileUploadPlugin implements Plugin { - public setup(core: CoreSetup, plugins: FileUploadPluginSetupDependencies) { +export class FileUploadPlugin + implements Plugin { + public setup(core: CoreSetup, plugins: SetupDependencies) { setupInitServicesAndConstants(core); } - public start(core: CoreStart, plugins: FileUploadPluginStartDependencies) { + public start(core: CoreStart, plugins: StartDependencies) { startInitServicesAndConstants(core, plugins); return { getFileUploadComponent, diff --git a/x-pack/plugins/global_search_providers/public/providers/application.test.ts b/x-pack/plugins/global_search_providers/public/providers/application.test.ts index ca19bddb60297..7d4143f9bcfa7 100644 --- a/x-pack/plugins/global_search_providers/public/providers/application.test.ts +++ b/x-pack/plugins/global_search_providers/public/providers/application.test.ts @@ -6,7 +6,7 @@ import { getAppResultsMock } from './application.test.mocks'; -import { of, EMPTY } from 'rxjs'; +import { EMPTY, of } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; import { ApplicationStart, AppNavLinkStatus, AppStatus, PublicAppInfo } from 'src/core/public'; import { @@ -100,6 +100,20 @@ describe('applicationResultProvider', () => { expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); }); + it('ignores apps with non-visible navlink', async () => { + application.applications$ = of( + createAppMap([ + createApp({ id: 'app1', title: 'App 1', navLinkStatus: AppNavLinkStatus.visible }), + createApp({ id: 'disabled', title: 'disabled', navLinkStatus: AppNavLinkStatus.disabled }), + createApp({ id: 'hidden', title: 'hidden', navLinkStatus: AppNavLinkStatus.hidden }), + ]) + ); + const provider = createApplicationResultProvider(Promise.resolve(application)); + await provider.find('term', defaultOption).toPromise(); + + expect(getAppResultsMock).toHaveBeenCalledWith('term', [expectApp('app1')]); + }); + it('ignores chromeless apps', async () => { application.applications$ = of( createAppMap([ diff --git a/x-pack/plugins/global_search_providers/public/providers/application.ts b/x-pack/plugins/global_search_providers/public/providers/application.ts index e40fcef17f73c..45264a3b2c521 100644 --- a/x-pack/plugins/global_search_providers/public/providers/application.ts +++ b/x-pack/plugins/global_search_providers/public/providers/application.ts @@ -17,7 +17,8 @@ export const createApplicationResultProvider = ( mergeMap((application) => application.applications$), map((apps) => [...apps.values()].filter( - (app) => app.status === 0 && (app.legacy === true || app.chromeless !== true) + // only include non-chromeless enabled apps with visible navLinks + (app) => app.status === 0 && app.navLinkStatus === 1 && app.chromeless !== true ) ), shareReplay(1) diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts index 84e05c67c5f66..11e3a40ddee17 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts @@ -112,7 +112,7 @@ describe('savedObjectsResultProvider', () => { expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ page: 1, perPage: defaultOption.maxResults, - search: 'term', + search: 'term*', preference: 'pref', searchFields: ['title', 'description'], type: ['typeA', 'typeB'], diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts index b423b19ebc672..8a3d3d732531f 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts @@ -25,7 +25,7 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider = const responsePromise = client.find({ page: 1, perPage: maxResults, - search: term, + search: term ? `${term}*` : undefined, preference, searchFields, type: searchableTypes.map((type) => type.name), diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx index ecaa40b398d08..ce5f2a60f5165 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx @@ -111,14 +111,17 @@ export const CreateField = React.memo(function CreateFieldComponent({ {/* Field subType (if any) */} - {({ type }) => ( - - )} + {({ type }) => { + const [fieldType] = type; + return ( + + ); + }} ); @@ -188,7 +191,10 @@ export const CreateField = React.memo(function CreateFieldComponent({ {({ type, subType }) => { - const ParametersForm = getParametersFormForType(type, subType); + const ParametersForm = getParametersFormForType( + type?.[0].value, + subType?.[0].value + ); if (!ParametersForm) { return null; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx index e6950ccfe253e..a9bbf008e5129 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx @@ -98,15 +98,15 @@ export const EditField = React.memo(({ form, field, allFields, exitEdit, updateF {({ type, subType }) => { const linkDocumentation = - documentationService.getTypeDocLink(subType) || - documentationService.getTypeDocLink(type); + documentationService.getTypeDocLink(subType?.[0].value) || + documentationService.getTypeDocLink(type?.[0].value); if (!linkDocumentation) { return null; } - const typeDefinition = TYPE_DEFINITION[type as MainType]; - const subTypeDefinition = TYPE_DEFINITION[subType as SubType]; + const typeDefinition = TYPE_DEFINITION[type[0].value as MainType]; + const subTypeDefinition = TYPE_DEFINITION[subType?.[0].value as SubType]; return ( @@ -148,7 +148,7 @@ export const EditField = React.memo(({ form, field, allFields, exitEdit, updateF {({ type, subType }) => { - const ParametersForm = getParametersFormForType(type, subType); + const ParametersForm = getParametersFormForType(type?.[0].value, subType?.[0].value); if (!ParametersForm) { return null; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_header_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_header_form.tsx index 5b969fa7ed827..b4b5bce21f768 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_header_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_header_form.tsx @@ -36,15 +36,18 @@ export const EditFieldHeaderForm = React.memo( {/* Field subType (if any) */} - {({ type }) => ( - - )} + {({ type }) => { + const [fieldType] = type; + return ( + + ); + }} @@ -52,7 +55,7 @@ export const EditFieldHeaderForm = React.memo( {({ type, subType }) => { - const typeDefinition = TYPE_DEFINITION[type as MainType]; + const typeDefinition = TYPE_DEFINITION[type[0].value as MainType]; const hasSubType = typeDefinition.subTypes !== undefined; if (hasSubType) { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts index 18a8270117ea4..2a8368c666859 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts @@ -335,6 +335,11 @@ export const reducer = (state: State, action: Action): State => { return { ...state, fields: updatedFields, + documentFields: { + ...state.documentFields, + // If we removed the last field, show the "Create field" form + status: updatedFields.rootLevelFields.length === 0 ? 'creatingField' : 'idle', + }, // If we have a search in progress, we reexecute the search to update our result array search: Boolean(state.search.term) ? { diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx index 2d53203a60e4f..5077bccdc1ca2 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx @@ -8,7 +8,7 @@ import React, { memo, useState, useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { isEmpty } from 'lodash'; -import { euiStyled } from '../../../../../observability/public'; +import { euiStyled, useUiTracker } from '../../../../../observability/public'; import { isTimestampColumn } from '../../../utils/log_entry'; import { LogColumnConfiguration, @@ -68,6 +68,8 @@ export const LogEntryRow = memo( scale, wrap, }: LogEntryRowProps) => { + const trackMetric = useUiTracker({ app: 'infra_logs' }); + const [isHovered, setIsHovered] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -82,10 +84,10 @@ export const LogEntryRow = memo( logEntry.id, ]); - const handleOpenViewLogInContext = useCallback(() => openViewLogInContext?.(logEntry), [ - openViewLogInContext, - logEntry, - ]); + const handleOpenViewLogInContext = useCallback(() => { + openViewLogInContext?.(logEntry); // eslint-disable-line no-unused-expressions + trackMetric({ metric: 'view_in_context__stream' }); + }, [openViewLogInContext, logEntry, trackMetric]); const hasContext = useMemo(() => !isEmpty(logEntry.context), [logEntry]); const hasActionFlyoutWithItem = openFlyoutWithItem !== undefined; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx index 908e52f01cbcc..7bcc05280994c 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx @@ -26,6 +26,7 @@ import { import { LogColumnConfiguration } from '../../../../../utils/source_configuration'; import { LogEntryContextMenu } from '../../../../../components/logging/log_text_stream/log_entry_context_menu'; import { useLinkProps } from '../../../../../hooks/use_link_props'; +import { useUiTracker } from '../../../../../../../observability/public'; export const exampleMessageScale = 'medium' as const; export const exampleTimestampFormat = 'dateTime' as const; @@ -39,6 +40,7 @@ export const CategoryExampleMessage: React.FunctionComponent<{ tiebreaker: number; context: LogEntryContext; }> = ({ id, dataset, message, timestamp, timeRange, tiebreaker, context }) => { + const trackMetric = useUiTracker({ app: 'infra_logs' }); const [, { setContextEntry }] = useContext(ViewLogInContext.Context); // handle special cases for the dataset value const humanFriendlyDataset = getFriendlyNameForPartitionId(dataset); @@ -129,6 +131,7 @@ export const CategoryExampleMessage: React.FunctionComponent<{ cursor: { time: timestamp, tiebreaker }, columns: [], }; + trackMetric({ metric: 'view_in_context__categories' }); setContextEntry(logEntry); }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/on_failure_processors_title.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/on_failure_processors_title.tsx index d223653442819..d2c001b0aaa13 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/on_failure_processors_title.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/on_failure_processors_title.tsx @@ -8,10 +8,10 @@ import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { usePipelineProcessorsContext } from '../pipeline_processors_editor/context'; +import { useKibana } from '../../../shared_imports'; export const OnFailureProcessorsTitle: FunctionComponent = () => { - const { links } = usePipelineProcessorsContext(); + const { services } = useKibana(); return ( { values={{ learnMoreLink: ( {i18n.translate( diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx index 32beb61039a90..3a97e6408b144 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx @@ -10,7 +10,7 @@ import { EuiSpacer, EuiSwitch, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { Processor } from '../../../../common/types'; -import { getUseField, getFormRow, Field, useKibana } from '../../../shared_imports'; +import { getUseField, getFormRow, Field } from '../../../shared_imports'; import { ProcessorsEditorContextProvider, @@ -45,8 +45,6 @@ export const PipelineFormFields: React.FunctionComponent = ({ hasVersion, onEditorFlyoutOpen, }) => { - const { services } = useKibana(); - const [isVersionVisible, setIsVersionVisible] = useState(hasVersion); return ( @@ -123,9 +121,6 @@ export const PipelineFormFields: React.FunctionComponent = ({ diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/processors_header.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/processors_header.tsx index 3e8cd999a484a..1f27d611e54d4 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/processors_header.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/processors_header.tsx @@ -9,7 +9,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiText, EuiTitle } from '@elastic/ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { usePipelineProcessorsContext } from '../pipeline_processors_editor/context'; +import { useKibana } from '../../../shared_imports'; import { LoadFromJsonButton, @@ -22,7 +22,7 @@ export interface Props { } export const ProcessorsHeader: FunctionComponent = ({ onLoadJson }) => { - const { links } = usePipelineProcessorsContext(); + const { services } = useKibana(); return ( = ({ onLoadJson }) => { defaultMessage="The processors used to pre-process documents before indexing. {learnMoreLink}" values={{ learnMoreLink: ( - + {i18n.translate( 'xpack.ingestPipelines.pipelineEditor.processorsDocumentationLink', { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx index 5ac43953e79bc..2e7a47e0c93de 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx @@ -5,6 +5,11 @@ */ import { act } from 'react-dom/test-utils'; import React from 'react'; + +import { notificationServiceMock, scopedHistoryMock } from 'src/core/public/mocks'; + +import { LocationDescriptorObject } from 'history'; +import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; import { registerTestBed, TestBed } from '../../../../../../../test_utils'; import { ProcessorsEditorContextProvider, @@ -13,6 +18,13 @@ import { GlobalOnFailureProcessorsEditor, } from '../'; +import { + breadcrumbService, + uiMetricService, + documentationService, + apiService, +} from '../../../services'; + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); @@ -60,11 +72,27 @@ jest.mock('react-virtualized', () => { }; }); +const history = scopedHistoryMock.create(); +history.createHref.mockImplementation((location: LocationDescriptorObject) => { + return `${location.pathname}?${location.search}`; +}); + +const appServices = { + breadcrumbs: breadcrumbService, + metric: uiMetricService, + documentation: documentationService, + api: apiService, + notifications: notificationServiceMock.createSetupContract(), + history, +}; + const testBedSetup = registerTestBed( (props: Props) => ( - - - + + + + + ), { doMountAsync: false, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx index d3c5df02c837e..b12f324528167 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx @@ -3,11 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { notificationServiceMock } from 'src/core/public/mocks'; import { setup, SetupResult } from './pipeline_processors_editor.helpers'; import { Pipeline } from '../../../../../common/types'; -import { apiService } from '../../../services'; const testProcessors: Pick = { processors: [ @@ -46,11 +44,6 @@ describe('Pipeline Editor', () => { }, onFlyoutOpen: jest.fn(), onUpdate, - links: { - esDocsBasePath: 'test', - }, - toasts: notificationServiceMock.createSetupContract().toasts, - api: apiService, }); }); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/field_components/xjson_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/field_components/xjson_editor.tsx index 228094c0dfac5..7fb92e89c9f68 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/field_components/xjson_editor.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/field_components/xjson_editor.tsx @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { XJsonLang } from '@kbn/monaco'; import React, { FunctionComponent, useCallback } from 'react'; import { FieldHook, Monaco } from '../../../../../../shared_imports'; @@ -33,9 +32,6 @@ export const XJsonEditor: FunctionComponent = ({ field, editorProps }) => value: xJson, languageId: XJsonLang.ID, options: { minimap: { enabled: false } }, - editorDidMount: (m: any) => { - XJsonLang.registerGrammarChecker(m); - }, onChange, ...editorProps, }} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/manage_processor_form.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/manage_processor_form.container.tsx index 84551ce152099..083529921b0a7 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/manage_processor_form.container.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/manage_processor_form.container.tsx @@ -6,11 +6,10 @@ import React, { FunctionComponent, useCallback, useEffect } from 'react'; -import { useForm, OnFormUpdateArg, FormData } from '../../../../../shared_imports'; +import { useForm, OnFormUpdateArg, FormData, useKibana } from '../../../../../shared_imports'; import { ProcessorInternal } from '../../types'; import { ManageProcessorForm as ViewComponent } from './manage_processor_form'; -import { usePipelineProcessorsContext } from '../../context'; export type ManageProcessorFormOnSubmitArg = Omit; @@ -33,9 +32,7 @@ export const ManageProcessorForm: FunctionComponent = ({ onSubmit, ...rest }) => { - const { - links: { esDocsBasePath }, - } = usePipelineProcessorsContext(); + const { services } = useKibana(); const handleSubmit = useCallback( async (data: FormData, isValid: boolean) => { @@ -67,6 +64,11 @@ export const ManageProcessorForm: FunctionComponent = ({ }, [onFormUpdate]); return ( - + ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx index 502045084b24d..b5f7df9117f35 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx @@ -81,7 +81,7 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { FieldsComponent: DateIndexName, docLinkPath: '/date-index-name-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.dateIndexName', { - defaultMessage: 'Date Index Name', + defaultMessage: 'Date index name', }), }, dissect: { @@ -95,7 +95,7 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { FieldsComponent: DotExpander, docLinkPath: '/dot-expand-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.dotExpander', { - defaultMessage: 'Dot Expander', + defaultMessage: 'Dot expander', }), }, drop: { @@ -144,7 +144,7 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { FieldsComponent: undefined, // TODO: Implement docLinkPath: '/htmlstrip-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.htmlStrip', { - defaultMessage: 'HTML Strip', + defaultMessage: 'HTML strip', }), }, inference: { @@ -214,7 +214,7 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { FieldsComponent: undefined, // TODO: Implement docLinkPath: '/ingest-node-set-security-user-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.setSecurityUser', { - defaultMessage: 'Set Security User', + defaultMessage: 'Set security user', }), }, split: { @@ -249,14 +249,14 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { FieldsComponent: undefined, // TODO: Implement docLinkPath: '/urldecode-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.urldecode', { - defaultMessage: 'URL Decode', + defaultMessage: 'URL decode', }), }, user_agent: { FieldsComponent: undefined, // TODO: Implement docLinkPath: '/user-agent-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.userAgent', { - defaultMessage: 'User Agent', + defaultMessage: 'User agent', }), }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/flyout_provider.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/flyout_provider.tsx index ad88259e3bcc4..53aeb9fdc08ba 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/flyout_provider.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/flyout_provider.tsx @@ -17,6 +17,8 @@ import { EuiCallOut, } from '@elastic/eui'; +import { useKibana } from '../../../../../shared_imports'; + import { usePipelineProcessorsContext, useTestConfigContext } from '../../context'; import { serialize } from '../../serialize'; @@ -27,10 +29,9 @@ export interface Props { } export const FlyoutProvider: React.FunctionComponent = ({ children }) => { + const { services } = useKibana(); const { state: { processors }, - api, - toasts, } = usePipelineProcessorsContext(); const serializedProcessors = serialize(processors.state); @@ -53,7 +54,7 @@ export const FlyoutProvider: React.FunctionComponent = ({ children }) => setIsExecuting(true); setExecuteError(null); - const { error, data: output } = await api.simulatePipeline({ + const { error, data: output } = await services.api.simulatePipeline({ documents, verbose, pipeline: { ...serializedProcessors }, @@ -68,7 +69,7 @@ export const FlyoutProvider: React.FunctionComponent = ({ children }) => setExecuteOutput(output); - toasts.addSuccess( + services.notifications.toasts.addSuccess( i18n.translate('xpack.ingestPipelines.testPipelineFlyout.successNotificationText', { defaultMessage: 'Pipeline executed', }), @@ -79,7 +80,7 @@ export const FlyoutProvider: React.FunctionComponent = ({ children }) => setSelectedTab('output'); }, - [serializedProcessors, api, toasts] + [services.api, services.notifications.toasts, serializedProcessors] ); useEffect(() => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/flyout_tabs/tab_documents.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/flyout_tabs/tab_documents.tsx index 593347f8b2343..794d935571210 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/flyout_tabs/tab_documents.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/flyout_tabs/tab_documents.tsx @@ -17,9 +17,10 @@ import { Form, useForm, FormConfig, + useKibana, } from '../../../../../../shared_imports'; -import { usePipelineProcessorsContext, useTestConfigContext, TestConfig } from '../../../context'; +import { useTestConfigContext, TestConfig } from '../../../context'; import { documentsSchema } from './schema'; @@ -31,7 +32,7 @@ interface Props { } export const DocumentsTab: React.FunctionComponent = ({ handleExecute, isExecuting }) => { - const { links } = usePipelineProcessorsContext(); + const { services } = useKibana(); const { setCurrentTestConfig, testConfig } = useTestConfigContext(); const { verbose: cachedVerbose, documents: cachedDocuments } = testConfig; @@ -71,7 +72,7 @@ export const DocumentsTab: React.FunctionComponent = ({ handleExecute, is values={{ learnMoreLink: ( diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/context.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/context.tsx index 1ccfcc8e19755..a1ea0fd9d0b9e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/context.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/context.tsx @@ -18,9 +18,6 @@ interface Props extends ProcessorsContextProps { export const ProcessorsEditorContextProvider: FunctionComponent = ({ children, - links, - api, - toasts, onUpdate, value, onFlyoutOpen, @@ -29,9 +26,6 @@ export const ProcessorsEditorContextProvider: FunctionComponent = ({ diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/processors_context.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/processors_context.tsx index 7124efc4905a7..f83803da7bf91 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/processors_context.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/processors_context.tsx @@ -15,10 +15,7 @@ import React, { useRef, } from 'react'; -import { NotificationsSetup } from 'src/core/public'; - import { Processor } from '../../../../../common/types'; -import { ApiService } from '../../../services'; import { EditorMode, @@ -27,7 +24,6 @@ import { OnUpdateHandlerArg, ContextValue, ContextValueState, - Links, ProcessorInternal, } from '../types'; @@ -51,9 +47,6 @@ import { getValue } from '../utils'; const PipelineProcessorsContext = createContext({} as any); export interface Props { - links: Links; - api: ApiService; - toasts: NotificationsSetup['toasts']; value: { processors: Processor[]; onFailure?: Processor[]; @@ -66,9 +59,6 @@ export interface Props { } export const PipelineProcessorsContextProvider: FunctionComponent = ({ - links, - api, - toasts, value: { processors: originalProcessors, onFailure: originalOnFailureProcessors }, onUpdate, onFlyoutOpen, @@ -211,9 +201,6 @@ export const PipelineProcessorsContextProvider: FunctionComponent = ({ return ( - {layerIds.map((layerId) => ( + {layerIds.map((layerId, index) => ( { onRemoveLayer: jest.fn(), dispatch: jest.fn(), core: coreMock.createStart(), + dataTestSubj: 'lns_layerPanel-0', }; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index a384e339e8fbd..38224bf962a3f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -36,6 +36,7 @@ const initialPopoverState = { export function LayerPanel( props: Exclude & { layerId: string; + dataTestSubj: string; isOnlyLayer: boolean; updateVisualization: StateSetter; updateDatasource: (datasourceId: string, newState: unknown) => void; @@ -50,7 +51,7 @@ export function LayerPanel( const dragDropContext = useContext(DragContext); const [popoverState, setPopoverState] = useState(initialPopoverState); - const { framePublicAPI, layerId, isOnlyLayer, onRemoveLayer } = props; + const { framePublicAPI, layerId, isOnlyLayer, onRemoveLayer, dataTestSubj } = props; const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId]; useEffect(() => { @@ -96,7 +97,7 @@ export function LayerPanel( return ( - + { return ( - - {preview.expression ? ( - - ) : ( - - - - )} - {showTitleAsLabel && ( - {preview.title} - )} - +
+ + {preview.expression ? ( + + ) : ( + + + + )} + {showTitleAsLabel && ( + {preview.title} + )} + +
); }; @@ -204,8 +206,8 @@ export function SuggestionPanel({ : undefined; return { suggestions: newSuggestions, currentStateExpression: newStateExpression }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - frame, currentDatasourceStates, currentVisualizationState, currentVisualizationId, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/_field_item.scss b/x-pack/plugins/lens/public/indexpattern_datasource/_field_item.scss index 6e51c45ad02c1..d194c694abdf8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/_field_item.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/_field_item.scss @@ -1,47 +1,11 @@ -.lnsFieldItem { - @include euiFontSizeS; - background: lightOrDarkTheme($euiColorEmptyShade, $euiColorLightestShade); - border-radius: $euiBorderRadius; - margin-bottom: $euiSizeXS; -} - -.lnsFieldItem__popoverAnchor:hover, -.lnsFieldItem__popoverAnchor:focus, -.lnsFieldItem__popoverAnchor:focus-within { - @include euiBottomShadowMedium; - border-radius: $euiBorderRadius; - z-index: 2; -} - .lnsFieldItem--missing { - background: lightOrDarkTheme(transparentize($euiColorMediumShade, .9), $euiColorEmptyShade); - color: $euiColorDarkShade; + .lnsFieldItem__info { + background: lightOrDarkTheme(transparentize($euiColorMediumShade, .9), $euiColorEmptyShade); + color: $euiColorDarkShade; + } } .lnsFieldItem__info { - border-radius: $euiBorderRadius - 1px; - padding: $euiSizeS; - display: flex; - align-items: flex-start; - transition: box-shadow $euiAnimSpeedFast $euiAnimSlightResistance, - background-color $euiAnimSpeedFast $euiAnimSlightResistance; // sass-lint:disable-line indentation - - .lnsFieldItem__name { - margin-left: $euiSizeS; - flex-grow: 1; - word-break: break-word; - } - - .lnsFieldListPanel__fieldIcon, - .lnsFieldItem__infoIcon { - flex-shrink: 0; - } - - .lnsFieldListPanel__fieldIcon { - margin-top: $euiSizeXS / 2; - margin-right: $euiSizeXS / 2; - } - .lnsFieldItem__infoIcon { visibility: hidden; } @@ -56,10 +20,6 @@ } } -.lnsFieldItem__info-isOpen { - @include euiFocusRing; -} - .lnsFieldItem__topValue { margin-bottom: $euiSizeS; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx index f52e747a63e0b..781222888b6dc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; +import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { EuiLoadingSpinner, EuiPopover } from '@elastic/eui'; import { InnerFieldItem, FieldItemProps } from './field_item'; @@ -17,6 +18,10 @@ import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks' const chartsThemeService = chartPluginMock.createSetupContract().theme; +function clickField(wrapper: ReactWrapper, field: string) { + wrapper.find(`[data-test-subj="lnsFieldListPanelField-${field}"] button`).simulate('click'); +} + describe('IndexPattern Field Item', () => { let defaultProps: FieldItemProps; let indexPattern: IndexPattern; @@ -101,7 +106,7 @@ describe('IndexPattern Field Item', () => { it('should display displayName of a field', () => { const wrapper = mountWithIntl(); - expect(wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').text()).toEqual( + expect(wrapper.find('[data-test-subj="lnsFieldListPanelField"]').first().text()).toEqual( 'bytesLabel' ); }); @@ -114,7 +119,7 @@ describe('IndexPattern Field Item', () => { const wrapper = mountWithIntl(); await act(async () => { - wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').simulate('click'); + clickField(wrapper, 'bytes'); }); expect(core.http.post).toHaveBeenCalledWith( @@ -138,7 +143,7 @@ describe('IndexPattern Field Item', () => { const wrapper = mountWithIntl(); - wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').simulate('click'); + clickField(wrapper, 'bytes'); expect(core.http.post).toHaveBeenCalledWith( `/api/lens/index_stats/my-fake-index-pattern/field`, @@ -188,7 +193,7 @@ describe('IndexPattern Field Item', () => { expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').simulate('click'); + clickField(wrapper, 'bytes'); expect(core.http.post).toHaveBeenCalledTimes(1); act(() => { @@ -214,7 +219,7 @@ describe('IndexPattern Field Item', () => { }); }); - wrapper.find('[data-test-subj="lnsFieldListPanelField-bytes"]').simulate('click'); + clickField(wrapper, 'bytes'); expect(core.http.post).toHaveBeenCalledTimes(2); expect(core.http.post).toHaveBeenLastCalledWith( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx index add451cabc4ea..5bcfbc64ec706 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx @@ -11,7 +11,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiIconTip, - EuiKeyboardAccessible, EuiLoadingSpinner, EuiPopover, EuiPopoverFooter, @@ -40,6 +39,7 @@ import { esQuery, IIndexPattern, } from '../../../../../src/plugins/data/public'; +import { FieldButton } from '../../../../../src/plugins/kibana_react/public'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; import { DraggedField } from './indexpattern'; import { DragDrop } from '../drag_drop'; @@ -177,8 +177,27 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { field, indexPattern.id, ]); + const lensFieldIcon = ; + const lensInfoIcon = ( + + ); return ( - -
{ - if (exists) { - togglePopover(); - } - }} - onKeyPress={(event) => { - if (exists && event.key === 'ENTER') { - togglePopover(); - } - }} - aria-label={i18n.translate('xpack.lens.indexPattern.fieldStatsButtonLabel', { - defaultMessage: 'Click for a field preview, or drag and drop to visualize.', - })} - > - - - - {wrappableHighlightableFieldName} - - - -
-
+ { + if (exists) { + togglePopover(); + } + }} + aria-label={i18n.translate('xpack.lens.indexPattern.fieldStatsButtonAriaLabel', { + defaultMessage: '{fieldName}: {fieldType}. Hit enter for a field preview.', + values: { + fieldName: field.displayName, + fieldType: field.type, + }, + })} + fieldIcon={lensFieldIcon} + fieldName={wrappableHighlightableFieldName} + fieldInfoIcon={lensInfoIcon} + /> } isOpen={infoIsOpen} diff --git a/x-pack/plugins/licensing/server/plugin.test.ts b/x-pack/plugins/licensing/server/plugin.test.ts index 6e8327e151543..b1669db00f227 100644 --- a/x-pack/plugins/licensing/server/plugin.test.ts +++ b/x-pack/plugins/licensing/server/plugin.test.ts @@ -37,7 +37,11 @@ function createCoreSetupWith(esClient: ILegacyClusterClient) { ...coreStart, elasticsearch: { ...coreStart.elasticsearch, - legacy: { client: esClient, createClient: jest.fn() }, + legacy: { + ...coreStart.elasticsearch.legacy, + client: esClient, + createClient: jest.fn(), + }, }, }, {}, diff --git a/x-pack/plugins/lists/common/constants.mock.ts b/x-pack/plugins/lists/common/constants.mock.ts index 428cc90d2908b..46ed524ff33e3 100644 --- a/x-pack/plugins/lists/common/constants.mock.ts +++ b/x-pack/plugins/lists/common/constants.mock.ts @@ -6,6 +6,7 @@ import moment from 'moment'; import { EntriesArray } from './schemas/types'; +import { EndpointEntriesArray } from './schemas/types/endpoint'; export const DATE_NOW = '2020-04-20T15:25:31.830Z'; export const OLD_DATE_RELATIVE_TO_DATE_NOW = '2020-04-19T15:25:31.830Z'; export const USER = 'some user'; @@ -41,6 +42,7 @@ export const ITEM_ID = 'some-list-item-id'; export const ENDPOINT_TYPE = 'endpoint'; export const FIELD = 'host.name'; export const OPERATOR = 'included'; +export const OPERATOR_EXCLUDED = 'excluded'; export const ENTRY_VALUE = 'some host name'; export const MATCH = 'match'; export const MATCH_ANY = 'match_any'; @@ -57,6 +59,14 @@ export const ENTRIES: EntriesArray = [ }, { field: 'some.not.nested.field', operator: 'included', type: 'match', value: 'some value' }, ]; +export const ENDPOINT_ENTRIES: EndpointEntriesArray = [ + { + entries: [{ field: 'nested.field', operator: 'included', type: 'match', value: 'some value' }], + field: 'some.parentField', + type: 'nested', + }, + { field: 'some.not.nested.field', operator: 'included', type: 'match', value: 'some value' }, +]; export const ITEM_TYPE = 'simple'; export const _TAGS = []; export const TAGS = []; diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.ts b/x-pack/plugins/lists/common/schemas/common/schemas.ts index 0d52b075ebf12..1556ef5a5dab9 100644 --- a/x-pack/plugins/lists/common/schemas/common/schemas.ts +++ b/x-pack/plugins/lists/common/schemas/common/schemas.ts @@ -274,6 +274,7 @@ export type CursorOrUndefined = t.TypeOf; export const namespace_type = DefaultNamespace; +export const operatorIncluded = t.keyof({ included: null }); export const operator = t.keyof({ excluded: null, included: null }); export type Operator = t.TypeOf; export enum OperatorEnum { diff --git a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.mock.ts index 5c4aff1fedcd3..529e173618f15 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.mock.ts @@ -7,7 +7,7 @@ import { COMMENTS, DESCRIPTION, - ENTRIES, + ENDPOINT_ENTRIES, ITEM_TYPE, META, NAME, @@ -21,7 +21,7 @@ export const getCreateEndpointListItemSchemaMock = (): CreateEndpointListItemSch _tags: _TAGS, comments: COMMENTS, description: DESCRIPTION, - entries: ENTRIES, + entries: ENDPOINT_ENTRIES, item_id: undefined, meta: META, name: NAME, diff --git a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.ts index dacd9d515de51..d1fc167f5a92b 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.ts @@ -18,7 +18,8 @@ import { tags, } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; -import { CreateCommentsArray, DefaultCreateCommentsArray, nonEmptyEntriesArray } from '../types'; +import { CreateCommentsArray, DefaultCreateCommentsArray } from '../types'; +import { nonEmptyEndpointEntriesArray } from '../types/endpoint'; import { EntriesArray } from '../types/entries'; import { DefaultUuid } from '../../shared_imports'; @@ -26,7 +27,7 @@ export const createEndpointListItemSchema = t.intersection([ t.exact( t.type({ description, - entries: nonEmptyEntriesArray, + entries: nonEmptyEndpointEntriesArray, name, type: exceptionListItemType, }) diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entries.mock.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entries.mock.ts new file mode 100644 index 0000000000000..c3bc88ab57a90 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entries.mock.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EndpointEntriesArray } from './entries'; +import { getEndpointEntryMatchMock } from './entry_match.mock'; +import { getEndpointEntryMatchAnyMock } from './entry_match_any.mock'; +import { getEndpointEntryNestedMock } from './entry_nested.mock'; + +export const getEndpointEntriesArrayMock = (): EndpointEntriesArray => [ + getEndpointEntryMatchMock(), + getEndpointEntryMatchAnyMock(), + getEndpointEntryNestedMock(), +]; diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entries.test.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entries.test.ts new file mode 100644 index 0000000000000..ee52e6b7b6561 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entries.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../../shared_imports'; +import { getEntryExistsMock } from '../entry_exists.mock'; +import { getEntryListMock } from '../entry_list.mock'; + +import { getEndpointEntryMatchMock } from './entry_match.mock'; +import { getEndpointEntryMatchAnyMock } from './entry_match_any.mock'; +import { getEndpointEntryNestedMock } from './entry_nested.mock'; +import { getEndpointEntriesArrayMock } from './entries.mock'; +import { + NonEmptyEndpointEntriesArray, + endpointEntriesArray, + nonEmptyEndpointEntriesArray, +} from './entries'; + +describe('Endpoint', () => { + describe('entriesArray', () => { + test('it should validate an array with match entry', () => { + const payload = [getEndpointEntryMatchMock()]; + const decoded = endpointEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array with match_any entry', () => { + const payload = [getEndpointEntryMatchAnyMock()]; + const decoded = endpointEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate an empty array', () => { + const payload: NonEmptyEndpointEntriesArray = []; + const decoded = nonEmptyEndpointEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "[]" supplied to "NonEmptyEndpointEntriesArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('type guard for nonEmptyEndpointNestedEntries should allow array of endpoint entries', () => { + const payload: NonEmptyEndpointEntriesArray = [getEndpointEntryMatchAnyMock()]; + const guarded = nonEmptyEndpointEntriesArray.is(payload); + expect(guarded).toBeTruthy(); + }); + + test('type guard for nonEmptyEndpointNestedEntries should disallow empty arrays', () => { + const payload: NonEmptyEndpointEntriesArray = []; + const guarded = nonEmptyEndpointEntriesArray.is(payload); + expect(guarded).toBeFalsy(); + }); + + test('it should NOT validate an array with exists entry', () => { + const payload = [getEntryExistsMock()]; + const decoded = endpointEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "exists" supplied to "type"', + 'Invalid value "undefined" supplied to "value"', + 'Invalid value "undefined" supplied to "entries"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate an array with list entry', () => { + const payload = [getEntryListMock()]; + const decoded = endpointEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "list" supplied to "type"', + 'Invalid value "undefined" supplied to "value"', + 'Invalid value "undefined" supplied to "entries"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate an array with nested entry', () => { + const payload = [getEndpointEntryNestedMock()]; + const decoded = endpointEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array with all types of entries', () => { + const payload = getEndpointEntriesArrayMock(); + const decoded = endpointEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entries.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entries.ts new file mode 100644 index 0000000000000..ebdac1a44293a --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entries.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 * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +import { endpointEntryMatchAny } from './entry_match_any'; +import { endpointEntryMatch } from './entry_match'; +import { endpointEntryNested } from './entry_nested'; + +export const endpointEntriesArray = t.array( + t.union([endpointEntryMatch, endpointEntryMatchAny, endpointEntryNested]) +); +export type EndpointEntriesArray = t.TypeOf; + +/** + * Types the nonEmptyEndpointEntriesArray as: + * - An array of entries of length 1 or greater + * + */ +export const nonEmptyEndpointEntriesArray = new t.Type< + EndpointEntriesArray, + EndpointEntriesArray, + unknown +>( + 'NonEmptyEndpointEntriesArray', + (u: unknown): u is EndpointEntriesArray => endpointEntriesArray.is(u) && u.length > 0, + (input, context): Either => { + if (Array.isArray(input) && input.length === 0) { + return t.failure(input, context); + } else { + return endpointEntriesArray.validate(input, context); + } + }, + t.identity +); + +export type NonEmptyEndpointEntriesArray = t.OutputOf; +export type NonEmptyEndpointEntriesArrayDecoded = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.mock.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.mock.ts new file mode 100644 index 0000000000000..35d10544c7fdb --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.mock.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ENTRY_VALUE, FIELD, MATCH, OPERATOR } from '../../../constants.mock'; + +import { EndpointEntryMatch } from './entry_match'; + +export const getEndpointEntryMatchMock = (): EndpointEntryMatch => ({ + field: FIELD, + operator: OPERATOR, + type: MATCH, + value: ENTRY_VALUE, +}); diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.test.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.test.ts new file mode 100644 index 0000000000000..48f128be0bb31 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../../shared_imports'; +import { getEntryMatchMock } from '../entry_match.mock'; + +import { getEndpointEntryMatchMock } from './entry_match.mock'; +import { EndpointEntryMatch, endpointEntryMatch } from './entry_match'; + +describe('endpointEntryMatch', () => { + test('it should validate an entry', () => { + const payload = getEndpointEntryMatchMock(); + const decoded = endpointEntryMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate when "operator" is "excluded"', () => { + // Use the generic entry mock so we can test operator: excluded + const payload = getEntryMatchMock(); + payload.operator = 'excluded'; + const decoded = endpointEntryMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "excluded" supplied to "operator"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "field" is empty string', () => { + const payload: Omit & { field: string } = { + ...getEndpointEntryMatchMock(), + field: '', + }; + const decoded = endpointEntryMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "field"']); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "value" is not string', () => { + const payload: Omit & { value: string[] } = { + ...getEndpointEntryMatchMock(), + value: ['some value'], + }; + const decoded = endpointEntryMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "["some value"]" supplied to "value"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "value" is empty string', () => { + const payload: Omit & { value: string } = { + ...getEndpointEntryMatchMock(), + value: '', + }; + const decoded = endpointEntryMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "value"']); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "type" is not "match"', () => { + const payload: Omit & { type: string } = { + ...getEndpointEntryMatchMock(), + type: 'match_any', + }; + const decoded = endpointEntryMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "match_any" supplied to "type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: EndpointEntryMatch & { + extraKey?: string; + } = getEndpointEntryMatchMock(); + payload.extraKey = 'some value'; + const decoded = endpointEntryMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getEntryMatchMock()); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.ts new file mode 100644 index 0000000000000..853e71cf68dd4 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +import { NonEmptyString } from '../../../shared_imports'; +import { operatorIncluded } from '../../common/schemas'; + +export const endpointEntryMatch = t.exact( + t.type({ + field: NonEmptyString, + operator: operatorIncluded, + type: t.keyof({ match: null }), + value: NonEmptyString, + }) +); +export type EndpointEntryMatch = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.mock.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.mock.ts new file mode 100644 index 0000000000000..75544e76dadab --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.mock.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ENTRY_VALUE, FIELD, MATCH_ANY, OPERATOR } from '../../../constants.mock'; + +import { EndpointEntryMatchAny } from './entry_match_any'; + +export const getEndpointEntryMatchAnyMock = (): EndpointEntryMatchAny => ({ + field: FIELD, + operator: OPERATOR, + type: MATCH_ANY, + value: [ENTRY_VALUE], +}); diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.test.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.test.ts new file mode 100644 index 0000000000000..6e52855bc25d4 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.test.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../../shared_imports'; +import { getEntryMatchAnyMock } from '../entry_match_any.mock'; + +import { getEndpointEntryMatchAnyMock } from './entry_match_any.mock'; +import { EndpointEntryMatchAny, endpointEntryMatchAny } from './entry_match_any'; + +describe('endpointEntryMatchAny', () => { + test('it should validate an entry', () => { + const payload = getEndpointEntryMatchAnyMock(); + const decoded = endpointEntryMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate when operator is "excluded"', () => { + // Use the generic entry mock so we can test operator: excluded + const payload = getEntryMatchAnyMock(); + payload.operator = 'excluded'; + const decoded = endpointEntryMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "excluded" supplied to "operator"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when field is empty string', () => { + const payload: Omit & { field: string } = { + ...getEndpointEntryMatchAnyMock(), + field: '', + }; + const decoded = endpointEntryMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "field"']); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when value is empty array', () => { + const payload: Omit & { value: string[] } = { + ...getEndpointEntryMatchAnyMock(), + value: [], + }; + const decoded = endpointEntryMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "[]" supplied to "value"']); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when value is not string array', () => { + const payload: Omit & { value: string } = { + ...getEndpointEntryMatchAnyMock(), + value: 'some string', + }; + const decoded = endpointEntryMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "some string" supplied to "value"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "type" is not "match_any"', () => { + const payload: Omit & { type: string } = { + ...getEndpointEntryMatchAnyMock(), + type: 'match', + }; + const decoded = endpointEntryMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "match" supplied to "type"']); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: EndpointEntryMatchAny & { + extraKey?: string; + } = getEndpointEntryMatchAnyMock(); + payload.extraKey = 'some extra key'; + const decoded = endpointEntryMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getEntryMatchAnyMock()); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.ts new file mode 100644 index 0000000000000..8fda8357c7210 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_match_any.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +import { NonEmptyString } from '../../../shared_imports'; +import { operatorIncluded } from '../../common/schemas'; +import { nonEmptyOrNullableStringArray } from '../non_empty_or_nullable_string_array'; + +export const endpointEntryMatchAny = t.exact( + t.type({ + field: NonEmptyString, + operator: operatorIncluded, + type: t.keyof({ match_any: null }), + value: nonEmptyOrNullableStringArray, + }) +); +export type EndpointEntryMatchAny = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.mock.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.mock.ts new file mode 100644 index 0000000000000..5501631655c61 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.mock.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 { FIELD, NESTED } from '../../../constants.mock'; + +import { EndpointEntryNested } from './entry_nested'; +import { getEndpointEntryMatchMock } from './entry_match.mock'; +import { getEndpointEntryMatchAnyMock } from './entry_match_any.mock'; + +export const getEndpointEntryNestedMock = (): EndpointEntryNested => ({ + entries: [getEndpointEntryMatchMock(), getEndpointEntryMatchAnyMock()], + field: FIELD, + type: NESTED, +}); diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.test.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.test.ts new file mode 100644 index 0000000000000..f19da71a5369c --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.test.ts @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../../shared_imports'; +import { getEntryExistsMock } from '../entry_exists.mock'; + +import { getEndpointEntryNestedMock } from './entry_nested.mock'; +import { EndpointEntryNested, endpointEntryNested } from './entry_nested'; +import { getEndpointEntryMatchAnyMock } from './entry_match_any.mock'; +import { + NonEmptyEndpointNestedEntriesArray, + nonEmptyEndpointNestedEntriesArray, +} from './non_empty_nested_entries_array'; +import { getEndpointEntryMatchMock } from './entry_match.mock'; + +describe('endpointEntryNested', () => { + test('it should validate a nested entry', () => { + const payload = getEndpointEntryNestedMock(); + const decoded = endpointEntryNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should FAIL validation when "type" is not "nested"', () => { + const payload: Omit & { type: 'match' } = { + ...getEndpointEntryNestedMock(), + type: 'match', + }; + const decoded = endpointEntryNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "match" supplied to "type"']); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "field" is empty string', () => { + const payload: Omit & { + field: string; + } = { ...getEndpointEntryNestedMock(), field: '' }; + const decoded = endpointEntryNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "field"']); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "field" is not a string', () => { + const payload: Omit & { + field: number; + } = { ...getEndpointEntryNestedMock(), field: 1 }; + const decoded = endpointEntryNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "1" supplied to "field"']); + expect(message.schema).toEqual({}); + }); + + test('it should FAIL validation when "entries" is not an array', () => { + const payload: Omit & { + entries: string; + } = { ...getEndpointEntryNestedMock(), entries: 'im a string' }; + const decoded = endpointEntryNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "im a string" supplied to "entries"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate when "entries" contains an entry item that is type "match"', () => { + const payload = { ...getEndpointEntryNestedMock(), entries: [getEndpointEntryMatchAnyMock()] }; + const decoded = endpointEntryNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual({ + entries: [ + { + field: 'host.name', + operator: 'included', + type: 'match_any', + value: ['some host name'], + }, + ], + field: 'host.name', + type: 'nested', + }); + }); + + test('it should NOT validate when "entries" contains an entry item that is type "exists"', () => { + const payload = { ...getEndpointEntryNestedMock(), entries: [getEntryExistsMock()] }; + const decoded = endpointEntryNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "exists" supplied to "entries,type"', + 'Invalid value "undefined" supplied to "entries,value"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: EndpointEntryNested & { + extraKey?: string; + } = getEndpointEntryNestedMock(); + payload.extraKey = 'some extra key'; + const decoded = endpointEntryNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getEndpointEntryNestedMock()); + }); + + test('type guard for nonEmptyEndpointNestedEntries should allow array of endpoint entries', () => { + const payload: NonEmptyEndpointNestedEntriesArray = [ + getEndpointEntryMatchMock(), + getEndpointEntryMatchAnyMock(), + ]; + const guarded = nonEmptyEndpointNestedEntriesArray.is(payload); + expect(guarded).toBeTruthy(); + }); + + test('type guard for nonEmptyEndpointNestedEntries should disallow empty arrays', () => { + const payload: NonEmptyEndpointNestedEntriesArray = []; + const guarded = nonEmptyEndpointNestedEntriesArray.is(payload); + expect(guarded).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.ts new file mode 100644 index 0000000000000..aad24674af08a --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/entry_nested.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +import { NonEmptyString } from '../../../shared_imports'; + +import { nonEmptyEndpointNestedEntriesArray } from './non_empty_nested_entries_array'; + +export const endpointEntryNested = t.exact( + t.type({ + entries: nonEmptyEndpointNestedEntriesArray, + field: NonEmptyString, + type: t.keyof({ nested: null }), + }) +); +export type EndpointEntryNested = t.TypeOf; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/index.ts similarity index 85% rename from x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts rename to x-pack/plugins/lists/common/schemas/types/endpoint/index.ts index cb9684408c459..91554cd441db4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { useRoutes } from './use_routes'; +export * from './entries'; diff --git a/x-pack/plugins/lists/common/schemas/types/endpoint/non_empty_nested_entries_array.ts b/x-pack/plugins/lists/common/schemas/types/endpoint/non_empty_nested_entries_array.ts new file mode 100644 index 0000000000000..f7d6e307da763 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/endpoint/non_empty_nested_entries_array.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 * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +import { endpointEntryMatchAny } from './entry_match_any'; +import { endpointEntryMatch } from './entry_match'; + +export const endpointNestedEntriesArray = t.array( + t.union([endpointEntryMatch, endpointEntryMatchAny]) +); +export type EndpointNestedEntriesArray = t.TypeOf; + +/** + * Types the nonEmptyNestedEntriesArray as: + * - An array of entries of length 1 or greater + * + */ +export const nonEmptyEndpointNestedEntriesArray = new t.Type< + EndpointNestedEntriesArray, + EndpointNestedEntriesArray, + unknown +>( + 'NonEmptyEndpointNestedEntriesArray', + (u: unknown): u is EndpointNestedEntriesArray => endpointNestedEntriesArray.is(u) && u.length > 0, + (input, context): Either => { + if (Array.isArray(input) && input.length === 0) { + return t.failure(input, context); + } else { + return endpointNestedEntriesArray.validate(input, context); + } + }, + t.identity +); + +export type NonEmptyEndpointNestedEntriesArray = t.OutputOf< + typeof nonEmptyEndpointNestedEntriesArray +>; +export type NonEmptyEndpointNestedEntriesArrayDecoded = t.TypeOf< + typeof nonEmptyEndpointNestedEntriesArray +>; diff --git a/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts index f092aec82a8f3..e51e113239f20 100644 --- a/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts @@ -17,7 +17,7 @@ import { import { getExceptionListClient } from './utils/get_exception_list_client'; import { endpointDisallowedFields } from './endpoint_disallowed_fields'; -import { validateExceptionListSize } from './validate'; +import { validateEndpointExceptionItemEntries, validateExceptionListSize } from './validate'; export const createExceptionListItemRoute = (router: IRouter): void => { router.post( @@ -73,13 +73,11 @@ export const createExceptionListItemRoute = (router: IRouter): void => { }); } else { if (exceptionList.type === 'endpoint') { + const error = validateEndpointExceptionItemEntries(request.body.entries); + if (error != null) { + return siemResponse.error(error); + } for (const entry of entries) { - if (entry.type === 'list') { - return siemResponse.error({ - body: `cannot add exception item with entry of type "list" to endpoint exception list`, - statusCode: 400, - }); - } if (endpointDisallowedFields.includes(entry.field)) { return siemResponse.error({ body: `cannot add endpoint exception item on field ${entry.field}`, diff --git a/x-pack/plugins/lists/server/routes/import_list_item_route.ts b/x-pack/plugins/lists/server/routes/import_list_item_route.ts index d46c943d95fe9..f7ecc7ac1ac83 100644 --- a/x-pack/plugins/lists/server/routes/import_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/import_list_item_route.ts @@ -27,7 +27,9 @@ export const importListItemRoute = (router: IRouter, config: ConfigType): void = parse: false, }, tags: ['access:lists-all'], - timeout: config.importTimeout.asMilliseconds(), + timeout: { + payload: config.importTimeout.asMilliseconds(), + }, }, path: `${LIST_ITEM_URL}/_import`, validate: { diff --git a/x-pack/plugins/lists/server/routes/validate.ts b/x-pack/plugins/lists/server/routes/validate.ts index a7f5c96e13d7b..703d0eee8c65a 100644 --- a/x-pack/plugins/lists/server/routes/validate.ts +++ b/x-pack/plugins/lists/server/routes/validate.ts @@ -4,11 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ +import { pipe } from 'fp-ts/lib/pipeable'; +import * as t from 'io-ts'; +import { fold } from 'fp-ts/lib/Either'; + import { ExceptionListClient } from '../services/exception_lists/exception_list_client'; import { MAX_EXCEPTION_LIST_SIZE } from '../../common/constants'; import { foundExceptionListItemSchema } from '../../common/schemas'; -import { NamespaceType } from '../../common/schemas/types'; -import { validate } from '../../common/shared_imports'; +import { NamespaceType, NonEmptyEntriesArray } from '../../common/schemas/types'; +import { nonEmptyEndpointEntriesArray } from '../../common/schemas/types/endpoint'; +import { exactCheck, validate } from '../../common/shared_imports'; +import { formatErrors } from '../siem_server_deps'; export const validateExceptionListSize = async ( exceptionLists: ExceptionListClient, @@ -54,3 +60,20 @@ export const validateExceptionListSize = async ( } return null; }; + +export const validateEndpointExceptionItemEntries = ( + entries: NonEmptyEntriesArray +): { body: string[]; statusCode: number } | null => + pipe( + nonEmptyEndpointEntriesArray.decode(entries), + (decoded) => exactCheck(entries, decoded), + fold( + (errors: t.Errors) => { + return { + body: formatErrors(errors), + statusCode: 400, + }; + }, + () => null + ) + ); diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/endpoint_list_item.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/endpoint_list_item.json index 8ccbe707f204c..6999441d21941 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/endpoint_list_item.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/endpoint_list_item.json @@ -8,8 +8,9 @@ "entries": [ { "field": "actingProcess.file.signer", - "operator": "excluded", - "type": "exists" + "operator": "included", + "type": "match", + "value": "test" }, { "field": "host.name", diff --git a/x-pack/plugins/lists/server/siem_server_deps.ts b/x-pack/plugins/lists/server/siem_server_deps.ts index 324103b7fb50d..12543566db954 100644 --- a/x-pack/plugins/lists/server/siem_server_deps.ts +++ b/x-pack/plugins/lists/server/siem_server_deps.ts @@ -19,3 +19,5 @@ export { buildRouteValidation, readPrivileges, } from '../../security_solution/server'; + +export { formatErrors } from '../../security_solution/common'; diff --git a/x-pack/plugins/maps/common/descriptor_types/index.ts b/x-pack/plugins/maps/common/descriptor_types/index.ts index b0ae065856a5d..fb47344ab32db 100644 --- a/x-pack/plugins/maps/common/descriptor_types/index.ts +++ b/x-pack/plugins/maps/common/descriptor_types/index.ts @@ -5,6 +5,7 @@ */ export * from './data_request_descriptor_types'; -export * from './sources'; +export * from './source_descriptor_types'; +export * from './layer_descriptor_types'; export * from './map_descriptor'; export * from './style_property_descriptor_types'; diff --git a/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts new file mode 100644 index 0000000000000..a04d0e1a978fd --- /dev/null +++ b/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + +import { Query } from 'src/plugins/data/public'; +import { StyleDescriptor, VectorStyleDescriptor } from './style_property_descriptor_types'; +import { DataRequestDescriptor } from './data_request_descriptor_types'; +import { AbstractSourceDescriptor, ESTermSourceDescriptor } from './source_descriptor_types'; + +export type JoinDescriptor = { + leftField?: string; + right: ESTermSourceDescriptor; +}; + +export type LayerDescriptor = { + __dataRequests?: DataRequestDescriptor[]; + __isInErrorState?: boolean; + __isPreviewLayer?: boolean; + __errorMessage?: string; + __trackedLayerDescriptor?: LayerDescriptor; + alpha?: number; + id: string; + joins?: JoinDescriptor[]; + label?: string | null; + areLabelsOnTop?: boolean; + minZoom?: number; + maxZoom?: number; + sourceDescriptor: AbstractSourceDescriptor | null; + type?: string; + visible?: boolean; + style?: StyleDescriptor | null; + query?: Query; +}; + +export type VectorLayerDescriptor = LayerDescriptor & { + style: VectorStyleDescriptor; +}; + +export type RangeFieldMeta = { + min: number; + max: number; + delta: number; + isMinOutsideStdRange?: boolean; + isMaxOutsideStdRange?: boolean; +}; + +export type Category = { + key: string; + count: number; +}; + +export type CategoryFieldMeta = { + categories: Category[]; +}; + +export type GeometryTypes = { + isPointsOnly: boolean; + isLinesOnly: boolean; + isPolygonsOnly: boolean; +}; + +export type StyleMetaDescriptor = { + geometryTypes?: GeometryTypes; + fieldMeta: { + [key: string]: { + range: RangeFieldMeta; + categories: CategoryFieldMeta; + }; + }; +}; diff --git a/x-pack/plugins/maps/common/descriptor_types/sources.ts b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts similarity index 65% rename from x-pack/plugins/maps/common/descriptor_types/sources.ts rename to x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts index 6e8884d942e19..400b6a41ead71 100644 --- a/x-pack/plugins/maps/common/descriptor_types/sources.ts +++ b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts @@ -15,8 +15,6 @@ import { SCALING_TYPES, MVT_FIELD_TYPE, } from '../constants'; -import { StyleDescriptor, VectorStyleDescriptor } from './style_property_descriptor_types'; -import { DataRequestDescriptor } from './data_request_descriptor_types'; export type AttributionDescriptor = { attributionText?: string; @@ -136,83 +134,3 @@ export type GeojsonFileSourceDescriptor = { name: string; type: string; }; - -export type JoinDescriptor = { - leftField?: string; - right: ESTermSourceDescriptor; -}; - -// todo : this union type is incompatible with dynamic extensibility of sources. -// Reconsider using SourceDescriptor in type signatures for top-level classes -export type SourceDescriptor = - | AbstractSourceDescriptor - | XYZTMSSourceDescriptor - | WMSSourceDescriptor - | KibanaTilemapSourceDescriptor - | KibanaRegionmapSourceDescriptor - | ESTermSourceDescriptor - | ESSearchSourceDescriptor - | ESGeoGridSourceDescriptor - | EMSFileSourceDescriptor - | ESPewPewSourceDescriptor - | TiledSingleLayerVectorSourceDescriptor - | EMSTMSSourceDescriptor - | EMSFileSourceDescriptor - | GeojsonFileSourceDescriptor; - -export type LayerDescriptor = { - __dataRequests?: DataRequestDescriptor[]; - __isInErrorState?: boolean; - __isPreviewLayer?: boolean; - __errorMessage?: string; - __trackedLayerDescriptor?: LayerDescriptor; - alpha?: number; - id: string; - joins?: JoinDescriptor[]; - label?: string | null; - areLabelsOnTop?: boolean; - minZoom?: number; - maxZoom?: number; - sourceDescriptor: SourceDescriptor | null; - type?: string; - visible?: boolean; - style?: StyleDescriptor | null; - query?: Query; -}; - -export type VectorLayerDescriptor = LayerDescriptor & { - style?: VectorStyleDescriptor; -}; - -export type RangeFieldMeta = { - min: number; - max: number; - delta: number; - isMinOutsideStdRange?: boolean; - isMaxOutsideStdRange?: boolean; -}; - -export type Category = { - key: string; - count: number; -}; - -export type CategoryFieldMeta = { - categories: Category[]; -}; - -export type GeometryTypes = { - isPointsOnly: boolean; - isLinesOnly: boolean; - isPolygonsOnly: boolean; -}; - -export type StyleMetaDescriptor = { - geometryTypes?: GeometryTypes; - fieldMeta: { - [key: string]: { - range: RangeFieldMeta; - categories: CategoryFieldMeta; - }; - }; -}; diff --git a/x-pack/plugins/maps/public/actions/map_actions.test.js b/x-pack/plugins/maps/public/actions/map_actions.test.js index d860f413df27b..50e583f00ae81 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.test.js +++ b/x-pack/plugins/maps/public/actions/map_actions.test.js @@ -11,7 +11,7 @@ jest.mock('./data_request_actions', () => { }; }); -import { mapExtentChanged, setMouseCoordinates } from './map_actions'; +import { mapExtentChanged, setMouseCoordinates, setQuery } from './map_actions'; const getStoreMock = jest.fn(); const dispatchMock = jest.fn(); @@ -226,4 +226,95 @@ describe('map_actions', () => { }); }); }); + + describe('setQuery', () => { + const query = { + language: 'kuery', + query: '', + queryLastTriggeredAt: '2020-08-14T15:07:12.276Z', + }; + const timeFilters = { from: 'now-1y', to: 'now' }; + const filters = [ + { + meta: { + index: '90943e30-9a47-11e8-b64d-95841ca0b247', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'extension', + params: { query: 'png' }, + }, + query: { match_phrase: { extension: 'png' } }, + $state: { store: 'appState' }, + }, + ]; + + beforeEach(() => { + //Mocks the "previous" state + require('../selectors/map_selectors').getQuery = () => { + return query; + }; + require('../selectors/map_selectors').getTimeFilters = () => { + return timeFilters; + }; + require('../selectors/map_selectors').getFilters = () => { + return filters; + }; + require('../selectors/map_selectors').getMapSettings = () => { + return { + autoFitToDataBounds: false, + }; + }; + }); + + it('should dispatch query action and resync when query changes', async () => { + const newQuery = { + language: 'kuery', + query: 'foobar', + queryLastTriggeredAt: '2020-08-14T15:07:12.276Z', + }; + const setQueryAction = await setQuery({ + query: newQuery, + filters, + }); + await setQueryAction(dispatchMock, getStoreMock); + + expect(dispatchMock.mock.calls).toEqual([ + [ + { + timeFilters, + query: newQuery, + filters, + type: 'SET_QUERY', + }, + ], + [undefined], // dispatch(syncDataForAllLayers()); + ]); + }); + + it('should not dispatch query action when nothing changes', async () => { + const setQueryAction = await setQuery({ + timeFilters, + query, + filters, + }); + await setQueryAction(dispatchMock, getStoreMock); + + expect(dispatchMock.mock.calls.length).toEqual(0); + }); + + it('should dispatch query action when nothing changes and force refresh', async () => { + const setQueryAction = await setQuery({ + timeFilters, + query, + filters, + forceRefresh: true, + }); + await setQueryAction(dispatchMock, getStoreMock); + + // Only checking calls length instead of calls because queryLastTriggeredAt changes on this run + expect(dispatchMock.mock.calls.length).toEqual(2); + }); + }); }); diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index 08826276c12ad..7ba58307e1952 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import _ from 'lodash'; import { Dispatch } from 'redux'; import turfBboxPolygon from '@turf/bbox-polygon'; import turfBooleanContains from '@turf/boolean-contains'; @@ -204,12 +205,12 @@ export function setQuery({ query, timeFilters, filters = [], - refresh = false, + forceRefresh = false, }: { - filters: Filter[]; + filters?: Filter[]; query?: Query; timeFilters?: TimeRange; - refresh?: boolean; + forceRefresh?: boolean; }) { return async (dispatch: Dispatch, getState: () => MapStoreState) => { const prevQuery = getQuery(getState()); @@ -218,15 +219,30 @@ export function setQuery({ ? prevQuery.queryLastTriggeredAt : generateQueryTimestamp(); - dispatch({ - type: SET_QUERY, + const nextQueryContext = { timeFilters: timeFilters ? timeFilters : getTimeFilters(getState()), query: { ...(query ? query : getQuery(getState())), // ensure query changes to trigger re-fetch when "Refresh" clicked - queryLastTriggeredAt: refresh ? generateQueryTimestamp() : prevTriggeredAt, + queryLastTriggeredAt: forceRefresh ? generateQueryTimestamp() : prevTriggeredAt, }, filters: filters ? filters : getFilters(getState()), + }; + + const prevQueryContext = { + timeFilters: getTimeFilters(getState()), + query: getQuery(getState()), + filters: getFilters(getState()), + }; + + if (_.isEqual(nextQueryContext, prevQueryContext)) { + // do nothing if query context has not changed + return; + } + + dispatch({ + type: SET_QUERY, + ...nextQueryContext, }); if (getMapSettings(getState()).autoFitToDataBounds) { diff --git a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx index c53a7a4facb0c..8d4d57e524276 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx @@ -17,7 +17,7 @@ import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export const emsBoundariesLayerWizardConfig: LayerWizard = { categories: [LAYER_WIZARD_CATEGORY.REFERENCE], - checkVisibility: () => { + checkVisibility: async () => { return getIsEmsEnabled(); }, description: i18n.translate('xpack.maps.source.emsFileDescription', { diff --git a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx index 5cc2a1225bbd7..315759a2eba29 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx @@ -18,7 +18,7 @@ import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export const emsBaseMapLayerWizardConfig: LayerWizard = { categories: [LAYER_WIZARD_CATEGORY.REFERENCE], - checkVisibility: () => { + checkVisibility: async () => { return getIsEmsEnabled(); }, description: i18n.translate('xpack.maps.source.emsTileDescription', { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts index 2707b2ac23e58..37193e148bdc7 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.test.ts @@ -8,11 +8,7 @@ import { MapExtent, MapFilters } from '../../../../common/descriptor_types'; jest.mock('../../../kibana_services'); jest.mock('ui/new_platform'); -import { - getIndexPatternService, - getSearchService, - fetchSearchSourceAndRecordWithInspector, -} from '../../../kibana_services'; +import { getIndexPatternService, getSearchService } from '../../../kibana_services'; import { ESGeoGridSource } from './es_geo_grid_source'; import { ES_GEO_FIELD_TYPE, @@ -54,6 +50,51 @@ describe('ESGeoGridSource', () => { }, {} ); + geogridSource._runEsQuery = async (args: unknown) => { + return { + took: 71, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: 748 + 683, + max_score: null, + hits: [], + }, + aggregations: { + gridSplit: { + buckets: [ + { + key: '4/4/6', + doc_count: 748, + gridCentroid: { + location: { + lat: 35.64189018148127, + lon: -82.84314106196105, + }, + count: 748, + }, + }, + { + key: '4/3/6', + doc_count: 683, + gridCentroid: { + location: { + lat: 35.24134021274211, + lon: -98.45945192042787, + }, + count: 683, + }, + }, + ], + }, + }, + }; + }; describe('getGeoJsonWithMeta', () => { let mockSearchSource: unknown; @@ -71,50 +112,6 @@ describe('ESGeoGridSource', () => { getIndexPatternService.mockReturnValue(mockIndexPatternService); // @ts-expect-error getSearchService.mockReturnValue(mockSearchService); - // @ts-expect-error - fetchSearchSourceAndRecordWithInspector.mockReturnValue({ - took: 71, - timed_out: false, - _shards: { - total: 1, - successful: 1, - skipped: 0, - failed: 0, - }, - hits: { - total: 748 + 683, - max_score: null, - hits: [], - }, - aggregations: { - gridSplit: { - buckets: [ - { - key: '4/4/6', - doc_count: 748, - gridCentroid: { - location: { - lat: 35.64189018148127, - lon: -82.84314106196105, - }, - count: 748, - }, - }, - { - key: '4/3/6', - doc_count: 683, - gridCentroid: { - location: { - lat: 35.24134021274211, - lon: -98.45945192042787, - }, - count: 683, - }, - }, - ], - }, - }, - }); }); const extent: MapExtent = { diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.d.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.d.ts index 1f2985ffcc27c..01fde589dcb84 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.d.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.d.ts @@ -52,4 +52,17 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource registerCancelCallback: (requestToken: symbol, callback: () => void) => void, searchFilters: VectorSourceRequestMeta ): Promise; + _runEsQuery: ({ + requestId, + requestName, + requestDescription, + searchSource, + registerCancelCallback, + }: { + requestId: string; + requestName: string; + requestDescription: string; + searchSource: ISearchSource; + registerCancelCallback: () => void; + }) => Promise; } diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.js b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.js index c043e6d6994ab..866e3c76c2a3f 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.js @@ -7,7 +7,6 @@ import { AbstractVectorSource } from '../vector_source'; import { getAutocompleteService, - fetchSearchSourceAndRecordWithInspector, getIndexPatternService, getTimeFilter, getSearchService, @@ -20,6 +19,7 @@ import uuid from 'uuid/v4'; import { copyPersistentState } from '../../../reducers/util'; import { DataRequestAbortError } from '../../util/data_request'; import { expandToTileBoundaries } from '../es_geo_grid_source/geo_tile_utils'; +import { search } from '../../../../../../../src/plugins/data/public'; export class AbstractESSource extends AbstractVectorSource { constructor(descriptor, inspectorAdapters) { @@ -84,16 +84,22 @@ export class AbstractESSource extends AbstractVectorSource { const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); + const inspectorRequest = this._inspectorAdapters.requests.start(requestName, { + id: requestId, + description: requestDescription, + }); + let resp; try { - return await fetchSearchSourceAndRecordWithInspector({ - inspectorAdapters: this._inspectorAdapters, - searchSource, - requestName, - requestId, - requestDesc: requestDescription, - abortSignal: abortController.signal, + inspectorRequest.stats(search.getRequestInspectorStats(searchSource)); + searchSource.getSearchRequestBody().then((body) => { + inspectorRequest.json(body); }); + resp = await searchSource.fetch({ abortSignal: abortController.signal }); + inspectorRequest + .stats(search.getResponseInspectorStats(resp, searchSource)) + .ok({ json: resp }); } catch (error) { + inspectorRequest.error({ error }); if (error.name === 'AbortError') { throw new DataRequestAbortError(); } @@ -105,6 +111,8 @@ export class AbstractESSource extends AbstractVectorSource { }) ); } + + return resp; } async makeSearchSource(searchFilters, limit, initialSearchContext) { diff --git a/x-pack/plugins/maps/public/classes/sources/source.ts b/x-pack/plugins/maps/public/classes/sources/source.ts index 696c07376575b..4a050cc3d7d19 100644 --- a/x-pack/plugins/maps/public/classes/sources/source.ts +++ b/x-pack/plugins/maps/public/classes/sources/source.ts @@ -11,9 +11,9 @@ import { ReactElement } from 'react'; import { Adapters } from 'src/plugins/inspector/public'; import { copyPersistentState } from '../../reducers/util'; -import { SourceDescriptor } from '../../../common/descriptor_types'; import { IField } from '../fields/field'; import { MAX_ZOOM, MIN_ZOOM } from '../../../common/constants'; +import { AbstractSourceDescriptor } from '../../../common/descriptor_types'; import { OnSourceChangeArgs } from '../../connected_components/layer_panel/view'; export type SourceEditorArgs = { @@ -56,7 +56,7 @@ export interface ISource { supportsFitToBounds(): Promise; showJoinEditor(): boolean; getJoinsDisabledReason(): string | null; - cloneDescriptor(): SourceDescriptor; + cloneDescriptor(): AbstractSourceDescriptor; getFieldNames(): string[]; getApplyGlobalQuery(): boolean; getIndexPatternIds(): string[]; @@ -70,17 +70,17 @@ export interface ISource { } export class AbstractSource implements ISource { - readonly _descriptor: SourceDescriptor; + readonly _descriptor: AbstractSourceDescriptor; readonly _inspectorAdapters?: Adapters | undefined; - constructor(descriptor: SourceDescriptor, inspectorAdapters?: Adapters) { + constructor(descriptor: AbstractSourceDescriptor, inspectorAdapters?: Adapters) { this._descriptor = descriptor; this._inspectorAdapters = inspectorAdapters; } destroy(): void {} - cloneDescriptor(): SourceDescriptor { + cloneDescriptor(): AbstractSourceDescriptor { return copyPersistentState(this._descriptor); } @@ -133,7 +133,7 @@ export class AbstractSource implements ISource { } getApplyGlobalQuery(): boolean { - return 'applyGlobalQuery' in this._descriptor ? !!this._descriptor.applyGlobalQuery : false; + return !!this._descriptor.applyGlobalQuery; } getIndexPatternIds(): string[] { diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap b/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap index 2cf5287ae6594..b005e3ca6b17d 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap @@ -5,7 +5,7 @@ exports[`LayerPanel is rendered 1`] = ` services={ Object { "appName": "maps", - "data": undefined, + "data": Object {}, "storage": Storage { "clear": [Function], "get": [Function], diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js b/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js index 33ca80b00c451..1a0eda102986f 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js @@ -40,6 +40,17 @@ jest.mock('./layer_settings', () => ({ }, })); +jest.mock('../../kibana_services', () => { + return { + getData() { + return {}; + }, + getCore() { + return {}; + }, + }; +}); + import React from 'react'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index 616d06a5c7b19..43ff274b1353f 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -129,12 +129,12 @@ export class MapEmbeddable extends Embeddable !filter.meta.disabled), query, timeFilters: timeRange, - refresh, + forceRefresh, }) ); } @@ -270,7 +270,7 @@ export class MapEmbeddable extends Embeddable>; -export function getIndexPatternSelectComponent(): any; -export function getHttp(): any; -export function getTimeFilter(): any; -export function getToasts(): any; -export function getIndexPatternService(): IndexPatternsService; -export function getAutocompleteService(): any; -export function getSavedObjectsClient(): any; -export function getMapsCapabilities(): any; -export function getVisualizations(): any; -export function getDocLinks(): any; -export function getCoreChrome(): any; -export function getUiSettings(): any; -export function getIsDarkMode(): boolean; -export function getCoreOverlays(): any; -export function getData(): any; -export function getUiActions(): any; -export function getCore(): any; -export function getNavigation(): any; -export function getCoreI18n(): any; -export function getSearchService(): DataPublicPluginStart['search']; -export function getKibanaCommonConfig(): MapsLegacyConfigType; -export function getMapAppConfig(): MapsConfigType; -export function getIsEmsEnabled(): any; -export function getEmsFontLibraryUrl(): any; -export function getEmsTileLayerId(): any; -export function getEmsFileApiUrl(): any; -export function getEmsTileApiUrl(): any; -export function getEmsLandingPageUrl(): any; -export function getRegionmapLayers(): any; -export function getTilemap(): any; -export function getKibanaVersion(): string; -export function getEnabled(): boolean; -export function getShowMapVisualizationTypes(): boolean; -export function getShowMapsInspectorAdapter(): boolean; -export function getPreserveDrawingBuffer(): boolean; -export function getProxyElasticMapsServiceInMaps(): boolean; -export function getIsGoldPlus(): boolean; -export function fetchSearchSourceAndRecordWithInspector(args: unknown): any; - -export function setLicenseId(args: unknown): void; -export function setInspector(args: unknown): void; -export function setFileUpload(args: unknown): void; -export function setIndexPatternSelect(args: unknown): void; -export function setHttp(args: unknown): void; -export function setTimeFilter(args: unknown): void; -export function setToasts(args: unknown): void; -export function setIndexPatternService(args: unknown): void; -export function setAutocompleteService(args: unknown): void; -export function setSavedObjectsClient(args: unknown): void; -export function setMapsCapabilities(args: unknown): void; -export function setVisualizations(args: unknown): void; -export function setDocLinks(args: unknown): void; -export function setCoreChrome(args: unknown): void; -export function setUiSettings(args: unknown): void; -export function setCoreOverlays(args: unknown): void; -export function setData(args: unknown): void; -export function setUiActions(args: unknown): void; -export function setCore(args: unknown): void; -export function setNavigation(args: unknown): void; -export function setCoreI18n(args: unknown): void; -export function setSearchService(args: DataPublicPluginStart['search']): void; -export function setKibanaCommonConfig(config: MapsLegacyConfigType): void; -export function setMapAppConfig(config: MapsConfigType): void; -export function setKibanaVersion(version: string): void; -export function setIsGoldPlus(isGoldPlus: boolean): void; -export function setEmbeddableService(embeddableService: EmbeddableStart): void; -export function getEmbeddableService(): EmbeddableStart; -export function setNavigateToApp( - navigateToApp: (appId: string, options?: NavigateToAppOptions | undefined) => Promise -): void; -export const navigateToApp: ( - appId: string, - options?: NavigateToAppOptions | undefined -) => Promise; diff --git a/x-pack/plugins/maps/public/kibana_services.js b/x-pack/plugins/maps/public/kibana_services.js deleted file mode 100644 index 64aa0e07ffafb..0000000000000 --- a/x-pack/plugins/maps/public/kibana_services.js +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { esFilters, search } from '../../../../src/plugins/data/public'; -import _ from 'lodash'; - -export const SPATIAL_FILTER_TYPE = esFilters.FILTERS.SPATIAL_FILTER; -const { getRequestInspectorStats, getResponseInspectorStats } = search; - -let indexPatternService; -export const setIndexPatternService = (dataIndexPatterns) => - (indexPatternService = dataIndexPatterns); -export const getIndexPatternService = () => indexPatternService; - -let autocompleteService; -export const setAutocompleteService = (dataAutoComplete) => - (autocompleteService = dataAutoComplete); -export const getAutocompleteService = () => autocompleteService; - -let licenseId; -export const setLicenseId = (latestLicenseId) => (licenseId = latestLicenseId); -export const getLicenseId = () => { - return licenseId; -}; - -let inspector; -export const setInspector = (newInspector) => (inspector = newInspector); -export const getInspector = () => { - return inspector; -}; - -let fileUploadPlugin; -export const setFileUpload = (fileUpload) => (fileUploadPlugin = fileUpload); -export const getFileUploadComponent = async () => { - return await fileUploadPlugin.getFileUploadComponent(); -}; - -let uiSettings; -export const setUiSettings = (coreUiSettings) => (uiSettings = coreUiSettings); -export const getUiSettings = () => uiSettings; -export const getIsDarkMode = () => { - return getUiSettings().get('theme:darkMode', false); -}; - -let indexPatternSelectComponent; -export const setIndexPatternSelect = (indexPatternSelect) => - (indexPatternSelectComponent = indexPatternSelect); -export const getIndexPatternSelectComponent = () => indexPatternSelectComponent; - -let coreHttp; -export const setHttp = (http) => (coreHttp = http); -export const getHttp = () => coreHttp; - -let dataTimeFilter; -export const setTimeFilter = (timeFilter) => (dataTimeFilter = timeFilter); -export const getTimeFilter = () => dataTimeFilter; - -let toast; -export const setToasts = (notificationToast) => (toast = notificationToast); -export const getToasts = () => toast; - -export async function fetchSearchSourceAndRecordWithInspector({ - searchSource, - requestId, - requestName, - requestDesc, - inspectorAdapters, - abortSignal, -}) { - const inspectorRequest = inspectorAdapters.requests.start(requestName, { - id: requestId, - description: requestDesc, - }); - let resp; - try { - inspectorRequest.stats(getRequestInspectorStats(searchSource)); - searchSource.getSearchRequestBody().then((body) => { - inspectorRequest.json(body); - }); - resp = await searchSource.fetch({ abortSignal }); - inspectorRequest.stats(getResponseInspectorStats(resp, searchSource)).ok({ json: resp }); - } catch (error) { - inspectorRequest.error({ error }); - throw error; - } - - return resp; -} - -let savedObjectsClient; -export const setSavedObjectsClient = (coreSavedObjectsClient) => - (savedObjectsClient = coreSavedObjectsClient); -export const getSavedObjectsClient = () => savedObjectsClient; - -let chrome; -export const setCoreChrome = (coreChrome) => (chrome = coreChrome); -export const getCoreChrome = () => chrome; - -let mapsCapabilities; -export const setMapsCapabilities = (coreAppMapsCapabilities) => - (mapsCapabilities = coreAppMapsCapabilities); -export const getMapsCapabilities = () => mapsCapabilities; - -let visualizations; -export const setVisualizations = (visPlugin) => (visualizations = visPlugin); -export const getVisualizations = () => visualizations; - -let docLinks; -export const setDocLinks = (coreDocLinks) => (docLinks = coreDocLinks); -export const getDocLinks = () => docLinks; - -let overlays; -export const setCoreOverlays = (coreOverlays) => (overlays = coreOverlays); -export const getCoreOverlays = () => overlays; - -let data; -export const setData = (dataPlugin) => (data = dataPlugin); -export const getData = () => data; - -let uiActions; -export const setUiActions = (pluginUiActions) => (uiActions = pluginUiActions); -export const getUiActions = () => uiActions; - -let core; -export const setCore = (kibanaCore) => (core = kibanaCore); -export const getCore = () => core; - -let navigation; -export const setNavigation = (pluginNavigation) => (navigation = pluginNavigation); -export const getNavigation = () => navigation; - -let coreI18n; -export const setCoreI18n = (kibanaCoreI18n) => (coreI18n = kibanaCoreI18n); -export const getCoreI18n = () => coreI18n; - -let dataSearchService; -export const setSearchService = (searchService) => (dataSearchService = searchService); -export const getSearchService = () => dataSearchService; - -let kibanaVersion; -export const setKibanaVersion = (version) => (kibanaVersion = version); -export const getKibanaVersion = () => kibanaVersion; - -// xpack.maps.* kibana.yml settings from this plugin -let mapAppConfig; -export const setMapAppConfig = (config) => (mapAppConfig = config); -export const getMapAppConfig = () => mapAppConfig; - -export const getEnabled = () => getMapAppConfig().enabled; -export const getShowMapVisualizationTypes = () => getMapAppConfig().showMapVisualizationTypes; -export const getShowMapsInspectorAdapter = () => getMapAppConfig().showMapsInspectorAdapter; -export const getPreserveDrawingBuffer = () => getMapAppConfig().preserveDrawingBuffer; - -// map.* kibana.yml settings from maps_legacy plugin that are shared between OSS map visualizations and maps app -let kibanaCommonConfig; -export const setKibanaCommonConfig = (config) => (kibanaCommonConfig = config); -export const getKibanaCommonConfig = () => kibanaCommonConfig; - -export const getIsEmsEnabled = () => getKibanaCommonConfig().includeElasticMapsService; -export const getEmsFontLibraryUrl = () => getKibanaCommonConfig().emsFontLibraryUrl; -export const getEmsTileLayerId = () => getKibanaCommonConfig().emsTileLayerId; -export const getEmsFileApiUrl = () => getKibanaCommonConfig().emsFileApiUrl; -export const getEmsTileApiUrl = () => getKibanaCommonConfig().emsTileApiUrl; -export const getEmsLandingPageUrl = () => getKibanaCommonConfig().emsLandingPageUrl; -export const getProxyElasticMapsServiceInMaps = () => - getKibanaCommonConfig().proxyElasticMapsServiceInMaps; -export const getRegionmapLayers = () => _.get(getKibanaCommonConfig(), 'regionmap.layers', []); -export const getTilemap = () => _.get(getKibanaCommonConfig(), 'tilemap', []); - -let isGoldPlus = false; -export const setIsGoldPlus = (igp) => { - isGoldPlus = igp; -}; - -export const getIsGoldPlus = () => { - return isGoldPlus; -}; - -let embeddableService; -export const setEmbeddableService = (_embeddableService) => { - embeddableService = _embeddableService; -}; -export const getEmbeddableService = () => { - return embeddableService; -}; - -export let navigateToApp; -export function setNavigateToApp(_navigateToApp) { - navigateToApp = _navigateToApp; -} diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts new file mode 100644 index 0000000000000..3b004e2cda67b --- /dev/null +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { esFilters } from '../../../../src/plugins/data/public'; +import { MapsLegacyConfigType } from '../../../../src/plugins/maps_legacy/public'; +import { MapsConfigType } from '../config'; +import { MapsPluginStartDependencies } from './plugin'; +import { CoreStart } from '../../../../src/core/public'; + +export const SPATIAL_FILTER_TYPE = esFilters.FILTERS.SPATIAL_FILTER; + +let licenseId: string | undefined; +export const setLicenseId = (latestLicenseId: string | undefined) => (licenseId = latestLicenseId); +export const getLicenseId = () => licenseId; +let isGoldPlus: boolean = false; +export const setIsGoldPlus = (igp: boolean) => (isGoldPlus = igp); +export const getIsGoldPlus = () => isGoldPlus; + +let kibanaVersion: string; +export const setKibanaVersion = (version: string) => (kibanaVersion = version); +export const getKibanaVersion = () => kibanaVersion; + +let coreStart: CoreStart; +let pluginsStart: MapsPluginStartDependencies; +export function setStartServices(core: CoreStart, plugins: MapsPluginStartDependencies) { + coreStart = core; + pluginsStart = plugins; +} +export const getIndexPatternService = () => pluginsStart.data.indexPatterns; +export const getAutocompleteService = () => pluginsStart.data.autocomplete; +export const getInspector = () => pluginsStart.inspector; +export const getFileUploadComponent = async () => { + return await pluginsStart.fileUpload.getFileUploadComponent(); +}; +export const getUiSettings = () => coreStart.uiSettings; +export const getIsDarkMode = () => getUiSettings().get('theme:darkMode', false); +export const getIndexPatternSelectComponent = (): any => pluginsStart.data.ui.IndexPatternSelect; +export const getHttp = () => coreStart.http; +export const getTimeFilter = () => pluginsStart.data.query.timefilter.timefilter; +export const getToasts = () => coreStart.notifications.toasts; +export const getSavedObjectsClient = () => coreStart.savedObjects.client; +export const getCoreChrome = () => coreStart.chrome; +export const getMapsCapabilities = () => coreStart.application.capabilities.maps; +export const getDocLinks = () => coreStart.docLinks; +export const getCoreOverlays = () => coreStart.overlays; +export const getData = () => pluginsStart.data; +export const getUiActions = () => pluginsStart.uiActions; +export const getCore = () => coreStart; +export const getNavigation = () => pluginsStart.navigation; +export const getCoreI18n = () => coreStart.i18n; +export const getSearchService = () => pluginsStart.data.search; +export const getEmbeddableService = () => pluginsStart.embeddable; +export const getNavigateToApp = () => coreStart.application.navigateToApp; + +// xpack.maps.* kibana.yml settings from this plugin +let mapAppConfig: MapsConfigType; +export const setMapAppConfig = (config: MapsConfigType) => (mapAppConfig = config); +export const getMapAppConfig = () => mapAppConfig; + +export const getEnabled = () => getMapAppConfig().enabled; +export const getShowMapsInspectorAdapter = () => getMapAppConfig().showMapsInspectorAdapter; +export const getPreserveDrawingBuffer = () => getMapAppConfig().preserveDrawingBuffer; + +// map.* kibana.yml settings from maps_legacy plugin that are shared between OSS map visualizations and maps app +let kibanaCommonConfig: MapsLegacyConfigType; +export const setKibanaCommonConfig = (config: MapsLegacyConfigType) => + (kibanaCommonConfig = config); +export const getKibanaCommonConfig = () => kibanaCommonConfig; + +export const getIsEmsEnabled = () => getKibanaCommonConfig().includeElasticMapsService; +export const getEmsFontLibraryUrl = () => getKibanaCommonConfig().emsFontLibraryUrl; +export const getEmsTileLayerId = () => getKibanaCommonConfig().emsTileLayerId; +export const getEmsFileApiUrl = () => getKibanaCommonConfig().emsFileApiUrl; +export const getEmsTileApiUrl = () => getKibanaCommonConfig().emsTileApiUrl; +export const getEmsLandingPageUrl = () => getKibanaCommonConfig().emsLandingPageUrl; +export const getProxyElasticMapsServiceInMaps = () => + getKibanaCommonConfig().proxyElasticMapsServiceInMaps; +export const getRegionmapLayers = () => _.get(getKibanaCommonConfig(), 'regionmap.layers', []); +export const getTilemap = () => _.get(getKibanaCommonConfig(), 'tilemap', []); diff --git a/x-pack/plugins/maps/public/lazy_load_bundle/index.ts b/x-pack/plugins/maps/public/lazy_load_bundle/index.ts index b77bf208c5865..5f2a640aa9d0f 100644 --- a/x-pack/plugins/maps/public/lazy_load_bundle/index.ts +++ b/x-pack/plugins/maps/public/lazy_load_bundle/index.ts @@ -6,7 +6,7 @@ import { AnyAction } from 'redux'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { IndexPatternsService } from 'src/plugins/data/public/index_patterns'; +import { IndexPatternsContract } from 'src/plugins/data/public/index_patterns'; import { ReactElement } from 'react'; import { IndexPattern } from 'src/plugins/data/public'; import { Embeddable, IContainer } from '../../../../../src/plugins/embeddable/public'; @@ -29,7 +29,7 @@ interface LazyLoadedMapModules { renderTooltipContent?: RenderToolTipContent, eventHandlers?: EventHandlers ) => Embeddable; - getIndexPatternService: () => IndexPatternsService; + getIndexPatternService: () => IndexPatternsContract; getHttp: () => any; getMapsCapabilities: () => any; createMapStore: () => MapStore; diff --git a/x-pack/plugins/maps/public/maps_vis_type_alias.js b/x-pack/plugins/maps/public/maps_vis_type_alias.js index d90674f0f7725..b7e95cdf987db 100644 --- a/x-pack/plugins/maps/public/maps_vis_type_alias.js +++ b/x-pack/plugins/maps/public/maps_vis_type_alias.js @@ -6,12 +6,10 @@ import { i18n } from '@kbn/i18n'; import { APP_ID, APP_ICON, MAP_PATH } from '../common/constants'; -import { getShowMapVisualizationTypes, getVisualizations } from './kibana_services'; -export function getMapsVisTypeAlias() { - const showMapVisualizationTypes = getShowMapVisualizationTypes(); +export function getMapsVisTypeAlias(visualizations, showMapVisualizationTypes) { if (!showMapVisualizationTypes) { - getVisualizations().hideTypes(['region_map', 'tile_map']); + visualizations.hideTypes(['region_map', 'tile_map']); } const description = i18n.translate('xpack.maps.visTypeAlias.description', { diff --git a/x-pack/plugins/maps/public/meta.ts b/x-pack/plugins/maps/public/meta.ts index 34c5f004fd7f3..5142793bede34 100644 --- a/x-pack/plugins/maps/public/meta.ts +++ b/x-pack/plugins/maps/public/meta.ts @@ -61,7 +61,7 @@ function relativeToAbsolute(url: string): string { } let emsClient: EMSClient | null = null; -let latestLicenseId: string | null = null; +let latestLicenseId: string | undefined; export function getEMSClient(): EMSClient { if (!emsClient) { const proxyElasticMapsServiceInMaps = getProxyElasticMapsServiceInMaps(); @@ -93,7 +93,7 @@ export function getEMSClient(): EMSClient { const licenseId = getLicenseId(); if (latestLicenseId !== licenseId) { latestLicenseId = licenseId; - emsClient.addQueryParams({ license: licenseId }); + emsClient.addQueryParams({ license: licenseId ? licenseId : '' }); } return emsClient; } diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index c374d3cb59b34..e2b40e22bfe7d 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -5,6 +5,9 @@ */ import { Setup as InspectorSetupContract } from 'src/plugins/inspector/public'; +import { UiActionsStart } from 'src/plugins/ui_actions/public'; +import { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; +import { Start as InspectorStartContract } from 'src/plugins/inspector/public'; import { CoreSetup, CoreStart, @@ -15,34 +18,12 @@ import { // @ts-ignore import { MapView } from './inspector/views/map_view'; import { - setAutocompleteService, - setCore, - setCoreChrome, - setCoreI18n, - setCoreOverlays, - setData, - setDocLinks, - setFileUpload, - setHttp, - setIndexPatternSelect, - setIndexPatternService, - setInspector, setIsGoldPlus, setKibanaCommonConfig, setKibanaVersion, setLicenseId, setMapAppConfig, - setMapsCapabilities, - setNavigation, - setSavedObjectsClient, - setSearchService, - setTimeFilter, - setToasts, - setUiActions, - setUiSettings, - setVisualizations, - setEmbeddableService, - setNavigateToApp, + setStartServices, } from './kibana_services'; import { featureCatalogueEntry } from './feature_catalogue_entry'; // @ts-ignore @@ -58,66 +39,29 @@ import { ILicense } from '../../licensing/common/types'; import { lazyLoadMapModules } from './lazy_load_bundle'; import { MapsStartApi } from './api'; import { createSecurityLayerDescriptors, registerLayerWizard, registerSource } from './api'; +import { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; +import { MapsLegacyConfigType } from '../../../../src/plugins/maps_legacy/public'; +import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { LicensingPluginStart } from '../../licensing/public'; +import { StartContract as FileUploadStartContract } from '../../file_upload/public'; export interface MapsPluginSetupDependencies { inspector: InspectorSetupContract; home: HomePublicPluginSetup; visualizations: VisualizationsSetup; embeddable: EmbeddableSetup; - mapsLegacy: { config: unknown }; + mapsLegacy: { config: MapsLegacyConfigType }; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface MapsPluginStartDependencies {} -export const bindSetupCoreAndPlugins = ( - core: CoreSetup, - plugins: any, - config: MapsConfigType, - kibanaVersion: string -) => { - const { licensing, mapsLegacy } = plugins; - const { uiSettings, http, notifications } = core; - if (licensing) { - licensing.license$.subscribe(({ uid }: { uid: string }) => setLicenseId(uid)); - } - setHttp(http); - setToasts(notifications.toasts); - setVisualizations(plugins.visualizations); - setUiSettings(uiSettings); - setKibanaCommonConfig(mapsLegacy.config); - setMapAppConfig(config); - setKibanaVersion(kibanaVersion); -}; - -export const bindStartCoreAndPlugins = (core: CoreStart, plugins: any) => { - const { fileUpload, data, inspector, licensing } = plugins; - if (licensing) { - licensing.license$.subscribe((license: ILicense) => { - const gold = license.check(APP_ID, 'gold'); - setIsGoldPlus(gold.state === 'valid'); - }); - } - - setInspector(inspector); - setFileUpload(fileUpload); - setIndexPatternSelect(data.ui.IndexPatternSelect); - setTimeFilter(data.query.timefilter.timefilter); - setSearchService(data.search); - setIndexPatternService(data.indexPatterns); - setAutocompleteService(data.autocomplete); - setCore(core); - setSavedObjectsClient(core.savedObjects.client); - setCoreChrome(core.chrome); - setCoreOverlays(core.overlays); - setMapsCapabilities(core.application.capabilities.maps); - setDocLinks(core.docLinks); - setData(plugins.data); - setUiActions(plugins.uiActions); - setNavigation(plugins.navigation); - setCoreI18n(core.i18n); - setEmbeddableService(plugins.embeddable); - setNavigateToApp(core.application.navigateToApp); -}; +export interface MapsPluginStartDependencies { + data: DataPublicPluginStart; + embeddable: EmbeddableStart; + fileUpload: FileUploadStartContract; + inspector: InspectorStartContract; + licensing: LicensingPluginStart; + navigation: NavigationPublicPluginStart; + uiActions: UiActionsStart; +} /** * These are the interfaces with your public contracts. You should export these @@ -144,14 +88,16 @@ export class MapsPlugin public setup(core: CoreSetup, plugins: MapsPluginSetupDependencies) { const config = this._initializerContext.config.get(); - const kibanaVersion = this._initializerContext.env.packageInfo.version; - const { inspector, home, visualizations, embeddable } = plugins; - bindSetupCoreAndPlugins(core, plugins, config, kibanaVersion); + setKibanaCommonConfig(plugins.mapsLegacy.config); + setMapAppConfig(config); + setKibanaVersion(this._initializerContext.env.packageInfo.version); - inspector.registerView(MapView); - home.featureCatalogue.register(featureCatalogueEntry); - visualizations.registerAlias(getMapsVisTypeAlias()); - embeddable.registerEmbeddableFactory(MAP_SAVED_OBJECT_TYPE, new MapEmbeddableFactory()); + plugins.inspector.registerView(MapView); + plugins.home.featureCatalogue.register(featureCatalogueEntry); + plugins.visualizations.registerAlias( + getMapsVisTypeAlias(plugins.visualizations, config.showMapVisualizationTypes) + ); + plugins.embeddable.registerEmbeddableFactory(MAP_SAVED_OBJECT_TYPE, new MapEmbeddableFactory()); core.application.register({ id: APP_ID, @@ -162,16 +108,23 @@ export class MapsPlugin category: DEFAULT_APP_CATEGORIES.kibana, // @ts-expect-error async mount(context, params) { - const [coreStart, startPlugins] = await core.getStartServices(); - bindStartCoreAndPlugins(coreStart, startPlugins); const { renderApp } = await lazyLoadMapModules(); return renderApp(context, params); }, }); } - public start(core: CoreStart, plugins: any): MapsStartApi { - bindStartCoreAndPlugins(core, plugins); + public start(core: CoreStart, plugins: MapsPluginStartDependencies): MapsStartApi { + if (plugins.licensing) { + plugins.licensing.license$.subscribe((license: ILicense) => { + const gold = license.check(APP_ID, 'gold'); + setIsGoldPlus(gold.state === 'valid'); + setLicenseId(license.uid); + }); + } + + setStartServices(core, plugins); + return { createSecurityLayerDescriptors, registerLayerWizard, diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/index.js b/x-pack/plugins/maps/public/routing/routes/maps_app/index.js index 15a036a0a1d7c..326db7289e60d 100644 --- a/x-pack/plugins/maps/public/routing/routes/maps_app/index.js +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/index.js @@ -52,13 +52,13 @@ function mapStateToProps(state = {}) { function mapDispatchToProps(dispatch) { return { - dispatchSetQuery: ({ refresh, filters, query, timeFilters }) => { + dispatchSetQuery: ({ forceRefresh, filters, query, timeFilters }) => { dispatch( setQuery({ filters, query, timeFilters, - refresh, + forceRefresh, }) ); }, diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js b/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js index 23625b4591db7..58f0bf16e93f2 100644 --- a/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js @@ -142,7 +142,7 @@ export class MapsAppView extends React.Component { return; } - this._onQueryChange({ time: globalState.time, refresh: true }); + this._onQueryChange({ time: globalState.time }); }; async _updateIndexPatterns() { @@ -160,7 +160,7 @@ export class MapsAppView extends React.Component { } } - _onQueryChange = ({ filters, query, time, refresh = false }) => { + _onQueryChange = ({ filters, query, time, forceRefresh = false }) => { const { filterManager } = getData().query; if (filters) { @@ -168,7 +168,7 @@ export class MapsAppView extends React.Component { } this.props.dispatchSetQuery({ - refresh, + forceRefresh, filters: filterManager.getFilters(), query, timeFilters: time, @@ -336,7 +336,7 @@ export class MapsAppView extends React.Component { this._onQueryChange({ query, time: dateRange, - refresh: true, + forceRefresh: true, }); }} onFiltersUpdated={this._onFiltersChange} diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/top_nav_config.tsx b/x-pack/plugins/maps/public/routing/routes/maps_app/top_nav_config.tsx index 8a4d8ae555895..35d8490f1a886 100644 --- a/x-pack/plugins/maps/public/routing/routes/maps_app/top_nav_config.tsx +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/top_nav_config.tsx @@ -13,7 +13,7 @@ import { getInspector, getToasts, getCoreI18n, - navigateToApp, + getNavigateToApp, } from '../../../kibana_services'; import { SavedObjectSaveModalOrigin, @@ -117,7 +117,7 @@ export function getTopNavConfig({ state: { id, type: MAP_SAVED_OBJECT_TYPE }, }); } else { - navigateToApp(originatingApp); + getNavigateToApp()(originatingApp); } } diff --git a/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts b/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts index f0286d7e5811f..8688bbe549f51 100644 --- a/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts +++ b/x-pack/plugins/maps/server/maps_telemetry/maps_telemetry.ts @@ -23,7 +23,6 @@ import { ESGeoGridSourceDescriptor, ESSearchSourceDescriptor, LayerDescriptor, - SourceDescriptor, } from '../../common/descriptor_types'; import { MapSavedObject } from '../../common/map_saved_object_type'; // @ts-ignore @@ -154,7 +153,7 @@ function isGeoShapeAggLayer(indexPatterns: IIndexPattern[], layer: LayerDescript return false; } - const sourceDescriptor: SourceDescriptor = layer.sourceDescriptor; + const sourceDescriptor = layer.sourceDescriptor; if (sourceDescriptor.type === SOURCE_TYPES.ES_GEO_GRID) { return isFieldGeoShape( indexPatterns, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx index 895d217555ef4..197160a1be4d9 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/evaluate_panel.tsx @@ -338,7 +338,16 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) {generalizationEval.error !== null && ( - {generalizationEval.error} + {isTrainingFilter === true && + generalizationDocsCount === 0 && + generalizationEval.error.includes('No documents found') + ? i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.evaluateNoTestingDocsError', + { + defaultMessage: 'No testing documents found', + } + ) + : generalizationEval.error} )} @@ -421,7 +430,16 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) {trainingEval.error !== null && ( - {trainingEval.error} + {isTrainingFilter === false && + trainingDocsCount === 0 && + trainingEval.error.includes('No documents found') + ? i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.evaluateNoTrainingDocsError', + { + defaultMessage: 'No training documents found', + } + ) + : trainingEval.error} )} diff --git a/x-pack/plugins/monitoring/public/components/cluster/listing/listing.js b/x-pack/plugins/monitoring/public/components/cluster/listing/listing.js index 4dc4201e358fb..5173ea89c5dd1 100644 --- a/x-pack/plugins/monitoring/public/components/cluster/listing/listing.js +++ b/x-pack/plugins/monitoring/public/components/cluster/listing/listing.js @@ -25,6 +25,7 @@ import { i18n } from '@kbn/i18n'; import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; import { AlertsStatus } from '../../../alerts/status'; import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../../common/constants'; +import { getSafeForExternalLink } from '../../../lib/get_safe_for_external_link'; import './listing.scss'; const IsClusterSupported = ({ isSupported, children }) => { @@ -78,7 +79,7 @@ const getColumns = ( if (cluster.isSupported) { return ( changeCluster(cluster.cluster_uuid, cluster.ccs)} + href={getSafeForExternalLink(`#/overview?_g=(cluster_uuid:${cluster.cluster_uuid})`)} data-test-subj="clusterLink" > {value} diff --git a/x-pack/plugins/observability/public/pages/home/index.tsx b/x-pack/plugins/observability/public/pages/home/index.tsx index 349533346f2ad..4310cd1aa4485 100644 --- a/x-pack/plugins/observability/public/pages/home/index.tsx +++ b/x-pack/plugins/observability/public/pages/home/index.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { fetchHasData } from '../../data_handler'; import { useFetcher } from '../../hooks/use_fetcher'; @@ -15,12 +15,14 @@ export function HomePage() { const values = Object.values(data); const hasSomeData = values.length ? values.some((hasData) => hasData) : null; - if (hasSomeData === true) { - history.push({ pathname: '/overview' }); - } - if (hasSomeData === false) { - history.push({ pathname: '/landing' }); - } + useEffect(() => { + if (hasSomeData === true) { + history.push({ pathname: '/overview' }); + } + if (hasSomeData === false) { + history.push({ pathname: '/landing' }); + } + }, [hasSomeData, history]); return <>; } diff --git a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx index 79f3c63a98051..21c7b87568e09 100644 --- a/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx +++ b/x-pack/plugins/observability/public/pages/overview/overview.stories.tsx @@ -122,7 +122,7 @@ storiesOf('app/Overview', module) .addDecorator((storyFn) => ( - {storyFn()}) + {storyFn()} )) @@ -156,7 +156,7 @@ storiesOf('app/Overview', module) .addDecorator((storyFn) => ( - {storyFn()}) + {storyFn()} )) @@ -180,7 +180,7 @@ storiesOf('app/Overview', module) .addDecorator((storyFn) => ( - {storyFn()}) + {storyFn()} )) @@ -209,7 +209,7 @@ storiesOf('app/Overview', module) .addDecorator((storyFn) => ( - {storyFn()}) + {storyFn()} )) @@ -238,7 +238,7 @@ storiesOf('app/Overview', module) .addDecorator((storyFn) => ( - {storyFn()}) + {storyFn()} )) @@ -272,7 +272,7 @@ storiesOf('app/Overview', module) .addDecorator((storyFn) => ( - {storyFn()}) + {storyFn()} )) @@ -311,7 +311,7 @@ storiesOf('app/Overview', module) .addDecorator((storyFn) => ( - {storyFn()}) + {storyFn()} )) @@ -350,7 +350,7 @@ storiesOf('app/Overview', module) .addDecorator((storyFn) => ( - {storyFn()}) + {storyFn()} )) @@ -389,7 +389,7 @@ storiesOf('app/Overview', module) .addDecorator((storyFn) => ( - {storyFn()}) + {storyFn()} )) @@ -437,7 +437,7 @@ storiesOf('app/Overview', module) .addDecorator((storyFn) => ( - {storyFn()}) + {storyFn()} )) @@ -484,7 +484,7 @@ storiesOf('app/Overview', module) .addDecorator((storyFn) => ( - {storyFn()}) + {storyFn()} )) @@ -534,7 +534,7 @@ storiesOf('app/Overview', module) .addDecorator((storyFn) => ( - {storyFn()}) + {storyFn()} )) diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts index 494f7ab0a28db..5f23bbc390bb8 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts @@ -303,7 +303,7 @@ export class HeadlessChromiumDriver { if (!allowed || !this.allowRequest(interceptedUrl)) { this.page.browser().close(); logger.error(getDisallowedOutgoingUrlError(interceptedUrl)); - throw getDisallowedOutgoingUrlError(interceptedUrl); + return; } }); diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts index 33249f20757e2..a81ffd754946b 100644 --- a/x-pack/plugins/reporting/server/config/schema.ts +++ b/x-pack/plugins/reporting/server/config/schema.ts @@ -46,7 +46,7 @@ const RulesSchema = schema.object({ const CaptureSchema = schema.object({ timeouts: schema.object({ - openUrl: schema.number({ defaultValue: 30000 }), + openUrl: schema.number({ defaultValue: 60000 }), waitForElements: schema.number({ defaultValue: 30000 }), renderComplete: schema.number({ defaultValue: 30000 }), }), diff --git a/x-pack/plugins/security/kibana.json b/x-pack/plugins/security/kibana.json index 064ff5b6a6711..6a09e9e55a01b 100644 --- a/x-pack/plugins/security/kibana.json +++ b/x-pack/plugins/security/kibana.json @@ -3,7 +3,7 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "security"], - "requiredPlugins": ["data", "features", "licensing"], + "requiredPlugins": ["data", "features", "licensing", "taskManager"], "optionalPlugins": ["home", "management"], "server": true, "ui": true, diff --git a/x-pack/plugins/security/public/authentication/authentication_service.ts b/x-pack/plugins/security/public/authentication/authentication_service.ts index 6657f5c0a900c..c650763ed481a 100644 --- a/x-pack/plugins/security/public/authentication/authentication_service.ts +++ b/x-pack/plugins/security/public/authentication/authentication_service.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ApplicationSetup, StartServicesAccessor, HttpSetup } from 'src/core/public'; +import { + ApplicationSetup, + StartServicesAccessor, + HttpSetup, + FatalErrorsSetup, +} from 'src/core/public'; import { AuthenticatedUser } from '../../common/model'; import { ConfigType } from '../config'; import { PluginStartDependencies } from '../plugin'; @@ -13,9 +18,11 @@ import { loginApp } from './login'; import { logoutApp } from './logout'; import { loggedOutApp } from './logged_out'; import { overwrittenSessionApp } from './overwritten_session'; +import { captureURLApp } from './capture_url'; interface SetupParams { application: ApplicationSetup; + fatalErrors: FatalErrorsSetup; config: ConfigType; http: HttpSetup; getStartServices: StartServicesAccessor; @@ -36,6 +43,7 @@ export interface AuthenticationServiceSetup { export class AuthenticationService { public setup({ application, + fatalErrors, config, getStartServices, http, @@ -48,6 +56,7 @@ export class AuthenticationService { .apiKeysEnabled; accessAgreementApp.create({ application, getStartServices }); + captureURLApp.create({ application, fatalErrors, http }); loginApp.create({ application, config, getStartServices, http }); logoutApp.create({ application, http }); loggedOutApp.create({ application, getStartServices, http }); diff --git a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts new file mode 100644 index 0000000000000..c5b9245414630 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AppMount, ScopedHistory } from 'src/core/public'; +import { captureURLApp } from './capture_url_app'; + +import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; + +describe('captureURLApp', () => { + beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { href: 'https://some-host' }, + writable: true, + }); + }); + + it('properly registers application', () => { + const coreSetupMock = coreMock.createSetup(); + + captureURLApp.create(coreSetupMock); + + expect(coreSetupMock.http.anonymousPaths.register).toHaveBeenCalledTimes(1); + expect(coreSetupMock.http.anonymousPaths.register).toHaveBeenCalledWith( + '/internal/security/capture-url' + ); + + expect(coreSetupMock.application.register).toHaveBeenCalledTimes(1); + + const [[appRegistration]] = coreSetupMock.application.register.mock.calls; + expect(appRegistration).toEqual({ + id: 'security_capture_url', + chromeless: true, + appRoute: '/internal/security/capture-url', + title: 'Capture URL', + mount: expect.any(Function), + }); + }); + + it('properly handles captured URL', async () => { + window.location.href = `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent( + '/mock-base-path/app/home' + )}&providerType=saml&providerName=saml1#/?_g=()`; + + const coreSetupMock = coreMock.createSetup(); + coreSetupMock.http.post.mockResolvedValue({ location: '/mock-base-path/app/home#/?_g=()' }); + + captureURLApp.create(coreSetupMock); + + const [[{ mount }]] = coreSetupMock.application.register.mock.calls; + await (mount as AppMount)({ + element: document.createElement('div'), + appBasePath: '', + onAppLeave: jest.fn(), + history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + }); + + expect(coreSetupMock.http.post).toHaveBeenCalledTimes(1); + expect(coreSetupMock.http.post).toHaveBeenCalledWith('/internal/security/login', { + body: JSON.stringify({ + providerType: 'saml', + providerName: 'saml1', + currentURL: `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent( + '/mock-base-path/app/home' + )}&providerType=saml&providerName=saml1#/?_g=()`, + }), + }); + + expect(window.location.href).toBe('/mock-base-path/app/home#/?_g=()'); + }); +}); diff --git a/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts new file mode 100644 index 0000000000000..81cce1499288c --- /dev/null +++ b/x-pack/plugins/security/public/authentication/capture_url/capture_url_app.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { parse } from 'url'; +import { ApplicationSetup, FatalErrorsSetup, HttpSetup } from 'src/core/public'; + +interface CreateDeps { + application: ApplicationSetup; + http: HttpSetup; + fatalErrors: FatalErrorsSetup; +} + +/** + * Some authentication providers need to know current user URL to, for example, restore it after a + * complex authentication handshake. But most of the Kibana URLs include hash fragment that is never + * sent to the server. To capture that authentication provider can redirect user to this app putting + * path segment into the `next` query string parameter (so that it's not lost during redirect). And + * since browsers preserve hash fragments during redirects (assuming redirect location doesn't + * specify its own hash fragment, which is true in our case) this app can capture both path and + * hash URL segments and send them back to the authentication provider via login endpoint. + * + * The flow can look like this: + * 1. User visits `/app/kibana#/management/elasticsearch` that initiates authentication. + * 2. Provider redirect user to `/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1`. + * 3. Browser preserves hash segment and users ends up at `/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1#/management/elasticsearch`. + * 4. The app captures full URL and sends it back as is via login endpoint: + * { + * providerType: 'saml', + * providerName: 'saml1', + * currentURL: 'https://kibana.com/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1#/management/elasticsearch' + * } + * 5. Login endpoint handler parses and validates `next` parameter, joins it with the hash segment + * and finally passes it to the provider that initiated capturing. + */ +export const captureURLApp = Object.freeze({ + id: 'security_capture_url', + create({ application, fatalErrors, http }: CreateDeps) { + http.anonymousPaths.register('/internal/security/capture-url'); + application.register({ + id: this.id, + title: 'Capture URL', + chromeless: true, + appRoute: '/internal/security/capture-url', + async mount() { + try { + const { providerName, providerType } = parse(window.location.href, true).query ?? {}; + if (!providerName || !providerType) { + fatalErrors.add(new Error('Provider to capture URL for is not specified.')); + return () => {}; + } + + const { location } = await http.post<{ location: string }>('/internal/security/login', { + body: JSON.stringify({ providerType, providerName, currentURL: window.location.href }), + }); + + window.location.href = location; + } catch (err) { + fatalErrors.add(new Error('Cannot login with captured URL.')); + } + + return () => {}; + }, + }); + }, +}); diff --git a/x-pack/plugins/security/public/authentication/capture_url/index.ts b/x-pack/plugins/security/public/authentication/capture_url/index.ts new file mode 100644 index 0000000000000..6dc1c2f7e2c27 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/capture_url/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 { captureURLApp } from './capture_url_app'; diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx index 39131f9f4499f..552d523fa4a84 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx @@ -171,7 +171,7 @@ describe('LoginForm', () => { '/some-base-path/app/home#/?_g=()' )}`; const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); - coreStartMock.http.post.mockResolvedValue({}); + coreStartMock.http.post.mockResolvedValue({ location: '/some-base-path/app/home#/?_g=()' }); const wrapper = mountWithIntl( { loginAssistanceMessage="" selector={{ enabled: false, - providers: [{ type: 'basic', name: 'basic', usesLoginForm: true }], + providers: [{ type: 'basic', name: 'basic1', usesLoginForm: true }], }} /> ); @@ -198,7 +198,14 @@ describe('LoginForm', () => { expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', { - body: JSON.stringify({ username: 'username1', password: 'password1' }), + body: JSON.stringify({ + providerType: 'basic', + providerName: 'basic1', + currentURL: `https://some-host/login?next=${encodeURIComponent( + '/some-base-path/app/home#/?_g=()' + )}`, + params: { username: 'username1', password: 'password1' }, + }), }); expect(window.location.href).toBe('/some-base-path/app/home#/?_g=()'); @@ -363,7 +370,7 @@ describe('LoginForm', () => { }); expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); - expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login_with', { + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', { body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }), }); @@ -407,7 +414,7 @@ describe('LoginForm', () => { }); expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); - expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login_with', { + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', { body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }), }); diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx index ec631e8a2b525..9ea553af75e00 100644 --- a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx @@ -29,7 +29,6 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { HttpStart, IHttpFetchError, NotificationsStart } from 'src/core/public'; -import { parseNext } from '../../../../../common/parse_next'; import { LoginSelector } from '../../../../../common/login_state'; import { LoginValidator } from './validate_login'; @@ -401,11 +400,25 @@ export class LoginForm extends Component { message: { type: MessageType.None }, }); - const { http } = this.props; + // We try to log in with the provider that uses login form and has the lowest order. + const providerToLoginWith = this.props.selector.providers.find( + (provider) => provider.usesLoginForm + )!; try { - await http.post('/internal/security/login', { body: JSON.stringify({ username, password }) }); - window.location.href = parseNext(window.location.href, http.basePath.serverBasePath); + const { location } = await this.props.http.post<{ location: string }>( + '/internal/security/login', + { + body: JSON.stringify({ + providerType: providerToLoginWith.type, + providerName: providerToLoginWith.name, + currentURL: window.location.href, + params: { username, password }, + }), + } + ); + + window.location.href = location; } catch (error) { const message = (error as IHttpFetchError).response?.status === 401 @@ -432,7 +445,7 @@ export class LoginForm extends Component { try { const { location } = await this.props.http.post<{ location: string }>( - '/internal/security/login_with', + '/internal/security/login', { body: JSON.stringify({ providerType, providerName, currentURL: window.location.href }) } ); diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.test.tsx b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.test.tsx index 7422319951a8a..1fc8824eeff3a 100644 --- a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.test.tsx +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.test.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; +import { EuiButton } from '@elastic/eui'; import { act } from '@testing-library/react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { OverwrittenSessionPage } from './overwritten_session_page'; @@ -15,6 +16,13 @@ import { mockAuthenticatedUser } from '../../../common/model/authenticated_user. import { AuthenticationStatePage } from '../components/authentication_state_page'; describe('OverwrittenSessionPage', () => { + beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { href: 'https://some-host' }, + writable: true, + }); + }); + it('renders as expected', async () => { const basePathMock = coreMock.createStart({ basePath: '/mock-base-path' }).http.basePath; const authenticationSetupMock = authenticationMock.createSetup(); @@ -36,4 +44,30 @@ describe('OverwrittenSessionPage', () => { expect(wrapper.find(AuthenticationStatePage)).toMatchSnapshot(); }); + + it('properly parses `next` parameter', async () => { + window.location.href = `https://host.com/mock-base-path/security/overwritten_session?next=${encodeURIComponent( + '/mock-base-path/app/home#/?_g=()' + )}`; + + const basePathMock = coreMock.createStart({ basePath: '/mock-base-path' }).http.basePath; + const authenticationSetupMock = authenticationMock.createSetup(); + authenticationSetupMock.getCurrentUser.mockResolvedValue( + mockAuthenticatedUser({ username: 'mock-user' }) + ); + + const wrapper = mountWithIntl( + + ); + + // Shouldn't render anything if username isn't yet available. + expect(wrapper.isEmptyRender()).toBe(true); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(EuiButton).prop('href')).toBe('/mock-base-path/app/home#/?_g=()'); + }); }); diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.tsx b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.tsx index 455cc9fb9ce1f..ee8784cdd0f9f 100644 --- a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.tsx +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_page.tsx @@ -9,6 +9,7 @@ import ReactDOM from 'react-dom'; import { EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { CoreStart, IBasePath } from 'src/core/public'; +import { parseNext } from '../../../common/parse_next'; import { AuthenticationServiceSetup } from '../authentication_service'; import { AuthenticationStatePage } from '../components'; @@ -36,7 +37,7 @@ export function OverwrittenSessionPage({ authc, basePath }: Props) { /> } > - + { ); }); - it('correctly produces `redirected` authentication result without state.', () => { + it('correctly produces `redirected` authentication result without state, user and response headers.', () => { const redirectURL = '/redirect/url'; const authenticationResult = AuthenticationResult.redirectTo(redirectURL); @@ -201,6 +201,49 @@ describe('AuthenticationResult', () => { expect(authenticationResult.user).toBeUndefined(); expect(authenticationResult.error).toBeUndefined(); }); + + it('correctly produces `redirected` authentication result with state and user.', () => { + const redirectURL = '/redirect/url'; + const state = { some: 'state' }; + const user = mockAuthenticatedUser(); + const authenticationResult = AuthenticationResult.redirectTo(redirectURL, { user, state }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.succeeded()).toBe(false); + expect(authenticationResult.failed()).toBe(false); + expect(authenticationResult.notHandled()).toBe(false); + + expect(authenticationResult.redirectURL).toBe(redirectURL); + expect(authenticationResult.state).toBe(state); + expect(authenticationResult.authHeaders).toBeUndefined(); + expect(authenticationResult.authResponseHeaders).toBeUndefined(); + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.error).toBeUndefined(); + }); + + it('correctly produces `redirected` authentication result with state, user and response headers.', () => { + const redirectURL = '/redirect/url'; + const state = { some: 'state' }; + const user = mockAuthenticatedUser(); + const authResponseHeaders = { 'WWW-Authenticate': 'Negotiate' }; + const authenticationResult = AuthenticationResult.redirectTo(redirectURL, { + user, + state, + authResponseHeaders, + }); + + expect(authenticationResult.redirected()).toBe(true); + expect(authenticationResult.succeeded()).toBe(false); + expect(authenticationResult.failed()).toBe(false); + expect(authenticationResult.notHandled()).toBe(false); + + expect(authenticationResult.redirectURL).toBe(redirectURL); + expect(authenticationResult.state).toBe(state); + expect(authenticationResult.authHeaders).toBeUndefined(); + expect(authenticationResult.authResponseHeaders).toBe(authResponseHeaders); + expect(authenticationResult.user).toBe(user); + expect(authenticationResult.error).toBeUndefined(); + }); }); describe('shouldUpdateState', () => { diff --git a/x-pack/plugins/security/server/authentication/authentication_result.ts b/x-pack/plugins/security/server/authentication/authentication_result.ts index 826665a3b8a30..a5e744ba36915 100644 --- a/x-pack/plugins/security/server/authentication/authentication_result.ts +++ b/x-pack/plugins/security/server/authentication/authentication_result.ts @@ -113,17 +113,29 @@ export class AuthenticationResult { /** * Produces `AuthenticationResult` for the case when authentication needs user to be redirected. * @param redirectURL URL that should be used to redirect user to complete authentication. + * @param [user] Optional user information retrieved as a result of successful authentication attempt. + * @param [authResponseHeaders] Optional dictionary of the HTTP headers with authentication + * information that should be specified in the response we send to the client request. * @param [state] Optional state to be stored and reused for the next request. */ public static redirectTo( redirectURL: string, - { state }: Pick = {} + { + user, + authResponseHeaders, + state, + }: Pick = {} ) { if (!redirectURL) { throw new Error('Redirect URL must be specified.'); } - return new AuthenticationResult(AuthenticationResultStatus.Redirected, { redirectURL, state }); + return new AuthenticationResult(AuthenticationResultStatus.Redirected, { + redirectURL, + user, + authResponseHeaders, + state, + }); } /** diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 300447096af99..fcc652505ba3a 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -10,34 +10,31 @@ jest.mock('./providers/saml'); jest.mock('./providers/http'); import Boom from 'boom'; -import { duration, Duration } from 'moment'; -import { SessionStorage } from '../../../../../src/core/server'; import { loggingSystemMock, httpServiceMock, httpServerMock, elasticsearchServiceMock, - sessionStorageMock, } from '../../../../../src/core/server/mocks'; import { licenseMock } from '../../common/licensing/index.mock'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; import { securityAuditLoggerMock } from '../audit/index.mock'; +import { sessionMock } from '../session_management/index.mock'; import { SecurityLicenseFeatures } from '../../common/licensing'; import { ConfigSchema, createConfig } from '../config'; +import { SessionValue } from '../session_management'; import { AuthenticationResult } from './authentication_result'; -import { Authenticator, AuthenticatorOptions, ProviderSession } from './authenticator'; +import { Authenticator, AuthenticatorOptions } from './authenticator'; import { DeauthenticationResult } from './deauthentication_result'; import { BasicAuthenticationProvider, SAMLAuthenticationProvider } from './providers'; import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; function getMockOptions({ - session, providers, http = {}, selector, }: { - session?: AuthenticatorOptions['config']['session']; providers?: Record | string[]; http?: Partial; selector?: AuthenticatorOptions['config']['authc']['selector']; @@ -50,11 +47,11 @@ function getMockOptions({ license: licenseMock.create(), loggers: loggingSystemMock.create(), config: createConfig( - ConfigSchema.validate({ session, authc: { selector, providers, http } }), + ConfigSchema.validate({ authc: { selector, providers, http } }), loggingSystemMock.create().get(), { isTLSEnabled: false } ), - sessionStorageFactory: sessionStorageMock.createFactory(), + session: sessionMock.create(), getFeatureUsageService: jest .fn() .mockReturnValue(securityFeatureUsageServiceMock.createStartContract()), @@ -84,6 +81,7 @@ describe('Authenticator', () => { jest.requireMock('./providers/saml').SAMLAuthenticationProvider.mockImplementation(() => ({ type: 'saml', + authenticate: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()), getHTTPAuthenticationScheme: jest.fn(), })); }); @@ -216,20 +214,11 @@ describe('Authenticator', () => { describe('`login` method', () => { let authenticator: Authenticator; let mockOptions: ReturnType; - let mockSessionStorage: jest.Mocked>; - let mockSessVal: any; + let mockSessVal: SessionValue; beforeEach(() => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); - mockSessionStorage = sessionStorageMock.create(); - mockSessionStorage.get.mockResolvedValue(null); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - mockSessVal = { - idleTimeoutExpiration: null, - lifespanExpiration: null, - state: { authorization: 'Basic xxx' }, - provider: { type: 'basic', name: 'basic1' }, - path: mockOptions.basePath.serverBasePath, - }; + mockOptions.session.get.mockResolvedValue(null); + mockSessVal = sessionMock.createValue({ state: { authorization: 'Basic xxx' } }); authenticator = new Authenticator(mockOptions); }); @@ -304,9 +293,10 @@ describe('Authenticator', () => { authenticator.login(request, { provider: { type: 'basic' }, value: {} }) ).resolves.toEqual(AuthenticationResult.succeeded(user, { state: { authorization } })); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, + expect(mockOptions.session.create).toHaveBeenCalledTimes(1); + expect(mockOptions.session.create).toHaveBeenCalledWith(request, { + username: user.username, + provider: mockSessVal.provider, state: { authorization }, }); }); @@ -361,9 +351,7 @@ describe('Authenticator', () => { }, }, }); - mockSessionStorage = sessionStorageMock.create(); - mockSessionStorage.get.mockResolvedValue(null); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + mockOptions.session.get.mockResolvedValue(null); authenticator = new Authenticator(mockOptions); }); @@ -382,9 +370,9 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { state: { token: 'access-token' } }) ); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, + expect(mockOptions.session.create).toHaveBeenCalledTimes(1); + expect(mockOptions.session.create).toHaveBeenCalledWith(request, { + username: user.username, provider: { type: 'saml', name: 'saml2' }, state: { token: 'access-token' }, }); @@ -400,7 +388,7 @@ describe('Authenticator', () => { authenticator.login(request, { provider: { type: 'saml' }, value: {} }) ).resolves.toEqual(AuthenticationResult.notHandled()); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled(); expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledTimes(1); @@ -412,10 +400,11 @@ describe('Authenticator', () => { it('returns as soon as provider handles request', async () => { const request = httpServerMock.createKibanaRequest(); + const user = mockAuthenticatedUser(); const authenticationResults = [ AuthenticationResult.failed(new Error('Fail')), - AuthenticationResult.succeeded(mockAuthenticatedUser(), { state: { result: '200' } }), + AuthenticationResult.succeeded(user, { state: { result: '200' } }), AuthenticationResult.redirectTo('/some/url', { state: { result: '302' } }), ]; @@ -427,14 +416,14 @@ describe('Authenticator', () => { ).resolves.toEqual(result); } - expect(mockSessionStorage.set).toHaveBeenCalledTimes(2); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, + expect(mockOptions.session.create).toHaveBeenCalledTimes(2); + expect(mockOptions.session.create).toHaveBeenCalledWith(request, { + username: user.username, provider: { type: 'saml', name: 'saml1' }, state: { result: '200' }, }); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, + expect(mockOptions.session.create).toHaveBeenCalledWith(request, { + username: undefined, provider: { type: 'saml', name: 'saml1' }, state: { result: '302' }, }); @@ -447,7 +436,7 @@ describe('Authenticator', () => { it('provides session only if provider name matches', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue({ + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, provider: { type: 'saml', name: 'saml2' }, }); @@ -480,32 +469,7 @@ describe('Authenticator', () => { }); }); - it('clears session if it belongs to a different provider.', async () => { - const user = mockAuthenticatedUser(); - const credentials = { username: 'user', password: 'password' }; - const request = httpServerMock.createKibanaRequest(); - - mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); - mockSessionStorage.get.mockResolvedValue({ - ...mockSessVal, - provider: { type: 'token', name: 'token1' }, - }); - - await expect( - authenticator.login(request, { provider: { type: 'basic' }, value: credentials }) - ).resolves.toEqual(AuthenticationResult.succeeded(user)); - - expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith( - request, - credentials, - null - ); - - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); - }); - - it('clears session if it belongs to a provider with the name that is registered but has different type.', async () => { + it('clears session if it belongs to a not configured provider or with the name that is registered but has different type.', async () => { const user = mockAuthenticatedUser(); const credentials = { username: 'user', password: 'password' }; const request = httpServerMock.createKibanaRequest(); @@ -518,11 +482,10 @@ describe('Authenticator', () => { getHTTPAuthenticationScheme: jest.fn(), })); mockOptions = getMockOptions({ providers: { token: { basic1: { order: 0 } } } }); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); authenticator = new Authenticator(mockOptions); mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect( authenticator.login(request, { provider: { name: 'basic1' }, value: credentials }) @@ -530,14 +493,17 @@ describe('Authenticator', () => { expect(loginMock).toHaveBeenCalledWith(request, credentials, null); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).toHaveBeenCalledTimes(1); + expect(mockOptions.session.clear).toHaveBeenCalledWith(request); }); - it('clears session if provider asked to do so.', async () => { + it('clears session if provider asked to do so in `succeeded` result.', async () => { const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); mockBasicAuthenticationProvider.login.mockResolvedValue( AuthenticationResult.succeeded(user, { state: null }) @@ -547,48 +513,493 @@ describe('Authenticator', () => { authenticator.login(request, { provider: { type: 'basic' }, value: {} }) ).resolves.toEqual(AuthenticationResult.succeeded(user, { state: null })); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).toHaveBeenCalledTimes(1); + expect(mockOptions.session.clear).toHaveBeenCalledWith(request); }); - it('clears legacy session.', async () => { - const user = mockAuthenticatedUser(); + it('clears session if provider asked to do so in `redirected` result.', async () => { const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(mockSessVal); - // Use string format for the `provider` session value field to emulate legacy session. - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'basic' }); - - mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.redirectTo('some-url', { state: null }) + ); await expect( authenticator.login(request, { provider: { type: 'basic' }, value: {} }) - ).resolves.toEqual(AuthenticationResult.succeeded(user)); + ).resolves.toEqual(AuthenticationResult.redirectTo('some-url', { state: null })); + + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).toHaveBeenCalledTimes(1); + expect(mockOptions.session.clear).toHaveBeenCalledWith(request); + }); + + describe('with Access Agreement', () => { + const mockUser = mockAuthenticatedUser(); + beforeEach(() => { + mockOptions = getMockOptions({ + providers: { + basic: { basic1: { order: 0, accessAgreement: { message: 'some notice' } } }, + }, + }); + + mockOptions.session.update.mockImplementation(async (request, value) => value); + mockOptions.session.extend.mockImplementation(async (request, value) => value); + mockOptions.session.create.mockImplementation(async (request, value) => ({ + ...mockSessVal, + ...value, + })); + + mockOptions.license.getFeatures.mockReturnValue({ + allowAccessAgreement: true, + } as SecurityLicenseFeatures); + + authenticator = new Authenticator(mockOptions); + }); + + it('does not redirect to Access Agreement if authenticated session is not created', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(null); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(mockUser)); + }); + + it('does not redirect to Access Agreement if request cannot be handled', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.notHandled()); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + }); + + it('does not redirect to Access Agreement if authentication fails', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + const failureReason = new Error('something went wrong'); + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.failed(failureReason) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.failed(failureReason)); + }); + + it('does not redirect to Access Agreement if redirect is required to complete login', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(null); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.redirectTo('/some-url', { state: 'some-state' }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.redirectTo('/some-url', { state: 'some-state' })); + }); + + it('does not redirect to Access Agreement if user has already acknowledged it', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ + ...mockSessVal, + accessAgreementAcknowledged: true, + }); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { state: 'some-state' }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(mockUser, { state: 'some-state' })); + }); + + it('does not redirect to Access Agreement its own requests', async () => { + const request = httpServerMock.createKibanaRequest({ path: '/security/access_agreement' }); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { state: 'some-state' }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(mockUser, { state: 'some-state' })); + }); + + it('does not redirect to Access Agreement if it is not configured', async () => { + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); + mockOptions.session.get.mockResolvedValue(mockSessVal); + authenticator = new Authenticator(mockOptions); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { state: 'some-state' }) + ); + + const request = httpServerMock.createKibanaRequest(); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(mockUser, { state: 'some-state' })); + }); + + it('does not redirect to Access Agreement if license doesnt allow it.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(mockSessVal); + mockOptions.license.getFeatures.mockReturnValue({ + allowAccessAgreement: false, + } as SecurityLicenseFeatures); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { state: 'some-state' }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(mockUser, { state: 'some-state' })); + }); - expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledTimes(1); - expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith(request, {}, null); + it('redirects to Access Agreement when needed.', async () => { + mockOptions.session.get.mockResolvedValue(mockSessVal); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + const request = httpServerMock.createKibanaRequest(); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/access_agreement?next=%2Fmock-server-basepath%2Fpath', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects to Access Agreement preserving redirect URL specified in login attempt.', async () => { + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + const request = httpServerMock.createKibanaRequest(); + await expect( + authenticator.login(request, { + provider: { type: 'basic' }, + value: {}, + redirectURL: '/some-url', + }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/access_agreement?next=%2Fsome-url', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects to Access Agreement preserving redirect URL specified in the authentication result.', async () => { + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.redirectTo('/some-url', { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + const request = httpServerMock.createKibanaRequest(); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/access_agreement?next=%2Fsome-url', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects AJAX requests to Access Agreement when needed.', async () => { + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/access_agreement?next=%2Fmock-server-basepath%2Fpath', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + }); + + describe('with Overwritten Session', () => { + const mockUser = mockAuthenticatedUser(); + beforeEach(() => { + mockOptions = getMockOptions({ + providers: { + basic: { basic1: { order: 0 } }, + saml: { saml1: { order: 1, realm: 'saml1' } }, + }, + }); + mockOptions.session.get.mockResolvedValue(null); + mockOptions.session.update.mockImplementation(async (request, value) => value); + mockOptions.session.extend.mockImplementation(async (request, value) => value); + mockOptions.session.create.mockImplementation(async (request, value) => ({ + ...mockSessVal, + ...value, + })); + + authenticator = new Authenticator(mockOptions); + }); + + it('does not redirect to Overwritten Session its own requests', async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/security/overwritten_session', + }); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { state: 'some-state' }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(mockUser, { state: 'some-state' })); + }); + + it('does not redirect to Overwritten Session if username and provider did not change', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + }); + + it('does not redirect to Overwritten Session if session was unauthenticated before login', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: undefined }); + + const newMockUser = mockAuthenticatedUser({ username: 'new-username' }); + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(newMockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.succeeded(newMockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + }); + + it('redirects to Overwritten Session when username changes', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/overwritten_session?next=%2Fmock-server-basepath%2Fpath', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects to Overwritten Session when provider changes', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'saml', name: 'saml1' }, + }); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/overwritten_session?next=%2Fmock-server-basepath%2Fpath', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects to Overwritten Session preserving redirect URL specified in login attempt.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect( + authenticator.login(request, { + provider: { type: 'basic' }, + value: {}, + redirectURL: '/some-url', + }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/overwritten_session?next=%2Fsome-url', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects to Overwritten Session preserving redirect URL specified in the authentication result.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.redirectTo('/some-url', { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/overwritten_session?next=%2Fsome-url', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects AJAX requests to Overwritten Session when needed.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.login.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/overwritten_session?next=%2Fmock-server-basepath%2Fpath', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); }); }); describe('`authenticate` method', () => { let authenticator: Authenticator; let mockOptions: ReturnType; - let mockSessionStorage: jest.Mocked>; - let mockSessVal: any; + let mockSessVal: SessionValue; beforeEach(() => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); - mockSessionStorage = sessionStorageMock.create(); - mockSessionStorage.get.mockResolvedValue(null); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - mockSessVal = { - idleTimeoutExpiration: null, - lifespanExpiration: null, - state: { authorization: 'Basic xxx' }, - provider: { type: 'basic', name: 'basic1' }, - path: mockOptions.basePath.serverBasePath, - }; + mockOptions.session.get.mockResolvedValue(null); + mockSessVal = sessionMock.createValue({ state: { authorization: 'Basic xxx' } }); authenticator = new Authenticator(mockOptions); }); @@ -642,9 +1053,10 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { state: { authorization } }) ); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, + expect(mockOptions.session.create).toHaveBeenCalledTimes(1); + expect(mockOptions.session.create).toHaveBeenCalledWith(request, { + username: user.username, + provider: mockSessVal.provider, state: { authorization }, }); }); @@ -664,9 +1076,10 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { state: { authorization } }) ); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, + expect(mockOptions.session.create).toHaveBeenCalledTimes(1); + expect(mockOptions.session.create).toHaveBeenCalledWith(request, { + username: user.username, + provider: mockSessVal.provider, state: { authorization }, }); }); @@ -680,14 +1093,16 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.succeeded(user) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.succeeded(user) ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); it('extends session for non-system API calls.', async () => { @@ -699,161 +1114,17 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.succeeded(user) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); - - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded(user) - ); - - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith(mockSessVal); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); - }); - - it('properly extends session expiration if it is defined.', async () => { - const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest(); - const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); - - // Create new authenticator with non-null session `idleTimeout`. - mockOptions = getMockOptions({ - session: { - idleTimeout: duration(3600 * 24), - lifespan: null, - }, - providers: { basic: { basic1: { order: 0 } } }, - }); - - mockSessionStorage = sessionStorageMock.create(); - mockSessionStorage.get.mockResolvedValue(mockSessVal); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - - authenticator = new Authenticator(mockOptions); - - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.succeeded(user) - ); - - jest.spyOn(Date, 'now').mockImplementation(() => currentDate); - - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded(user) - ); - - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, - idleTimeoutExpiration: currentDate + 3600 * 24, - }); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); - }); - - it('does not extend session lifespan expiration.', async () => { - const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest(); - const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); - const hr = 1000 * 60 * 60; - - // Create new authenticator with non-null session `idleTimeout` and `lifespan`. - mockOptions = getMockOptions({ - session: { - idleTimeout: duration(hr * 2), - lifespan: duration(hr * 8), - }, - providers: { basic: { basic1: { order: 0 } } }, - }); - - mockSessionStorage = sessionStorageMock.create(); - mockSessionStorage.get.mockResolvedValue({ - ...mockSessVal, - // this session was created 6.5 hrs ago (and has 1.5 hrs left in its lifespan) - // it was last extended 1 hour ago, which means it will expire in 1 hour - idleTimeoutExpiration: currentDate + hr * 1, - lifespanExpiration: currentDate + hr * 1.5, - }); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - - authenticator = new Authenticator(mockOptions); - - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.succeeded(user) - ); - - jest.spyOn(Date, 'now').mockImplementation(() => currentDate); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.succeeded(user) ); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, - idleTimeoutExpiration: currentDate + hr * 2, - lifespanExpiration: currentDate + hr * 1.5, - }); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); - }); - - describe('conditionally updates the session lifespan expiration', () => { - const hr = 1000 * 60 * 60; - const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); - - async function createAndUpdateSession( - lifespan: Duration | null, - oldExpiration: number | null, - newExpiration: number | null - ) { - const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest(); - jest.spyOn(Date, 'now').mockImplementation(() => currentDate); - - mockOptions = getMockOptions({ - session: { - idleTimeout: null, - lifespan, - }, - providers: { basic: { basic1: { order: 0 } } }, - }); - - mockSessionStorage = sessionStorageMock.create(); - mockSessionStorage.get.mockResolvedValue({ - ...mockSessVal, - idleTimeoutExpiration: null, - lifespanExpiration: oldExpiration, - }); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - - authenticator = new Authenticator(mockOptions); - - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.succeeded(user) - ); - - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded(user) - ); - - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ - ...mockSessVal, - idleTimeoutExpiration: null, - lifespanExpiration: newExpiration, - }); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); - } - - it('does not change a non-null lifespan expiration when configured to non-null value.', async () => { - await createAndUpdateSession(duration(hr * 8), 1234, 1234); - }); - it('does not change a null lifespan expiration when configured to null value.', async () => { - await createAndUpdateSession(null, null, null); - }); - it('does change a non-null lifespan expiration when configured to null value.', async () => { - await createAndUpdateSession(null, 1234, null); - }); - it('does change a null lifespan expiration when configured to non-null value', async () => { - await createAndUpdateSession(duration(hr * 8), null, currentDate + hr * 8); - }); + expect(mockOptions.session.extend).toHaveBeenCalledTimes(1); + expect(mockOptions.session.extend).toHaveBeenCalledWith(request, mockSessVal); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); it('does not touch session for system API calls if authentication fails with non-401 reason.', async () => { @@ -865,14 +1136,16 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.failed(failureReason) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.failed(failureReason) ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); it('does not touch session for non-system API calls if authentication fails with non-401 reason.', async () => { @@ -884,14 +1157,16 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.failed(failureReason) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.failed(failureReason) ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); it('replaces existing session with the one returned by authentication provider for system API requests', async () => { @@ -904,18 +1179,20 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.succeeded(user, { state: newState }) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.succeeded(user, { state: newState }) ); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expect(mockOptions.session.update).toHaveBeenCalledTimes(1); + expect(mockOptions.session.update).toHaveBeenCalledWith(request, { ...mockSessVal, state: newState, }); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); it('replaces existing session with the one returned by authentication provider for non-system API requests', async () => { @@ -928,18 +1205,20 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.succeeded(user, { state: newState }) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.succeeded(user, { state: newState }) ); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expect(mockOptions.session.update).toHaveBeenCalledTimes(1); + expect(mockOptions.session.update).toHaveBeenCalledWith(request, { ...mockSessVal, state: newState, }); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); it('clears session if provider failed to authenticate system API request with 401 with active session.', async () => { @@ -950,14 +1229,17 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.failed(Boom.unauthorized()) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized()) ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); + expect(mockOptions.session.clear).toHaveBeenCalledTimes(1); + expect(mockOptions.session.clear).toHaveBeenCalledWith(request); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); }); it('clears session if provider failed to authenticate non-system API request with 401 with active session.', async () => { @@ -968,14 +1250,17 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.failed(Boom.unauthorized()) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized()) ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); + expect(mockOptions.session.clear).toHaveBeenCalledTimes(1); + expect(mockOptions.session.clear).toHaveBeenCalledWith(request); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); }); it('clears session if provider requested it via setting state to `null`.', async () => { @@ -984,36 +1269,17 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.redirectTo('some-url', { state: null }) ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo('some-url', { state: null }) ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); - }); - - it('clears legacy session.', async () => { - const user = mockAuthenticatedUser(); - const request = httpServerMock.createKibanaRequest(); - - // Use string format for the `provider` session value field to emulate legacy session. - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'basic' }); - - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.succeeded(user) - ); - - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.succeeded(user) - ); - - expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledTimes(1); - expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledWith(request, null); - - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); + expect(mockOptions.session.clear).toHaveBeenCalledTimes(1); + expect(mockOptions.session.clear).toHaveBeenCalledWith(request); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); }); it('does not clear session if provider can not handle system API request authentication with active session.', async () => { @@ -1021,14 +1287,16 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'true' }, }); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.notHandled() ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); }); it('does not clear session if provider can not handle non-system API request authentication with active session.', async () => { @@ -1036,50 +1304,16 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'false' }, }); - mockSessionStorage.get.mockResolvedValue(mockSessVal); - - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.notHandled() - ); - - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); - }); - - it('clears session for system API request if it belongs to not configured provider.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { 'kbn-system-request': 'true' }, - }); - - mockSessionStorage.get.mockResolvedValue({ - ...mockSessVal, - provider: { type: 'token', name: 'token1' }, - }); - - await expect(authenticator.authenticate(request)).resolves.toEqual( - AuthenticationResult.notHandled() - ); - - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); - }); - - it('clears session for non-system API request if it belongs to not configured provider.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { 'kbn-system-request': 'false' }, - }); - - mockSessionStorage.get.mockResolvedValue({ - ...mockSessVal, - provider: { type: 'token', name: 'token1' }, - }); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.notHandled() ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); - expect(mockSessionStorage.clear).toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.create).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); + expect(mockOptions.session.extend).not.toHaveBeenCalled(); }); describe('with Login Selector', () => { @@ -1088,14 +1322,13 @@ describe('Authenticator', () => { selector: { enabled: true }, providers: { basic: { basic1: { order: 0 } } }, }); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); authenticator = new Authenticator(mockOptions); }); it('does not redirect to Login Selector if there is an active session', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.notHandled() @@ -1125,7 +1358,6 @@ describe('Authenticator', () => { it('does not redirect to Login Selector if it is not enabled', async () => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); authenticator = new Authenticator(mockOptions); const request = httpServerMock.createKibanaRequest(); @@ -1154,7 +1386,6 @@ describe('Authenticator', () => { basic: { basic1: { order: 0, accessAgreement: { message: 'some notice' } } }, }, }); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); mockOptions.license.getFeatures.mockReturnValue({ allowAccessAgreement: true, } as SecurityLicenseFeatures); @@ -1168,7 +1399,7 @@ describe('Authenticator', () => { it('does not redirect to Access Agreement if there is no active session', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(null); + mockOptions.session.get.mockResolvedValue(null); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.succeeded(mockUser) @@ -1177,7 +1408,7 @@ describe('Authenticator', () => { it('does not redirect AJAX requests to Access Agreement', async () => { const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.succeeded(mockUser) @@ -1186,7 +1417,7 @@ describe('Authenticator', () => { it('does not redirect to Access Agreement if request cannot be handled', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.notHandled() @@ -1199,7 +1430,7 @@ describe('Authenticator', () => { it('does not redirect to Access Agreement if authentication fails', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); const failureReason = new Error('something went wrong'); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( @@ -1213,7 +1444,7 @@ describe('Authenticator', () => { it('does not redirect to Access Agreement if redirect is required to complete authentication', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); mockBasicAuthenticationProvider.authenticate.mockResolvedValue( AuthenticationResult.redirectTo('/some-url') @@ -1226,7 +1457,7 @@ describe('Authenticator', () => { it('does not redirect to Access Agreement if user has already acknowledged it', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue({ + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, accessAgreementAcknowledged: true, }); @@ -1238,7 +1469,7 @@ describe('Authenticator', () => { it('does not redirect to Access Agreement its own requests', async () => { const request = httpServerMock.createKibanaRequest({ path: '/security/access_agreement' }); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.succeeded(mockUser) @@ -1247,8 +1478,7 @@ describe('Authenticator', () => { it('does not redirect to Access Agreement if it is not configured', async () => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); authenticator = new Authenticator(mockOptions); const request = httpServerMock.createKibanaRequest(); @@ -1259,7 +1489,7 @@ describe('Authenticator', () => { it('does not redirect to Access Agreement if license doesnt allow it.', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); mockOptions.license.getFeatures.mockReturnValue({ allowAccessAgreement: false, } as SecurityLicenseFeatures); @@ -1270,12 +1500,186 @@ describe('Authenticator', () => { }); it('redirects to Access Agreement when needed.', async () => { - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); + mockOptions.session.extend.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + const request = httpServerMock.createKibanaRequest(); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/access_agreement?next=%2Fmock-server-basepath%2Fpath', + { user: mockUser, authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' } } + ) + ); + }); + }); + + describe('with Overwritten Session', () => { + const mockUser = mockAuthenticatedUser(); + beforeEach(() => { + mockOptions = getMockOptions({ + providers: { + basic: { basic1: { order: 0 } }, + saml: { saml1: { order: 1, realm: 'saml1' } }, + }, + }); + mockOptions.session.update.mockImplementation(async (request, value) => value); + mockOptions.session.extend.mockImplementation(async (request, value) => value); + mockOptions.session.create.mockImplementation(async (request, value) => ({ + ...mockSessVal, + ...value, + })); + + authenticator = new Authenticator(mockOptions); + }); + + it('does not redirect to Overwritten Session its own requests', async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/security/overwritten_session', + }); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(mockUser) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(mockUser) + ); + }); + + it('does not redirect AJAX requests to Overwritten Session', async () => { + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + }); + + it('does not redirect to Overwritten Session if username and provider did not change', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(mockSessVal); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + }); + + it('does not redirect to Overwritten Session if session was unauthenticated before this authentication attempt', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: undefined }); + + const newMockUser = mockAuthenticatedUser({ username: 'new-username' }); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(newMockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(newMockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + }); + + it('redirects to Overwritten Session when username changes', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/overwritten_session?next=%2Fmock-server-basepath%2Fpath', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects to Overwritten Session when provider changes', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'saml', name: 'saml1' }, + }); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(mockUser, { + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/security/overwritten_session?next=%2Fmock-server-basepath%2Fpath', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } + ) + ); + }); + + it('redirects to Overwritten Session preserving redirect URL specified in the authentication result.', async () => { const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' }); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.redirectTo('/some-url', { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); + await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/security/access_agreement?next=%2Fmock-server-basepath%2Fpath' + '/mock-server-basepath/security/overwritten_session?next=%2Fsome-url', + { + user: mockUser, + state: 'some-state', + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + } ) ); }); @@ -1285,19 +1689,10 @@ describe('Authenticator', () => { describe('`logout` method', () => { let authenticator: Authenticator; let mockOptions: ReturnType; - let mockSessionStorage: jest.Mocked>; - let mockSessVal: any; + let mockSessVal: SessionValue; beforeEach(() => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); - mockSessionStorage = sessionStorageMock.create(); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - mockSessVal = { - idleTimeoutExpiration: null, - lifespanExpiration: null, - state: { authorization: 'Basic xxx' }, - provider: { type: 'basic', name: 'basic1' }, - path: mockOptions.basePath.serverBasePath, - }; + mockSessVal = sessionMock.createValue({ state: { authorization: 'Basic xxx' } }); authenticator = new Authenticator(mockOptions); }); @@ -1310,14 +1705,14 @@ describe('Authenticator', () => { it('returns `notHandled` if session does not exist.', async () => { const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(null); + mockOptions.session.get.mockResolvedValue(null); mockBasicAuthenticationProvider.logout.mockResolvedValue(DeauthenticationResult.notHandled()); await expect(authenticator.logout(request)).resolves.toEqual( DeauthenticationResult.notHandled() ); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); it('clears session and returns whatever authentication provider returns.', async () => { @@ -1325,19 +1720,19 @@ describe('Authenticator', () => { mockBasicAuthenticationProvider.logout.mockResolvedValue( DeauthenticationResult.redirectTo('some-url') ); - mockSessionStorage.get.mockResolvedValue(mockSessVal); + mockOptions.session.get.mockResolvedValue(mockSessVal); await expect(authenticator.logout(request)).resolves.toEqual( DeauthenticationResult.redirectTo('some-url') ); expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.clear).toHaveBeenCalled(); + expect(mockOptions.session.clear).toHaveBeenCalled(); }); it('if session does not exist but provider name is valid, returns whatever authentication provider returns.', async () => { const request = httpServerMock.createKibanaRequest({ query: { provider: 'basic1' } }); - mockSessionStorage.get.mockResolvedValue(null); + mockOptions.session.get.mockResolvedValue(null); mockBasicAuthenticationProvider.logout.mockResolvedValue( DeauthenticationResult.redirectTo('some-url') @@ -1348,81 +1743,37 @@ describe('Authenticator', () => { ); expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledWith(request, null); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); - it('returns `notHandled` if session does not exist and provider name is invalid', async () => { - const request = httpServerMock.createKibanaRequest({ query: { provider: 'foo' } }); - mockSessionStorage.get.mockResolvedValue(null); + it('if session does not exist and provider name is not available, returns whatever authentication provider returns.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.session.get.mockResolvedValue(null); - await expect(authenticator.logout(request)).resolves.toEqual( - DeauthenticationResult.notHandled() + mockBasicAuthenticationProvider.logout.mockResolvedValue( + DeauthenticationResult.redirectTo('some-url') ); - expect(mockSessionStorage.clear).not.toHaveBeenCalled(); - }); - - it('clears session if it belongs to not configured provider.', async () => { - const request = httpServerMock.createKibanaRequest(); - const state = { authorization: 'Bearer xxx' }; - mockSessionStorage.get.mockResolvedValue({ - ...mockSessVal, - state, - provider: { type: 'token', name: 'token1' }, - }); - await expect(authenticator.logout(request)).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo('some-url') ); expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.clear).toHaveBeenCalled(); - }); - }); - - describe('`getSessionInfo` method', () => { - let authenticator: Authenticator; - let mockOptions: ReturnType; - let mockSessionStorage: jest.Mocked>; - beforeEach(() => { - mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); - mockSessionStorage = sessionStorageMock.create(); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - - authenticator = new Authenticator(mockOptions); - }); - - it('returns current session info if session exists.', async () => { - const request = httpServerMock.createKibanaRequest(); - const state = { authorization: 'Basic xxx' }; - const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf(); - const mockInfo = { - now: currentDate, - idleTimeoutExpiration: currentDate + 60000, - lifespanExpiration: currentDate + 120000, - provider: { type: 'basic' as 'basic', name: 'basic1' }, - }; - mockSessionStorage.get.mockResolvedValue({ - idleTimeoutExpiration: mockInfo.idleTimeoutExpiration, - lifespanExpiration: mockInfo.lifespanExpiration, - state, - provider: mockInfo.provider, - path: mockOptions.basePath.serverBasePath, - }); - jest.spyOn(Date, 'now').mockImplementation(() => currentDate); - - const sessionInfo = await authenticator.getSessionInfo(request); - - expect(sessionInfo).toEqual(mockInfo); + expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledWith(request); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); - it('returns `null` if session does not exist.', async () => { - const request = httpServerMock.createKibanaRequest(); - mockSessionStorage.get.mockResolvedValue(null); + it('returns `notHandled` if session does not exist and provider name is invalid', async () => { + const request = httpServerMock.createKibanaRequest({ query: { provider: 'foo' } }); + mockOptions.session.get.mockResolvedValue(null); - const sessionInfo = await authenticator.getSessionInfo(request); + await expect(authenticator.logout(request)).resolves.toEqual( + DeauthenticationResult.notHandled() + ); - expect(sessionInfo).toBe(null); + expect(mockBasicAuthenticationProvider.logout).not.toHaveBeenCalled(); + expect(mockOptions.session.clear).not.toHaveBeenCalled(); }); }); @@ -1450,20 +1801,11 @@ describe('Authenticator', () => { describe('`acknowledgeAccessAgreement` method', () => { let authenticator: Authenticator; let mockOptions: ReturnType; - let mockSessionStorage: jest.Mocked>; - let mockSessionValue: any; + let mockSessionValue: SessionValue; beforeEach(() => { mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); - mockSessionStorage = sessionStorageMock.create(); - mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); - mockSessionValue = { - idleTimeoutExpiration: null, - lifespanExpiration: null, - state: { authorization: 'Basic xxx' }, - provider: { type: 'basic', name: 'basic1' }, - path: mockOptions.basePath.serverBasePath, - }; - mockSessionStorage.get.mockResolvedValue(mockSessionValue); + mockSessionValue = sessionMock.createValue({ state: { authorization: 'Basic xxx' } }); + mockOptions.session.get.mockResolvedValue(mockSessionValue); mockOptions.getCurrentUser.mockReturnValue(mockAuthenticatedUser()); mockOptions.license.getFeatures.mockReturnValue({ allowAccessAgreement: true, @@ -1481,14 +1823,14 @@ describe('Authenticator', () => { `"Cannot acknowledge access agreement for unauthenticated user."` ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); expect( mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage ).not.toHaveBeenCalled(); }); it('fails if cannot retrieve user session', async () => { - mockSessionStorage.get.mockResolvedValue(null); + mockOptions.session.get.mockResolvedValue(null); await expect( authenticator.acknowledgeAccessAgreement(httpServerMock.createKibanaRequest()) @@ -1496,7 +1838,7 @@ describe('Authenticator', () => { `"Cannot acknowledge access agreement for unauthenticated user."` ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); expect( mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage ).not.toHaveBeenCalled(); @@ -1513,17 +1855,18 @@ describe('Authenticator', () => { `"Current license does not allow access agreement acknowledgement."` ); - expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockOptions.session.update).not.toHaveBeenCalled(); expect( mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage ).not.toHaveBeenCalled(); }); it('properly acknowledges access agreement for the authenticated user', async () => { - await authenticator.acknowledgeAccessAgreement(httpServerMock.createKibanaRequest()); + const request = httpServerMock.createKibanaRequest(); + await authenticator.acknowledgeAccessAgreement(request); - expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); - expect(mockSessionStorage.set).toHaveBeenCalledWith({ + expect(mockOptions.session.update).toHaveBeenCalledTimes(1); + expect(mockOptions.session.update).toHaveBeenCalledWith(request, { ...mockSessionValue, accessAgreementAcknowledged: true, }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 42ec3f79bddf3..1fb9d9221f041 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -4,22 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Duration } from 'moment'; import { - SessionStorageFactory, - SessionStorage, KibanaRequest, LoggerFactory, - Logger, - HttpServiceSetup, ILegacyClusterClient, + IBasePath, } from '../../../../../src/core/server'; import { SecurityLicense } from '../../common/licensing'; import { AuthenticatedUser } from '../../common/model'; -import { AuthenticationProvider, SessionInfo } from '../../common/types'; +import { AuthenticationProvider } from '../../common/types'; import { SecurityAuditLogger } from '../audit'; import { ConfigType } from '../config'; import { getErrorStatusCode } from '../errors'; +import { SecurityFeatureUsageServiceStart } from '../feature_usage'; +import { SessionValue, Session } from '../session_management'; import { AuthenticationProviderOptions, @@ -38,45 +36,6 @@ import { DeauthenticationResult } from './deauthentication_result'; import { Tokens } from './tokens'; import { canRedirectRequest } from './can_redirect_request'; import { HTTPAuthorizationHeader } from './http_authentication'; -import { SecurityFeatureUsageServiceStart } from '../feature_usage'; - -/** - * The shape of the session that is actually stored in the cookie. - */ -export interface ProviderSession { - /** - * Name and type of the provider this session belongs to. - */ - provider: AuthenticationProvider; - - /** - * The Unix time in ms when the session should be considered expired. If `null`, session will stay - * active until the browser is closed. - */ - idleTimeoutExpiration: number | null; - - /** - * The Unix time in ms which is the max total lifespan of the session. If `null`, session expire - * time can be extended indefinitely. - */ - lifespanExpiration: number | null; - - /** - * Session value that is fed to the authentication provider. The shape is unknown upfront and - * entirely determined by the authentication provider that owns the current session. - */ - state: unknown; - - /** - * Cookie "Path" attribute that is validated against the current Kibana server configuration. - */ - path: string; - - /** - * Indicates whether user acknowledged access agreement or not. - */ - accessAgreementAcknowledged?: boolean; -} /** * The shape of the login attempt. @@ -87,6 +46,12 @@ export interface ProviderLoginAttempt { */ provider: Pick | Pick; + /** + * Optional URL to redirect user to after successful login. This URL is ignored if provider + * decides to redirect user to another URL after login. + */ + redirectURL?: string; + /** * Login attempt can have any form and defined by the specific provider. */ @@ -97,12 +62,12 @@ export interface AuthenticatorOptions { auditLogger: SecurityAuditLogger; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null; - config: Pick; - basePath: HttpServiceSetup['basePath']; + config: Pick; + basePath: IBasePath; license: SecurityLicense; loggers: LoggerFactory; clusterClient: ILegacyClusterClient; - sessionStorageFactory: SessionStorageFactory; + session: PublicMethodsOf; } // Mapping between provider key defined in the config and authentication @@ -127,6 +92,11 @@ const providerMap = new Map< */ const ACCESS_AGREEMENT_ROUTE = '/security/access_agreement'; +/** + * The route to the overwritten session UI. + */ +const OVERWRITTEN_SESSION_ROUTE = '/security/overwritten_session'; + function assertRequest(request: KibanaRequest) { if (!(request instanceof KibanaRequest)) { throw new Error(`Request should be a valid "KibanaRequest" instance, was [${typeof request}].`); @@ -161,15 +131,6 @@ function isLoginAttemptWithProviderType( ); } -/** - * Determines if session value was created by the previous Kibana versions which had a different - * session value format. - * @param sessionValue The session value to check. - */ -function isLegacyProviderSession(sessionValue: any) { - return typeof sessionValue?.provider === 'string'; -} - /** * Instantiates authentication provider based on the provider key from config. * @param providerType Provider type key. @@ -209,32 +170,20 @@ export class Authenticator { private readonly providers: Map; /** - * Which base path the HTTP server is hosted on. - */ - private readonly serverBasePath: string; - - /** - * Session timeout in ms. If `null` session will stay active until the browser is closed. + * Session instance. */ - private readonly idleTimeout: Duration | null = null; - - /** - * Session max lifespan in ms. If `null` session may live indefinitely. - */ - private readonly lifespan: Duration | null = null; + private readonly session = this.options.session; /** * Internal authenticator logger. */ - private readonly logger: Logger; + private readonly logger = this.options.loggers.get('authenticator'); /** * Instantiates Authenticator and bootstrap configured providers. * @param options Authenticator options. */ constructor(private readonly options: Readonly) { - this.logger = options.loggers.get('authenticator'); - const providerCommonOptions = { client: this.options.clusterClient, basePath: this.options.basePath, @@ -284,11 +233,6 @@ export class Authenticator { 'No authentication provider is configured. Verify `xpack.security.authc.*` config value.' ); } - - this.serverBasePath = this.options.basePath.serverBasePath || '/'; - - this.idleTimeout = this.options.config.session.idleTimeout; - this.lifespan = this.options.config.session.lifespan; } /** @@ -300,8 +244,7 @@ export class Authenticator { assertRequest(request); assertLoginAttempt(attempt); - const sessionStorage = this.options.sessionStorageFactory.asScoped(request); - const existingSession = await this.getSessionValue(sessionStorage); + const existingSessionValue = await this.getSessionValue(request); // Login attempt can target specific provider by its name (e.g. chosen at the Login Selector UI) // or a group of providers with the specified type (e.g. in case of 3rd-party initiated login @@ -311,7 +254,7 @@ export class Authenticator { isLoginAttemptWithProviderName(attempt) && this.providers.has(attempt.provider.name) ? [[attempt.provider.name, this.providers.get(attempt.provider.name)!]] : isLoginAttemptWithProviderType(attempt) - ? [...this.providerIterator(existingSession)].filter( + ? [...this.providerIterator(existingSessionValue)].filter( ([, { type }]) => type === attempt.provider.type ) : []; @@ -330,24 +273,28 @@ export class Authenticator { for (const [providerName, provider] of providers) { // Check if current session has been set by this provider. const ownsSession = - existingSession?.provider.name === providerName && - existingSession?.provider.type === provider.type; + existingSessionValue?.provider.name === providerName && + existingSessionValue?.provider.type === provider.type; const authenticationResult = await provider.login( request, attempt.value, - ownsSession ? existingSession!.state : null + ownsSession ? existingSessionValue!.state : null ); - this.updateSessionValue(sessionStorage, { - provider: { type: provider.type, name: providerName }, - isSystemRequest: request.isSystemRequest, - authenticationResult, - existingSession: ownsSession ? existingSession : null, - }); - if (!authenticationResult.notHandled()) { - return authenticationResult; + const sessionUpdateResult = await this.updateSessionValue(request, { + provider: { type: provider.type, name: providerName }, + authenticationResult, + existingSessionValue, + }); + + return this.handlePreAccessRedirects( + request, + authenticationResult, + sessionUpdateResult, + attempt.redirectURL + ); } } @@ -361,10 +308,9 @@ export class Authenticator { async authenticate(request: KibanaRequest) { assertRequest(request); - const sessionStorage = this.options.sessionStorageFactory.asScoped(request); - const existingSession = await this.getSessionValue(sessionStorage); + const existingSessionValue = await this.getSessionValue(request); - if (this.shouldRedirectToLoginSelector(request, existingSession)) { + if (this.shouldRedirectToLoginSelector(request, existingSessionValue)) { this.logger.debug('Redirecting request to Login Selector.'); return AuthenticationResult.redirectTo( `${this.options.basePath.serverBasePath}/login?next=${encodeURIComponent( @@ -373,40 +319,27 @@ export class Authenticator { ); } - for (const [providerName, provider] of this.providerIterator(existingSession)) { + for (const [providerName, provider] of this.providerIterator(existingSessionValue)) { // Check if current session has been set by this provider. const ownsSession = - existingSession?.provider.name === providerName && - existingSession?.provider.type === provider.type; + existingSessionValue?.provider.name === providerName && + existingSessionValue?.provider.type === provider.type; const authenticationResult = await provider.authenticate( request, - ownsSession ? existingSession!.state : null + ownsSession ? existingSessionValue!.state : null ); - const updatedSession = this.updateSessionValue(sessionStorage, { - provider: { type: provider.type, name: providerName }, - isSystemRequest: request.isSystemRequest, - authenticationResult, - existingSession: ownsSession ? existingSession : null, - }); - if (!authenticationResult.notHandled()) { - if ( - authenticationResult.succeeded() && - this.shouldRedirectToAccessAgreement(request, updatedSession) - ) { - this.logger.debug('Redirecting user to the access agreement screen.'); - return AuthenticationResult.redirectTo( - `${ - this.options.basePath.serverBasePath - }${ACCESS_AGREEMENT_ROUTE}?next=${encodeURIComponent( - `${this.options.basePath.get(request)}${request.url.path}` - )}` - ); - } - - return authenticationResult; + const sessionUpdateResult = await this.updateSessionValue(request, { + provider: { type: provider.type, name: providerName }, + authenticationResult, + existingSessionValue, + }); + + return canRedirectRequest(request) + ? this.handlePreAccessRedirects(request, authenticationResult, sessionUpdateResult) + : authenticationResult; } } @@ -420,19 +353,19 @@ export class Authenticator { async logout(request: KibanaRequest) { assertRequest(request); - const sessionStorage = this.options.sessionStorageFactory.asScoped(request); - const sessionValue = await this.getSessionValue(sessionStorage); + const sessionValue = await this.getSessionValue(request); if (sessionValue) { - sessionStorage.clear(); - - return this.providers.get(sessionValue.provider.name)!.logout(request, sessionValue.state); + await this.session.clear(request); + return this.providers + .get(sessionValue.provider.name)! + .logout(request, sessionValue.state ?? null); } - const providerName = this.getProviderName(request.query); - if (providerName) { + const queryStringProviderName = (request.query as Record)?.provider; + if (queryStringProviderName) { // provider name is passed in a query param and sourced from the browser's local storage; // hence, we can't assume that this provider exists, so we have to check it - const provider = this.providers.get(providerName); + const provider = this.providers.get(queryStringProviderName); if (provider) { return provider.logout(request, null); } @@ -454,29 +387,6 @@ export class Authenticator { return DeauthenticationResult.notHandled(); } - /** - * Returns session information for the current request. - * @param request Request instance. - */ - async getSessionInfo(request: KibanaRequest): Promise { - assertRequest(request); - - const sessionStorage = this.options.sessionStorageFactory.asScoped(request); - const sessionValue = await this.getSessionValue(sessionStorage); - - if (sessionValue) { - // We can't rely on the client's system clock, so in addition to returning expiration timestamps, we also return - // the current server time -- that way the client can calculate the relative time to expiration. - return { - now: Date.now(), - idleTimeoutExpiration: sessionValue.idleTimeoutExpiration, - lifespanExpiration: sessionValue.lifespanExpiration, - provider: sessionValue.provider, - }; - } - return null; - } - /** * Checks whether specified provider type is currently enabled. * @param providerType Type of the provider (`basic`, `saml`, `pki` etc.). @@ -492,10 +402,9 @@ export class Authenticator { async acknowledgeAccessAgreement(request: KibanaRequest) { assertRequest(request); - const sessionStorage = this.options.sessionStorageFactory.asScoped(request); - const existingSession = await this.getSessionValue(sessionStorage); + const existingSessionValue = await this.getSessionValue(request); const currentUser = this.options.getCurrentUser(request); - if (!existingSession || !currentUser) { + if (!existingSessionValue || !currentUser) { throw new Error('Cannot acknowledge access agreement for unauthenticated user.'); } @@ -503,11 +412,14 @@ export class Authenticator { throw new Error('Current license does not allow access agreement acknowledgement.'); } - sessionStorage.set({ ...existingSession, accessAgreementAcknowledged: true }); + await this.session.update(request, { + ...existingSessionValue, + accessAgreementAcknowledged: true, + }); this.options.auditLogger.accessAgreementAcknowledged( currentUser.username, - existingSession.provider + existingSessionValue.provider ); this.options.getFeatureUsageService().recordPreAccessAgreementUsage(); @@ -546,7 +458,7 @@ export class Authenticator { * @param sessionValue Current session value. */ private *providerIterator( - sessionValue: ProviderSession | null + sessionValue: SessionValue | null ): IterableIterator<[string, BaseAuthenticationProvider]> { // If there is no session to predict which provider to use first, let's use the order // providers are configured in. Otherwise return provider that owns session first, and only then the rest @@ -565,114 +477,159 @@ export class Authenticator { } /** - * Extracts session value for the specified request. Under the hood it can - * clear session if it belongs to the provider that is not available. - * @param sessionStorage Session storage instance. + * Extracts session value for the specified request. Under the hood it can clear session if it + * belongs to the provider that is not available. + * @param request Request instance. */ - private async getSessionValue(sessionStorage: SessionStorage) { - const sessionValue = await sessionStorage.get(); + private async getSessionValue(request: KibanaRequest) { + const existingSessionValue = await this.session.get(request); - // If we detect that session is in incompatible format or for some reason we have a session - // stored for the provider that is not available anymore (e.g. when user was logged in with one - // provider, but then configuration has changed and that provider is no longer available), then - // we should clear session entirely. + // If we detect that for some reason we have a session stored for the provider that is not + // available anymore (e.g. when user was logged in with one provider, but then configuration has + // changed and that provider is no longer available), then we should clear session entirely. if ( - sessionValue && - (isLegacyProviderSession(sessionValue) || - this.providers.get(sessionValue.provider.name)?.type !== sessionValue.provider.type) + existingSessionValue && + this.providers.get(existingSessionValue.provider.name)?.type !== + existingSessionValue.provider.type ) { - sessionStorage.clear(); + this.logger.warn( + `Attempted to retrieve session for the "${existingSessionValue.provider.type}/${existingSessionValue.provider.name}" provider, but it is not configured.` + ); + await this.session.clear(request); return null; } - return sessionValue; + return existingSessionValue; } - private updateSessionValue( - sessionStorage: SessionStorage, + /** + * Updates, creates, extends or clears session value based on the received authentication result. + * @param request Request instance. + * @param provider Provider that produced provided authentication result. + * @param authenticationResult Result of the authentication or login attempt. + * @param existingSessionValue Value of the existing session if any. + */ + private async updateSessionValue( + request: KibanaRequest, { provider, authenticationResult, - existingSession, - isSystemRequest, + existingSessionValue, }: { provider: AuthenticationProvider; authenticationResult: AuthenticationResult; - existingSession: ProviderSession | null; - isSystemRequest: boolean; + existingSessionValue: Readonly | null; } ) { - if (!existingSession && !authenticationResult.shouldUpdateState()) { + if (!existingSessionValue && !authenticationResult.shouldUpdateState()) { + return null; + } + + // Provider can specifically ask to clear session by setting it to `null` even if authentication + // attempt didn't fail. + if (authenticationResult.shouldClearState()) { + this.logger.debug('Authentication provider requested to invalidate existing session.'); + await this.session.clear(request); + return null; + } + + const ownsSession = + existingSessionValue?.provider.name === provider.name && + existingSessionValue?.provider.type === provider.type; + + // If provider owned the session, but failed to authenticate anyway, that likely means that + // session is not valid and we should clear it. Unexpected errors should not cause session + // invalidation (e.g. when Elasticsearch is temporarily unavailable). + if (authenticationResult.failed()) { + if (ownsSession && getErrorStatusCode(authenticationResult.error) === 401) { + this.logger.debug('Authentication attempt failed, existing session will be invalidated.'); + await this.session.clear(request); + } return null; } // If authentication succeeds or requires redirect we should automatically extend existing user session, // unless authentication has been triggered by a system API request. In case provider explicitly returns new // state we should store it in the session regardless of whether it's a system API request or not. - const sessionCanBeUpdated = + const sessionShouldBeUpdatedOrExtended = (authenticationResult.succeeded() || authenticationResult.redirected()) && - (authenticationResult.shouldUpdateState() || !isSystemRequest); + (authenticationResult.shouldUpdateState() || (!request.isSystemRequest && ownsSession)); + if (!sessionShouldBeUpdatedOrExtended) { + return ownsSession ? { value: existingSessionValue, overwritten: false } : null; + } - // If provider owned the session, but failed to authenticate anyway, that likely means that - // session is not valid and we should clear it. Also provider can specifically ask to clear - // session by setting it to `null` even if authentication attempt didn't fail. - if ( - authenticationResult.shouldClearState() || - (authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401) - ) { - sessionStorage.clear(); - return null; + const isExistingSessionAuthenticated = !!existingSessionValue?.username; + const isNewSessionAuthenticated = !!authenticationResult.user; + + const providerHasChanged = !!existingSessionValue && !ownsSession; + const sessionHasBeenAuthenticated = + !!existingSessionValue && !isExistingSessionAuthenticated && isNewSessionAuthenticated; + const usernameHasChanged = + isExistingSessionAuthenticated && + isNewSessionAuthenticated && + authenticationResult.user!.username !== existingSessionValue!.username; + + // There are 3 cases when we SHOULD invalidate existing session and create a new one with + // regenerated SID/AAD: + // 1. If a new session must be created while existing is still valid (e.g. IdP initiated login + // for the user with active session created by another provider). + // 2. If the existing session was unauthenticated (e.g. intermediate session used during SSO + // handshake) and can now be turned into an authenticated one. + // 3. If we re-authenticated user with another username (e.g. during IdP initiated SSO login or + // when client certificate changes and PKI provider needs to re-authenticate user). + if (providerHasChanged) { + this.logger.debug( + 'Authentication provider has changed, existing session will be invalidated.' + ); + await this.session.clear(request); + existingSessionValue = null; + } else if (sessionHasBeenAuthenticated) { + this.logger.debug( + 'Session is authenticated, existing unauthenticated session will be invalidated.' + ); + await this.session.clear(request); + existingSessionValue = null; + } else if (usernameHasChanged) { + this.logger.debug('Username has changed, existing session will be invalidated.'); + await this.session.clear(request); + existingSessionValue = null; } - if (sessionCanBeUpdated) { - const { idleTimeoutExpiration, lifespanExpiration } = this.calculateExpiry(existingSession); - const updatedSession = { + let newSessionValue; + if (!existingSessionValue) { + newSessionValue = await this.session.create(request, { + username: authenticationResult.user?.username, + provider, + state: authenticationResult.shouldUpdateState() ? authenticationResult.state : null, + }); + } else if (authenticationResult.shouldUpdateState()) { + newSessionValue = await this.session.update(request, { + ...existingSessionValue, state: authenticationResult.shouldUpdateState() ? authenticationResult.state - : existingSession!.state, - provider, - idleTimeoutExpiration, - lifespanExpiration, - path: this.serverBasePath, - accessAgreementAcknowledged: existingSession?.accessAgreementAcknowledged, - }; - sessionStorage.set(updatedSession); - return updatedSession; - } - - return existingSession; - } - - private getProviderName(query: any): string | null { - if (query && query.provider && typeof query.provider === 'string') { - return query.provider; + : existingSessionValue.state, + }); + } else { + newSessionValue = await this.session.extend(request, existingSessionValue); } - return null; - } - private calculateExpiry( - existingSession: ProviderSession | null - ): { idleTimeoutExpiration: number | null; lifespanExpiration: number | null } { - const now = Date.now(); - // if we are renewing an existing session, use its `lifespanExpiration` -- otherwise, set this value - // based on the configured server `lifespan`. - // note, if the server had a `lifespan` set and then removes it, remove `lifespanExpiration` on renewed sessions - // also, if the server did not have a `lifespan` set and then adds it, add `lifespanExpiration` on renewed sessions - const lifespanExpiration = - existingSession?.lifespanExpiration && this.lifespan - ? existingSession.lifespanExpiration - : this.lifespan && now + this.lifespan.asMilliseconds(); - const idleTimeoutExpiration = this.idleTimeout && now + this.idleTimeout.asMilliseconds(); - - return { idleTimeoutExpiration, lifespanExpiration }; + return { + value: newSessionValue, + // We care only about cases when one authenticated session has been overwritten by another + // authenticated session that belongs to a different user (different name or provider/realm). + overwritten: + isExistingSessionAuthenticated && + isNewSessionAuthenticated && + (providerHasChanged || usernameHasChanged), + }; } /** * Checks whether request should be redirected to the Login Selector UI. * @param request Request instance. - * @param session Current session value if any. + * @param sessionValue Current session value if any. */ - private shouldRedirectToLoginSelector(request: KibanaRequest, session: ProviderSession | null) { + private shouldRedirectToLoginSelector(request: KibanaRequest, sessionValue: SessionValue | null) { // Request should be redirected to Login Selector UI only if all following conditions are met: // 1. Request can be redirected (not API call) // 2. Request is not authenticated yet @@ -680,7 +637,7 @@ export class Authenticator { // 4. Request isn't attributed with HTTP Authorization header return ( canRedirectRequest(request) && - !session && + !sessionValue && this.options.config.authc.selector.enabled && HTTPAuthorizationHeader.parseFromRequest(request) == null ); @@ -688,10 +645,9 @@ export class Authenticator { /** * Checks whether request should be redirected to the Access Agreement UI. - * @param request Request instance. - * @param session Current session value if any. + * @param sessionValue Current session value if any. */ - private shouldRedirectToAccessAgreement(request: KibanaRequest, session: ProviderSession | null) { + private shouldRedirectToAccessAgreement(sessionValue: SessionValue | null) { // Request should be redirected to Access Agreement UI only if all following conditions are met: // 1. Request can be redirected (not API call) // 2. Request is authenticated, but user hasn't acknowledged access agreement in the current @@ -700,14 +656,71 @@ export class Authenticator { // 4. Current license allows access agreement // 5. And it's not a request to the Access Agreement UI itself return ( - canRedirectRequest(request) && - session != null && - !session.accessAgreementAcknowledged && - (this.options.config.authc.providers as Record)[session.provider.type]?.[ - session.provider.name + sessionValue != null && + !sessionValue.accessAgreementAcknowledged && + (this.options.config.authc.providers as Record)[sessionValue.provider.type]?.[ + sessionValue.provider.name ]?.accessAgreement && - this.options.license.getFeatures().allowAccessAgreement && - request.url.pathname !== ACCESS_AGREEMENT_ROUTE + this.options.license.getFeatures().allowAccessAgreement ); } + + /** + * In some cases we'd like to redirect user to another page right after successful authentication + * before they can access anything else in Kibana. This method makes sure we do a proper redirect + * that would eventually lead user to a initially requested Kibana URL. + * @param request Request instance. + * @param authenticationResult Result of the authentication. + * @param sessionUpdateResult Result of the session update. + * @param redirectURL + */ + private handlePreAccessRedirects( + request: KibanaRequest, + authenticationResult: AuthenticationResult, + sessionUpdateResult: { value: Readonly | null; overwritten: boolean } | null, + redirectURL?: string + ) { + if ( + authenticationResult.failed() || + request.url.pathname === ACCESS_AGREEMENT_ROUTE || + request.url.pathname === OVERWRITTEN_SESSION_ROUTE + ) { + return authenticationResult; + } + + const isSessionAuthenticated = !!sessionUpdateResult?.value?.username; + + let preAccessRedirectURL; + if (isSessionAuthenticated && sessionUpdateResult?.overwritten) { + this.logger.debug('Redirecting user to the overwritten session UI.'); + preAccessRedirectURL = `${this.options.basePath.serverBasePath}${OVERWRITTEN_SESSION_ROUTE}`; + } else if ( + isSessionAuthenticated && + this.shouldRedirectToAccessAgreement(sessionUpdateResult?.value ?? null) + ) { + this.logger.debug('Redirecting user to the access agreement UI.'); + preAccessRedirectURL = `${this.options.basePath.serverBasePath}${ACCESS_AGREEMENT_ROUTE}`; + } + + // If we need to redirect user to anywhere else before they can access Kibana we should remember + // redirect URL in the `next` parameter. Redirect URL provided in authentication result, if any, + // always takes precedence over what is specified in `redirectURL` parameter. + if (preAccessRedirectURL) { + preAccessRedirectURL = `${preAccessRedirectURL}?next=${encodeURIComponent( + authenticationResult.redirectURL || + redirectURL || + `${this.options.basePath.get(request)}${request.url.path}` + )}`; + } else if (redirectURL && !authenticationResult.redirectURL) { + preAccessRedirectURL = redirectURL; + } + + return preAccessRedirectURL + ? AuthenticationResult.redirectTo(preAccessRedirectURL, { + state: authenticationResult.state, + user: authenticationResult.user, + authResponseHeaders: authenticationResult.authResponseHeaders, + }) + : authenticationResult; + } } diff --git a/x-pack/plugins/security/server/authentication/index.mock.ts b/x-pack/plugins/security/server/authentication/index.mock.ts index 7cd3ac18634f7..299a75335a64c 100644 --- a/x-pack/plugins/security/server/authentication/index.mock.ts +++ b/x-pack/plugins/security/server/authentication/index.mock.ts @@ -18,7 +18,6 @@ export const authenticationMock = { invalidateAPIKey: jest.fn(), invalidateAPIKeyAsInternalUser: jest.fn(), isAuthenticated: jest.fn(), - getSessionInfo: jest.fn(), acknowledgeAccessAgreement: jest.fn(), }), }; diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index a125d9a62afb7..263ea5c4e5041 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { licenseMock } from '../../common/licensing/index.mock'; - jest.mock('./api_keys'); jest.mock('./authenticator'); @@ -18,17 +16,20 @@ import { httpServiceMock, elasticsearchServiceMock, } from '../../../../../src/core/server/mocks'; +import { licenseMock } from '../../common/licensing/index.mock'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; import { securityAuditLoggerMock } from '../audit/index.mock'; +import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; +import { sessionMock } from '../session_management/session.mock'; import { AuthenticationHandler, AuthToolkit, ILegacyClusterClient, - CoreSetup, KibanaRequest, LoggerFactory, LegacyScopedClusterClient, + HttpServiceSetup, } from '../../../../../src/core/server'; import { AuthenticatedUser } from '../../common/model'; import { ConfigSchema, ConfigType, createConfig } from '../config'; @@ -43,17 +44,18 @@ import { import { SecurityLicense } from '../../common/licensing'; import { SecurityAuditLogger } from '../audit'; import { SecurityFeatureUsageServiceStart } from '../feature_usage'; -import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; +import { Session } from '../session_management'; describe('setupAuthentication()', () => { let mockSetupAuthenticationParams: { auditLogger: jest.Mocked; config: ConfigType; loggers: LoggerFactory; - http: jest.Mocked; + http: jest.Mocked; clusterClient: jest.Mocked; license: jest.Mocked; getFeatureUsageService: () => jest.Mocked; + session: jest.Mocked>; }; let mockScopedClusterClient: jest.Mocked>; beforeEach(() => { @@ -75,6 +77,7 @@ describe('setupAuthentication()', () => { getFeatureUsageService: jest .fn() .mockReturnValue(securityFeatureUsageServiceMock.createStartContract()), + session: sessionMock.create(), }; mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); @@ -85,31 +88,13 @@ describe('setupAuthentication()', () => { afterEach(() => jest.clearAllMocks()); - it('properly initializes session storage and registers auth handler', async () => { - const config = { - encryptionKey: 'ab'.repeat(16), - secureCookies: true, - cookieName: 'my-sid-cookie', - }; - + it('properly registers auth handler', async () => { await setupAuthentication(mockSetupAuthenticationParams); expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledTimes(1); expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledWith( expect.any(Function) ); - - expect( - mockSetupAuthenticationParams.http.createCookieSessionStorageFactory - ).toHaveBeenCalledTimes(1); - expect( - mockSetupAuthenticationParams.http.createCookieSessionStorageFactory - ).toHaveBeenCalledWith({ - encryptionKey: config.encryptionKey, - isSecure: config.secureCookies, - name: config.cookieName, - validate: expect.any(Function), - }); }); describe('authentication handler', () => { @@ -121,6 +106,11 @@ describe('setupAuthentication()', () => { await setupAuthentication(mockSetupAuthenticationParams); + expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledTimes(1); + expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledWith( + expect.any(Function) + ); + authHandler = mockSetupAuthenticationParams.http.registerAuth.mock.calls[0][0]; authenticate = jest.requireMock('./authenticator').Authenticator.mock.instances[0] .authenticate; @@ -195,15 +185,20 @@ describe('setupAuthentication()', () => { expect(authenticate).toHaveBeenCalledWith(mockRequest); }); - it('redirects user if redirection is requested by the authenticator', async () => { + it('redirects user if redirection is requested by the authenticator preserving authentication response headers if any', async () => { const mockResponse = httpServerMock.createLifecycleResponseFactory(); - authenticate.mockResolvedValue(AuthenticationResult.redirectTo('/some/url')); + authenticate.mockResolvedValue( + AuthenticationResult.redirectTo('/some/url', { + authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, + }) + ); await authHandler(httpServerMock.createKibanaRequest(), mockResponse, mockAuthToolkit); expect(mockAuthToolkit.redirected).toHaveBeenCalledTimes(1); expect(mockAuthToolkit.redirected).toHaveBeenCalledWith({ location: '/some/url', + 'WWW-Authenticate': 'Negotiate', }); expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled(); expect(mockResponse.internalError).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index ed631e221b7a3..431c82fb28a6c 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -6,24 +6,32 @@ import { UnwrapPromise } from '@kbn/utility-types'; import { ILegacyClusterClient, - CoreSetup, KibanaRequest, LoggerFactory, + HttpServiceSetup, } from '../../../../../src/core/server'; import { SecurityLicense } from '../../common/licensing'; import { AuthenticatedUser } from '../../common/model'; import { SecurityAuditLogger } from '../audit'; import { ConfigType } from '../config'; import { getErrorStatusCode } from '../errors'; -import { Authenticator, ProviderSession } from './authenticator'; -import { APIKeys, CreateAPIKeyParams, InvalidateAPIKeyParams } from './api_keys'; import { SecurityFeatureUsageServiceStart } from '../feature_usage'; +import { Session } from '../session_management'; +import { Authenticator } from './authenticator'; +import { APIKeys, CreateAPIKeyParams, InvalidateAPIKeyParams } from './api_keys'; export { canRedirectRequest } from './can_redirect_request'; export { Authenticator, ProviderLoginAttempt } from './authenticator'; export { AuthenticationResult } from './authentication_result'; export { DeauthenticationResult } from './deauthentication_result'; -export { OIDCLogin, SAMLLogin } from './providers'; +export { + OIDCLogin, + SAMLLogin, + BasicAuthenticationProvider, + TokenAuthenticationProvider, + SAMLAuthenticationProvider, + OIDCAuthenticationProvider, +} from './providers'; export { CreateAPIKeyResult, InvalidateAPIKeyResult, @@ -39,11 +47,12 @@ export { interface SetupAuthenticationParams { auditLogger: SecurityAuditLogger; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; - http: CoreSetup['http']; + http: HttpServiceSetup; clusterClient: ILegacyClusterClient; config: ConfigType; license: SecurityLicense; loggers: LoggerFactory; + session: PublicMethodsOf; } export type Authentication = UnwrapPromise>; @@ -56,6 +65,7 @@ export async function setupAuthentication({ config, license, loggers, + session, }: SetupAuthenticationParams) { const authLogger = loggers.get('authentication'); @@ -71,46 +81,16 @@ export async function setupAuthentication({ return (http.auth.get(request).state ?? null) as AuthenticatedUser | null; }; - const isValid = (sessionValue: ProviderSession) => { - // ensure that this cookie was created with the current Kibana configuration - const { path, idleTimeoutExpiration, lifespanExpiration } = sessionValue; - if (path !== undefined && path !== (http.basePath.serverBasePath || '/')) { - authLogger.debug(`Outdated session value with path "${sessionValue.path}"`); - return false; - } - // ensure that this cookie is not expired - if (idleTimeoutExpiration && idleTimeoutExpiration < Date.now()) { - return false; - } else if (lifespanExpiration && lifespanExpiration < Date.now()) { - return false; - } - return true; - }; - const authenticator = new Authenticator({ auditLogger, - getFeatureUsageService, - getCurrentUser, + loggers, clusterClient, basePath: http.basePath, - config: { session: config.session, authc: config.authc }, + config: { authc: config.authc }, + getCurrentUser, + getFeatureUsageService, license, - loggers, - sessionStorageFactory: await http.createCookieSessionStorageFactory({ - encryptionKey: config.encryptionKey, - isSecure: config.secureCookies, - name: config.cookieName, - sameSite: config.sameSiteCookies, - validate: (session: ProviderSession | ProviderSession[]) => { - const array: ProviderSession[] = Array.isArray(session) ? session : [session]; - for (const sess of array) { - if (!isValid(sess)) { - return { isValid: false, path: sess.path }; - } - } - return { isValid: true }; - }, - }), + session, }); authLogger.debug('Successfully initialized authenticator.'); @@ -145,6 +125,7 @@ export async function setupAuthentication({ // decides what location user should be redirected to. return t.redirected({ location: authenticationResult.redirectURL!, + ...(authenticationResult.authResponseHeaders || {}), }); } @@ -180,7 +161,6 @@ export async function setupAuthentication({ return { login: authenticator.login.bind(authenticator), logout: authenticator.logout.bind(authenticator), - getSessionInfo: authenticator.getSessionInfo.bind(authenticator), isProviderTypeEnabled: authenticator.isProviderTypeEnabled.bind(authenticator), acknowledgeAccessAgreement: authenticator.acknowledgeAccessAgreement.bind(authenticator), getCurrentUser, diff --git a/x-pack/plugins/security/server/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts index 22d10d1cec347..2481844abb389 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -184,6 +184,12 @@ describe('BasicAuthenticationProvider', () => { ); }); + it('redirects to login view if state is `null`.', async () => { + await expect(provider.logout(httpServerMock.createKibanaRequest(), null)).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT') + ); + }); + it('always redirects to the login page.', async () => { await expect(provider.logout(httpServerMock.createKibanaRequest(), {})).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT') diff --git a/x-pack/plugins/security/server/authentication/providers/basic.ts b/x-pack/plugins/security/server/authentication/providers/basic.ts index 83d4ea689f46a..35ab2d242659a 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.ts @@ -121,7 +121,9 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { public async logout(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); - if (!state) { + // Having a `null` state means that provider was specifically called to do a logout, but when + // session isn't defined then provider is just being probed whether or not it can perform logout. + if (state === undefined) { return DeauthenticationResult.notHandled(); } diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index f04506eb01593..839b5c991f09b 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -496,8 +496,14 @@ describe('KerberosAuthenticationProvider', () => { await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); + expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); + }); + + it('redirects to logged out view if state is `null`.', async () => { + const request = httpServerMock.createKibanaRequest(); + await expect(provider.logout(request, null)).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) ); expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index 44919fae9ced8..5b593851cc2f2 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -102,16 +102,20 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { public async logout(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); - if (!state) { + // Having a `null` state means that provider was specifically called to do a logout, but when + // session isn't defined then provider is just being probed whether or not it can perform logout. + if (state === undefined) { this.logger.debug('There is no access token invalidate.'); return DeauthenticationResult.notHandled(); } - try { - await this.options.tokens.invalidate(state); - } catch (err) { - this.logger.debug(`Failed invalidating access and/or refresh tokens: ${err.message}`); - return DeauthenticationResult.failed(err); + if (state) { + try { + await this.options.tokens.invalidate(state); + } catch (err) { + this.logger.debug(`Failed invalidating access and/or refresh tokens: ${err.message}`); + return DeauthenticationResult.failed(err); + } } return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index aea5994e3ba3e..81e9ecb8a377b 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -12,31 +12,33 @@ import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } import { LegacyElasticsearchErrorHelpers, - ILegacyClusterClient, KibanaRequest, - ScopeableRequest, + ILegacyScopedClusterClient, } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { OIDCAuthenticationProvider, OIDCLogin, ProviderLoginAttempt } from './oidc'; - -function expectAuthenticateCall( - mockClusterClient: jest.Mocked, - scopeableRequest: ScopeableRequest -) { - expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); - expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); - - const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); -} +import { AuthenticatedUser } from '../../../common/model'; describe('OIDCAuthenticationProvider', () => { let provider: OIDCAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; + let mockUser: AuthenticatedUser; + let mockScopedClusterClient: jest.Mocked; beforeEach(() => { mockOptions = mockAuthenticationProviderOptions({ name: 'oidc' }); + + mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockUser = mockAuthenticatedUser({ authentication_provider: 'oidc' }); + mockScopedClusterClient.callAsCurrentUser.mockImplementation(async (method) => { + if (method === 'shield.authenticate') { + return mockUser; + } + + throw new Error(`Unexpected call to ${method}!`); + }); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + provider = new OIDCAuthenticationProvider(mockOptions, { realm: 'oidc1' }); }); @@ -88,7 +90,7 @@ describe('OIDCAuthenticationProvider', () => { state: { state: 'statevalue', nonce: 'noncevalue', - nextURL: '/mock-server-basepath/', + redirectURL: '/mock-server-basepath/', realm: 'oidc1', }, } @@ -118,7 +120,7 @@ describe('OIDCAuthenticationProvider', () => { await expect( provider.login(request, { type: OIDCLogin.LoginInitiatedByUser, - redirectURLPath: '/mock-server-basepath/app/super-kibana', + redirectURL: '/mock-server-basepath/app/super-kibana#some-hash', }) ).resolves.toEqual( AuthenticationResult.redirectTo( @@ -132,7 +134,7 @@ describe('OIDCAuthenticationProvider', () => { state: { state: 'statevalue', nonce: 'noncevalue', - nextURL: '/mock-server-basepath/app/super-kibana', + redirectURL: '/mock-server-basepath/app/super-kibana#some-hash', realm: 'oidc1', }, } @@ -144,6 +146,24 @@ describe('OIDCAuthenticationProvider', () => { }); }); + it('fails if OpenID Connect authentication request preparation fails.', async () => { + const request = httpServerMock.createKibanaRequest(); + + const failureReason = new Error('Realm is misconfigured!'); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + + await expect( + provider.login(request, { + type: OIDCLogin.LoginInitiatedByUser, + redirectURL: '/mock-server-basepath/app/super-kibana#some-hash', + }) + ).resolves.toEqual(AuthenticationResult.failed(failureReason)); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { + body: { realm: `oidc1` }, + }); + }); + function defineAuthenticationFlowTests( getMocks: () => { request: KibanaRequest; @@ -163,7 +183,7 @@ describe('OIDCAuthenticationProvider', () => { provider.login(request, attempt, { state: 'statevalue', nonce: 'noncevalue', - nextURL: '/base-path/some-path', + redirectURL: '/base-path/some-path', realm: 'oidc1', }) ).resolves.toEqual( @@ -173,6 +193,7 @@ describe('OIDCAuthenticationProvider', () => { refreshToken: 'some-refresh-token', realm: 'oidc1', }, + user: mockUser, }) ); @@ -193,7 +214,7 @@ describe('OIDCAuthenticationProvider', () => { const { request, attempt } = getMocks(); await expect( - provider.login(request, attempt, { nextURL: '/base-path/some-path', realm: 'oidc1' }) + provider.login(request, attempt, { redirectURL: '/base-path/some-path', realm: 'oidc1' }) ).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( @@ -251,7 +272,7 @@ describe('OIDCAuthenticationProvider', () => { provider.login(request, attempt, { state: 'statevalue', nonce: 'noncevalue', - nextURL: '/base-path/some-path', + redirectURL: '/base-path/some-path', realm: 'oidc1', }) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -328,60 +349,25 @@ describe('OIDCAuthenticationProvider', () => { ); }); - it('redirects non-AJAX request that can not be authenticated to the OpenId Connect Provider.', async () => { + it('redirects non-AJAX request that can not be authenticated to the "capture URL" page.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); mockOptions.client.callAsInternalUser.mockResolvedValue({ - state: 'statevalue', - nonce: 'noncevalue', - redirect: - 'https://op-host/path/login?response_type=code' + - '&scope=openid%20profile%20email' + - '&client_id=s6BhdRkqt3' + - '&state=statevalue' + - '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc', + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', }); await expect(provider.authenticate(request, null)).resolves.toEqual( AuthenticationResult.redirectTo( - 'https://op-host/path/login?response_type=code' + - '&scope=openid%20profile%20email' + - '&client_id=s6BhdRkqt3' + - '&state=statevalue' + - '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc', - { - state: { - state: 'statevalue', - nonce: 'noncevalue', - nextURL: '/mock-server-basepath/s/foo/some-path', - realm: 'oidc1', - }, - } + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=oidc&providerName=oidc', + { state: null } ) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { - body: { realm: `oidc1` }, - }); - }); - - it('fails if OpenID Connect authentication request preparation fails.', async () => { - const request = httpServerMock.createKibanaRequest({ path: '/some-path' }); - - const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { - body: { realm: `oidc1` }, - }); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('succeeds if state contains a valid token.', async () => { - const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest({ headers: {} }); const tokenPair = { accessToken: 'some-valid-token', @@ -389,20 +375,13 @@ describe('OIDCAuthenticationProvider', () => { }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect( provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) ).resolves.toEqual( - AuthenticationResult.succeeded( - { ...user, authentication_provider: 'oidc' }, - { authHeaders: { authorization } } - ) + AuthenticationResult.succeeded(mockUser, { authHeaders: { authorization } }) ); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); }); @@ -446,36 +425,31 @@ describe('OIDCAuthenticationProvider', () => { const authorization = `Bearer ${tokenPair.accessToken}`; const failureReason = new Error('Token is not valid!'); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect( provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); }); it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => { - const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'expired-token', refreshToken: 'valid-refresh-token' }; mockOptions.client.asScoped.mockImplementation((scopeableRequest) => { if (scopeableRequest?.headers.authorization === `Bearer ${tokenPair.accessToken}`) { - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + const mockScopedClusterClientToFail = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClientToFail.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - return mockScopedClusterClient; + return mockScopedClusterClientToFail; } if (scopeableRequest?.headers.authorization === 'Bearer new-access-token') { - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); return mockScopedClusterClient; } @@ -490,17 +464,14 @@ describe('OIDCAuthenticationProvider', () => { await expect( provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) ).resolves.toEqual( - AuthenticationResult.succeeded( - { ...user, authentication_provider: 'oidc' }, - { - authHeaders: { authorization: 'Bearer new-access-token' }, - state: { - accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', - realm: 'oidc1', - }, - } - ) + AuthenticationResult.succeeded(mockUser, { + authHeaders: { authorization: 'Bearer new-access-token' }, + state: { + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + realm: 'oidc1', + }, + }) ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); @@ -514,11 +485,9 @@ describe('OIDCAuthenticationProvider', () => { const tokenPair = { accessToken: 'expired-token', refreshToken: 'invalid-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const refreshFailureReason = { statusCode: 500, @@ -533,32 +502,19 @@ describe('OIDCAuthenticationProvider', () => { expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); }); - it('redirects to OpenID Connect Provider for non-AJAX requests if refresh token is expired or already refreshed.', async () => { + it('redirects non-AJAX requests to the "capture URL" page if refresh token is expired or already refreshed.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} }); const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; - mockOptions.client.callAsInternalUser.mockResolvedValue({ - state: 'statevalue', - nonce: 'noncevalue', - redirect: - 'https://op-host/path/login?response_type=code' + - '&scope=openid%20profile%20email' + - '&client_id=s6BhdRkqt3' + - '&state=statevalue' + - '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc', - }); - - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -566,19 +522,8 @@ describe('OIDCAuthenticationProvider', () => { provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) ).resolves.toEqual( AuthenticationResult.redirectTo( - 'https://op-host/path/login?response_type=code' + - '&scope=openid%20profile%20email' + - '&client_id=s6BhdRkqt3' + - '&state=statevalue' + - '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc', - { - state: { - state: 'statevalue', - nonce: 'noncevalue', - nextURL: '/mock-server-basepath/s/foo/some-path', - realm: 'oidc1', - }, - } + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=oidc&providerName=oidc', + { state: null } ) ); @@ -592,9 +537,7 @@ describe('OIDCAuthenticationProvider', () => { expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { - body: { realm: `oidc1` }, - }); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { @@ -602,11 +545,9 @@ describe('OIDCAuthenticationProvider', () => { const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -619,7 +560,7 @@ describe('OIDCAuthenticationProvider', () => { expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - expectAuthenticateCall(mockOptions.client, { + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { 'kbn-xsrf': 'xsrf', authorization }, }); @@ -631,11 +572,9 @@ describe('OIDCAuthenticationProvider', () => { const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; const authorization = `Bearer ${tokenPair.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -648,7 +587,7 @@ describe('OIDCAuthenticationProvider', () => { expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); }); @@ -666,19 +605,26 @@ describe('OIDCAuthenticationProvider', () => { }); describe('`logout` method', () => { - it('returns `notHandled` if state is not presented or does not include access token.', async () => { + it('returns `notHandled` if state is not presented.', async () => { const request = httpServerMock.createKibanaRequest(); await expect(provider.logout(request, undefined as any)).resolves.toEqual( DeauthenticationResult.notHandled() ); - await expect(provider.logout(request, {} as any)).resolves.toEqual( - DeauthenticationResult.notHandled() - ); + await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('redirects to logged out view if state is `null` or does not include access token.', async () => { + const request = httpServerMock.createKibanaRequest(); + + await expect(provider.logout(request, null)).resolves.toEqual( + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) + ); await expect(provider.logout(request, { nonce: 'x', realm: 'oidc1' })).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index ac7374401f99a..75c909cdcd94b 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -7,6 +7,7 @@ import Boom from 'boom'; import type from 'type-detect'; import { KibanaRequest } from '../../../../../../src/core/server'; +import { AuthenticatedUser } from '../../../common/model'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -32,7 +33,7 @@ export enum OIDCLogin { * Describes the parameters that are required by the provider to process the initial login request. */ export type ProviderLoginAttempt = - | { type: OIDCLogin.LoginInitiatedByUser; redirectURLPath: string } + | { type: OIDCLogin.LoginInitiatedByUser; redirectURL: string } | { type: OIDCLogin.LoginWithImplicitFlow | OIDCLogin.LoginWithAuthorizationCodeFlow; authenticationResponseURI: string; @@ -58,7 +59,7 @@ interface ProviderState extends Partial { /** * URL to redirect user to after successful OpenID Connect handshake. */ - nextURL?: string; + redirectURL?: string; /** * The name of the OpenID Connect realm that was used to establish session. @@ -143,11 +144,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { if (attempt.type === OIDCLogin.LoginInitiatedByUser) { this.logger.debug(`Login has been initiated by a user.`); - return this.initiateOIDCAuthentication( - request, - { realm: this.realm }, - attempt.redirectURLPath - ); + return this.initiateOIDCAuthentication(request, { realm: this.realm }, attempt.redirectURL); } if (attempt.type === OIDCLogin.LoginWithImplicitFlow) { @@ -200,7 +197,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in // another tab) return authenticationResult.notHandled() && canStartNewSession(request) - ? await this.initiateOIDCAuthentication(request, { realm: this.realm }) + ? await this.captureRedirectURL(request) : authenticationResult; } @@ -231,8 +228,11 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // If it is an authentication response and the users' session state doesn't contain all the necessary information, // then something unexpected happened and we should fail because Elasticsearch won't be able to validate the // response. - const { nonce: stateNonce = '', state: stateOIDCState = '', nextURL: stateRedirectURL = '' } = - sessionState || {}; + const { + nonce: stateNonce = '', + state: stateOIDCState = '', + redirectURL: stateRedirectURL = '', + } = sessionState || {}; if (!stateNonce || !stateOIDCState || !stateRedirectURL) { const message = 'Response session state does not have corresponding state or nonce parameters or redirect URL.'; @@ -241,30 +241,47 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { } // We have all the necessary parameters, so attempt to complete the OpenID Connect Authentication + let accessToken; + let refreshToken; try { // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/oidc/authenticate`. - const { - access_token: accessToken, - refresh_token: refreshToken, - } = await this.options.client.callAsInternalUser('shield.oidcAuthenticate', { - body: { - state: stateOIDCState, - nonce: stateNonce, - redirect_uri: authenticationResponseURI, - realm: this.realm, - }, - }); + const authenticateResponse = await this.options.client.callAsInternalUser( + 'shield.oidcAuthenticate', + { + body: { + state: stateOIDCState, + nonce: stateNonce, + redirect_uri: authenticationResponseURI, + realm: this.realm, + }, + } + ); - this.logger.debug('Request has been authenticated via OpenID Connect.'); + accessToken = authenticateResponse.access_token; + refreshToken = authenticateResponse.refresh_token; + } catch (err) { + this.logger.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); + return AuthenticationResult.failed(err); + } - return AuthenticationResult.redirectTo(stateRedirectURL, { - state: { accessToken, refreshToken, realm: this.realm }, + // Now we need to retrieve full user information. + let user: Readonly; + try { + user = await this.getUser(request, { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), }); } catch (err) { - this.logger.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); + this.logger.debug(`Failed to retrieve user using access token: ${err.message}`); return AuthenticationResult.failed(err); } + + this.logger.debug('Login has been performed with OpenID Connect response.'); + + return AuthenticationResult.redirectTo(stateRedirectURL, { + state: { accessToken, refreshToken, realm: this.realm }, + user, + }); } /** @@ -272,13 +289,12 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * * @param request Request instance. * @param params OIDC authentication parameters. - * @param [redirectURLPath] Optional URL user is supposed to be redirected to after successful - * login. If not provided the URL of the specified request is used. + * @param redirectURL URL user is supposed to be redirected to after successful login. */ private async initiateOIDCAuthentication( request: KibanaRequest, params: { realm: string } | { iss: string; login_hint?: string }, - redirectURLPath = `${this.options.basePath.get(request)}${request.url.path}` + redirectURL: string ) { this.logger.debug('Trying to initiate OpenID Connect authentication.'); @@ -295,7 +311,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.redirectTo( redirect, // Store the state and nonce parameters in the session state of the user - { state: { state, nonce, nextURL: redirectURLPath, realm: this.realm } } + { state: { state, nonce, redirectURL, realm: this.realm } } ); } catch (err) { this.logger.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`); @@ -367,7 +383,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug( 'Both elasticsearch access and refresh tokens are expired. Re-initiating OpenID Connect authentication.' ); - return this.initiateOIDCAuthentication(request, { realm: this.realm }); + return this.captureRedirectURL(request); } return AuthenticationResult.failed( @@ -401,43 +417,47 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - public async logout(request: KibanaRequest, state: ProviderState) { + public async logout(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); - if (!state || !state.accessToken) { + // Having a `null` state means that provider was specifically called to do a logout, but when + // session isn't defined then provider is just being probed whether or not it can perform logout. + if (state === undefined) { this.logger.debug('There is no elasticsearch access token to invalidate.'); return DeauthenticationResult.notHandled(); } - try { - const logoutBody = { - body: { - token: state.accessToken, - refresh_token: state.refreshToken, - }, - }; - // This operation should be performed on behalf of the user with a privilege that normal - // user usually doesn't have `cluster:admin/xpack/security/oidc/logout`. - const { redirect } = await this.options.client.callAsInternalUser( - 'shield.oidcLogout', - logoutBody - ); - - this.logger.debug('User session has been successfully invalidated.'); + if (state?.accessToken) { + try { + const logoutBody = { + body: { + token: state.accessToken, + refresh_token: state.refreshToken, + }, + }; + // This operation should be performed on behalf of the user with a privilege that normal + // user usually doesn't have `cluster:admin/xpack/security/oidc/logout`. + const { redirect } = await this.options.client.callAsInternalUser( + 'shield.oidcLogout', + logoutBody + ); - // Having non-null `redirect` field within logout response means that the OpenID Connect realm configuration - // supports RP initiated Single Logout and we should redirect user to the specified location in the OpenID Connect - // Provider to properly complete logout. - if (redirect != null) { - this.logger.debug('Redirecting user to the OpenID Connect Provider to complete logout.'); - return DeauthenticationResult.redirectTo(redirect); + this.logger.debug('User session has been successfully invalidated.'); + + // Having non-null `redirect` field within logout response means that the OpenID Connect realm configuration + // supports RP initiated Single Logout and we should redirect user to the specified location in the OpenID Connect + // Provider to properly complete logout. + if (redirect != null) { + this.logger.debug('Redirecting user to the OpenID Connect Provider to complete logout.'); + return DeauthenticationResult.redirectTo(redirect); + } + } catch (err) { + this.logger.debug(`Failed to deauthenticate user: ${err.message}`); + return DeauthenticationResult.failed(err); } - - return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); - } catch (err) { - this.logger.debug(`Failed to deauthenticate user: ${err.message}`); - return DeauthenticationResult.failed(err); } + + return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); } /** @@ -447,4 +467,23 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { public getHTTPAuthenticationScheme() { return 'bearer'; } + + /** + * Tries to capture full redirect URL (both path and fragment) and initiate OIDC handshake. + * @param request Request instance. + */ + private captureRedirectURL(request: KibanaRequest) { + return AuthenticationResult.redirectTo( + `${ + this.options.basePath.serverBasePath + }/internal/security/capture-url?next=${encodeURIComponent( + `${this.options.basePath.get(request)}${request.url.path}` + )}&providerType=${encodeURIComponent(this.type)}&providerName=${encodeURIComponent( + this.options.name + )}`, + // Here we indicate that current session, if any, should be invalidated. It is a no-op for the + // initial handshake, but is essential when both access and refresh tokens are expired. + { state: null } + ); + } } diff --git a/x-pack/plugins/security/server/authentication/providers/pki.test.ts b/x-pack/plugins/security/server/authentication/providers/pki.test.ts index fec03c5d04b0d..053d20e37b39e 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts @@ -527,8 +527,14 @@ describe('PKIAuthenticationProvider', () => { await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); + expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); + }); + + it('redirects to logged out view if state is `null`.', async () => { + const request = httpServerMock.createKibanaRequest(); + await expect(provider.logout(request, null)).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) ); expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts index 164a9516f0695..f3cc21500df26 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.ts @@ -107,16 +107,20 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { public async logout(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); - if (!state) { + // Having a `null` state means that provider was specifically called to do a logout, but when + // session isn't defined then provider is just being probed whether or not it can perform logout. + if (state === undefined) { this.logger.debug('There is no access token to invalidate.'); return DeauthenticationResult.notHandled(); } - try { - await this.options.tokens.invalidate({ accessToken: state.accessToken }); - } catch (err) { - this.logger.debug(`Failed invalidating access token: ${err.message}`); - return DeauthenticationResult.failed(err); + if (state) { + try { + await this.options.tokens.invalidate({ accessToken: state.accessToken }); + } catch (err) { + this.logger.debug(`Failed invalidating access token: ${err.message}`); + return DeauthenticationResult.failed(err); + } } return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index 851ecf8107ad2..75eb7ae93f360 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -5,7 +5,6 @@ */ import Boom from 'boom'; -import { ByteSizeValue } from '@kbn/config-schema'; import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; @@ -13,33 +12,34 @@ import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } import { LegacyElasticsearchErrorHelpers, - ILegacyClusterClient, - ScopeableRequest, + ILegacyScopedClusterClient, } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { SAMLAuthenticationProvider, SAMLLogin } from './saml'; - -function expectAuthenticateCall( - mockClusterClient: jest.Mocked, - scopeableRequest: ScopeableRequest -) { - expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); - expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); - - const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); -} +import { AuthenticatedUser } from '../../../common/model'; describe('SAMLAuthenticationProvider', () => { let provider: SAMLAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; + let mockUser: AuthenticatedUser; + let mockScopedClusterClient: jest.Mocked; beforeEach(() => { mockOptions = mockAuthenticationProviderOptions({ name: 'saml' }); + + mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockUser = mockAuthenticatedUser({ authentication_provider: 'saml' }); + mockScopedClusterClient.callAsCurrentUser.mockImplementation(async (method) => { + if (method === 'shield.authenticate') { + return mockUser; + } + + throw new Error(`Unexpected call to ${method}!`); + }); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', - maxRedirectURLSize: new ByteSizeValue(100), }); }); @@ -57,28 +57,11 @@ describe('SAMLAuthenticationProvider', () => { ); }); - it('throws if `maxRedirectURLSize` option is not specified', () => { - const providerOptions = mockAuthenticationProviderOptions(); - - expect( - () => new SAMLAuthenticationProvider(providerOptions, { realm: 'test-realm' }) - ).toThrowError('Maximum redirect URL size must be specified'); - - expect( - () => - new SAMLAuthenticationProvider(providerOptions, { - realm: 'test-realm', - maxRedirectURLSize: undefined, - }) - ).toThrowError('Maximum redirect URL size must be specified'); - }); - describe('`login` method', () => { it('gets token and redirects user to requested URL if SAML Response is valid.', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'user', access_token: 'some-token', refresh_token: 'some-refresh-token', }); @@ -96,11 +79,11 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.redirectTo('/test-base-path/some-path#some-app', { state: { - username: 'user', accessToken: 'some-token', refreshToken: 'some-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); @@ -114,14 +97,12 @@ describe('SAMLAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'user', access_token: 'some-token', refresh_token: 'some-refresh-token', }); provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', - maxRedirectURLSize: new ByteSizeValue(100), useRelayStateDeepLink: true, }); await expect( @@ -141,11 +122,11 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.redirectTo('/test-base-path/some-path#some-app', { state: { - username: 'user', accessToken: 'some-token', refreshToken: 'some-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); @@ -214,6 +195,7 @@ describe('SAMLAuthenticationProvider', () => { refreshToken: 'user-initiated-login-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); @@ -233,7 +215,6 @@ describe('SAMLAuthenticationProvider', () => { provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', - maxRedirectURLSize: new ByteSizeValue(100), useRelayStateDeepLink: true, }); await expect( @@ -253,6 +234,7 @@ describe('SAMLAuthenticationProvider', () => { refreshToken: 'user-initiated-login-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); @@ -282,6 +264,7 @@ describe('SAMLAuthenticationProvider', () => { refreshToken: 'idp-initiated-login-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); @@ -319,12 +302,6 @@ describe('SAMLAuthenticationProvider', () => { beforeEach(() => { mockOptions.basePath.get.mockReturnValue(mockOptions.basePath.serverBasePath); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockImplementation(() => - Promise.resolve(mockAuthenticatedUser()) - ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.client.callAsInternalUser.mockResolvedValue({ username: 'user', access_token: 'valid-token', @@ -333,7 +310,6 @@ describe('SAMLAuthenticationProvider', () => { provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', - maxRedirectURLSize: new ByteSizeValue(100), useRelayStateDeepLink: true, }); }); @@ -341,7 +317,6 @@ describe('SAMLAuthenticationProvider', () => { it('redirects to the home page if `useRelayStateDeepLink` is set to `false`.', async () => { provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', - maxRedirectURLSize: new ByteSizeValue(100), useRelayStateDeepLink: false, }); @@ -354,11 +329,11 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { state: { - username: 'user', accessToken: 'valid-token', refreshToken: 'valid-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); }); @@ -372,11 +347,11 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { state: { - username: 'user', accessToken: 'valid-token', refreshToken: 'valid-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); }); @@ -391,11 +366,11 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { state: { - username: 'user', accessToken: 'valid-token', refreshToken: 'valid-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); }); @@ -410,11 +385,11 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, { state: { - username: 'user', accessToken: 'valid-token', refreshToken: 'valid-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); }); @@ -431,11 +406,11 @@ describe('SAMLAuthenticationProvider', () => { `${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`, { state: { - username: 'user', accessToken: 'valid-token', refreshToken: 'valid-refresh-token', realm: 'test-realm', }, + user: mockUser, } ) ); @@ -447,11 +422,6 @@ describe('SAMLAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const authorization = 'Bearer some-valid-token'; - const user = mockAuthenticatedUser(); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - const failureReason = new Error('SAML response is invalid!'); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); @@ -460,7 +430,6 @@ describe('SAMLAuthenticationProvider', () => { request, { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, { - username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', realm: 'test-realm', @@ -468,8 +437,7 @@ describe('SAMLAuthenticationProvider', () => { ) ).resolves.toEqual(AuthenticationResult.notHandled()); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); - + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { @@ -481,18 +449,12 @@ describe('SAMLAuthenticationProvider', () => { it('fails if fails to invalidate existing access/refresh tokens.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { - username: 'user', accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; - const user = mockAuthenticatedUser(); - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.client.callAsInternalUser.mockResolvedValue({ username: 'user', access_token: 'new-valid-token', @@ -510,8 +472,7 @@ describe('SAMLAuthenticationProvider', () => { ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); - + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { @@ -527,25 +488,30 @@ describe('SAMLAuthenticationProvider', () => { }); for (const [description, response] of [ - ['session is valid', Promise.resolve({ username: 'user' })], [ - 'session is is expired', + 'current session is valid', + Promise.resolve(mockAuthenticatedUser({ authentication_provider: 'saml' })), + ], + [ + 'current session is is expired', Promise.reject(LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())), ], ] as Array<[string, Promise]>) { - it(`redirects to the home page if new SAML Response is for the same user if ${description}.`, async () => { + it(`redirects to the home page if ${description}.`, async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { - username: 'user', accessToken: 'existing-token', refreshToken: 'existing-refresh-token', realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockImplementation(() => response); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + // The first call is made using tokens from existing session. + mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(() => response); + // The second call is made using new tokens. + mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(mockUser) + ); mockOptions.client.callAsInternalUser.mockResolvedValue({ username: 'user', @@ -564,16 +530,15 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.redirectTo('/mock-server-basepath/', { state: { - username: 'user', accessToken: 'new-valid-token', refreshToken: 'new-valid-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); - + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { @@ -588,19 +553,21 @@ describe('SAMLAuthenticationProvider', () => { }); }); - it(`redirects to the URL from relay state if new SAML Response is for the same user if ${description}.`, async () => { + it(`redirects to the URL from relay state if ${description}.`, async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { - username: 'user', accessToken: 'existing-token', refreshToken: 'existing-refresh-token', realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockImplementation(() => response); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + // The first call is made using tokens from existing session. + mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(() => response); + // The second call is made using new tokens. + mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(mockUser) + ); mockOptions.client.callAsInternalUser.mockResolvedValue({ username: 'user', @@ -612,7 +579,6 @@ describe('SAMLAuthenticationProvider', () => { provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', - maxRedirectURLSize: new ByteSizeValue(100), useRelayStateDeepLink: true, }); @@ -629,71 +595,15 @@ describe('SAMLAuthenticationProvider', () => { ).resolves.toEqual( AuthenticationResult.redirectTo('/mock-server-basepath/app/some-app#some-deep-link', { state: { - username: 'user', accessToken: 'new-valid-token', refreshToken: 'new-valid-refresh-token', realm: 'test-realm', }, + user: mockUser, }) ); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( - 'shield.samlAuthenticate', - { - body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, - } - ); - - expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, - refreshToken: state.refreshToken, - }); - }); - - it(`redirects to \`overwritten_session\` if new SAML Response is for the another user if ${description}.`, async () => { - const request = httpServerMock.createKibanaRequest({ headers: {} }); - const state = { - username: 'user', - accessToken: 'existing-token', - refreshToken: 'existing-refresh-token', - realm: 'test-realm', - }; - const authorization = `Bearer ${state.accessToken}`; - - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockImplementation(() => response); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - - mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'new-user', - access_token: 'new-valid-token', - refresh_token: 'new-valid-refresh-token', - }); - - mockOptions.tokens.invalidate.mockResolvedValue(undefined); - - await expect( - provider.login( - request, - { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, - state - ) - ).resolves.toEqual( - AuthenticationResult.redirectTo('/mock-server-basepath/security/overwritten_session', { - state: { - username: 'new-user', - accessToken: 'new-valid-token', - refreshToken: 'new-valid-refresh-token', - realm: 'test-realm', - }, - }) - ); - - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); - + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { @@ -711,24 +621,24 @@ describe('SAMLAuthenticationProvider', () => { }); describe('User initiated login with captured redirect URL', () => { - it('fails if redirectURLPath is not available', async () => { + it('fails if redirectURL is not valid', async () => { const request = httpServerMock.createKibanaRequest(); await expect( provider.login(request, { type: SAMLLogin.LoginInitiatedByUser, - redirectURLFragment: '#some-fragment', + redirectURL: '', }) ).resolves.toEqual( AuthenticationResult.failed( - Boom.badRequest('State or login attempt does not include URL path to redirect to.') + Boom.badRequest('Login attempt should include non-empty `redirectURL` string.') ) ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); - it('redirects requests to the IdP remembering combined redirect URL.', async () => { + it('redirects requests to the IdP remembering redirect URL with existing state.', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.mockResolvedValue({ @@ -741,9 +651,9 @@ describe('SAMLAuthenticationProvider', () => { request, { type: SAMLLogin.LoginInitiatedByUser, - redirectURLFragment: '#some-fragment', + redirectURL: '/test-base-path/some-path#some-fragment', }, - { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } + { realm: 'test-realm' } ) ).resolves.toEqual( AuthenticationResult.redirectTo( @@ -765,7 +675,7 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.logger.warn).not.toHaveBeenCalled(); }); - it('redirects requests to the IdP remembering combined redirect URL if path is provided in attempt.', async () => { + it('redirects requests to the IdP remembering redirect URL without state.', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.mockResolvedValue({ @@ -778,8 +688,7 @@ describe('SAMLAuthenticationProvider', () => { request, { type: SAMLLogin.LoginInitiatedByUser, - redirectURLPath: '/test-base-path/some-path', - redirectURLFragment: '#some-fragment', + redirectURL: '/test-base-path/some-path#some-fragment', }, null ) @@ -803,120 +712,6 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.logger.warn).not.toHaveBeenCalled(); }); - it('prepends redirect URL fragment with `#` if it does not have one.', async () => { - const request = httpServerMock.createKibanaRequest(); - - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); - - await expect( - provider.login( - request, - { - type: SAMLLogin.LoginInitiatedByUser, - redirectURLFragment: '../some-fragment', - }, - { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } - ) - ).resolves.toEqual( - AuthenticationResult.redirectTo( - 'https://idp-host/path/login?SAMLRequest=some%20request%20', - { - state: { - requestId: 'some-request-id', - redirectURL: '/test-base-path/some-path#../some-fragment', - realm: 'test-realm', - }, - } - ) - ); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { - body: { realm: 'test-realm' }, - }); - - expect(mockOptions.logger.warn).toHaveBeenCalledTimes(1); - expect(mockOptions.logger.warn).toHaveBeenCalledWith( - 'Redirect URL fragment does not start with `#`.' - ); - }); - - it('redirects requests to the IdP remembering only redirect URL path if fragment is too large.', async () => { - const request = httpServerMock.createKibanaRequest(); - - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); - - await expect( - provider.login( - request, - { - type: SAMLLogin.LoginInitiatedByUser, - redirectURLFragment: '#some-fragment'.repeat(10), - }, - { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } - ) - ).resolves.toEqual( - AuthenticationResult.redirectTo( - 'https://idp-host/path/login?SAMLRequest=some%20request%20', - { - state: { - requestId: 'some-request-id', - redirectURL: '/test-base-path/some-path', - realm: 'test-realm', - }, - } - ) - ); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { - body: { realm: 'test-realm' }, - }); - - expect(mockOptions.logger.warn).toHaveBeenCalledTimes(1); - expect(mockOptions.logger.warn).toHaveBeenCalledWith( - 'Max URL size should not exceed 100b but it was 165b. Only URL path is captured.' - ); - }); - - it('redirects requests to the IdP remembering base path if redirect URL path in attempt is too large.', async () => { - const request = httpServerMock.createKibanaRequest(); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); - - await expect( - provider.login( - request, - { - type: SAMLLogin.LoginInitiatedByUser, - redirectURLPath: `/s/foo/${'some-path'.repeat(11)}`, - redirectURLFragment: '#some-fragment', - }, - null - ) - ).resolves.toEqual( - AuthenticationResult.redirectTo( - 'https://idp-host/path/login?SAMLRequest=some%20request%20', - { state: { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } } - ) - ); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { - body: { realm: 'test-realm' }, - }); - - expect(mockOptions.logger.warn).toHaveBeenCalledTimes(1); - expect(mockOptions.logger.warn).toHaveBeenCalledWith( - 'Max URL path size should not exceed 100b but it was 106b. URL is not captured.' - ); - }); - it('fails if SAML request preparation fails.', async () => { const request = httpServerMock.createKibanaRequest(); @@ -928,9 +723,9 @@ describe('SAMLAuthenticationProvider', () => { request, { type: SAMLLogin.LoginInitiatedByUser, - redirectURLFragment: '#some-fragment', + redirectURL: '/test-base-path/some-path#some-fragment', }, - { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } + { realm: 'test-realm' } ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -977,7 +772,6 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.authenticate(request, { - username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', realm: 'test-realm', @@ -988,7 +782,7 @@ describe('SAMLAuthenticationProvider', () => { expect(request.headers.authorization).toBe('Bearer some-token'); }); - it('redirects non-AJAX request that can not be authenticated to the "capture fragment" page.', async () => { + it('redirects non-AJAX request that can not be authenticated to the "capture URL" page.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); mockOptions.client.callAsInternalUser.mockResolvedValue({ @@ -998,81 +792,28 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/internal/security/saml/capture-url-fragment', - { state: { redirectURL: '/mock-server-basepath/s/foo/some-path', realm: 'test-realm' } } + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=saml&providerName=saml', + { state: null } ) ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); - it('redirects non-AJAX request that can not be authenticated to the IdP if request path is too large.', async () => { - const request = httpServerMock.createKibanaRequest({ - path: `/s/foo/${'some-path'.repeat(10)}`, - }); - - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); - - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.redirectTo( - 'https://idp-host/path/login?SAMLRequest=some%20request%20', - { state: { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } } - ) - ); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { - body: { realm: 'test-realm' }, - }); - - expect(mockOptions.logger.warn).toHaveBeenCalledTimes(1); - expect(mockOptions.logger.warn).toHaveBeenCalledWith( - 'Max URL path size should not exceed 100b but it was 118b. URL is not captured.' - ); - }); - - it('fails if SAML request preparation fails.', async () => { - const request = httpServerMock.createKibanaRequest({ - path: `/s/foo/${'some-path'.repeat(10)}`, - }); - - const failureReason = new Error('Realm is misconfigured!'); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { - body: { realm: 'test-realm' }, - }); - }); - it('succeeds if state contains a valid token.', async () => { - const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { - username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.succeeded( - { ...user, authentication_provider: 'saml' }, - { authHeaders: { authorization } } - ) + AuthenticationResult.succeeded(mockUser, { authHeaders: { authorization } }) ); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); }); @@ -1080,7 +821,6 @@ describe('SAMLAuthenticationProvider', () => { it('fails if token from the state is rejected because of unknown reason.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { - username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', realm: 'test-realm', @@ -1088,24 +828,20 @@ describe('SAMLAuthenticationProvider', () => { const authorization = `Bearer ${state.accessToken}`; const failureReason = { statusCode: 500, message: 'Token is not valid!' }; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.failed(failureReason as any) ); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); }); it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => { - const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest(); const state = { - username: 'user', accessToken: 'expired-token', refreshToken: 'valid-refresh-token', realm: 'test-realm', @@ -1113,16 +849,14 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.asScoped.mockImplementation((scopeableRequest) => { if (scopeableRequest?.headers.authorization === `Bearer ${state.accessToken}`) { - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + const mockScopedClusterClientToFail = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClientToFail.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - return mockScopedClusterClient; + return mockScopedClusterClientToFail; } if (scopeableRequest?.headers.authorization === 'Bearer new-access-token') { - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); return mockScopedClusterClient; } @@ -1135,18 +869,14 @@ describe('SAMLAuthenticationProvider', () => { }); await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.succeeded( - { ...user, authentication_provider: 'saml' }, - { - authHeaders: { authorization: 'Bearer new-access-token' }, - state: { - username: 'user', - accessToken: 'new-access-token', - refreshToken: 'new-refresh-token', - realm: 'test-realm', - }, - } - ) + AuthenticationResult.succeeded(mockUser, { + authHeaders: { authorization: 'Bearer new-access-token' }, + state: { + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + realm: 'test-realm', + }, + }) ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); @@ -1158,18 +888,15 @@ describe('SAMLAuthenticationProvider', () => { it('fails if token from the state is expired and refresh attempt failed with unknown reason too.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const state = { - username: 'user', accessToken: 'expired-token', refreshToken: 'invalid-refresh-token', realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); const refreshFailureReason = { statusCode: 500, @@ -1184,7 +911,7 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); }); @@ -1192,18 +919,15 @@ describe('SAMLAuthenticationProvider', () => { it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); const state = { - username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -1214,7 +938,7 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); - expectAuthenticateCall(mockOptions.client, { + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { 'kbn-xsrf': 'xsrf', authorization }, }); @@ -1224,18 +948,15 @@ describe('SAMLAuthenticationProvider', () => { it('fails for non-AJAX requests that do not require authentication with user friendly message if refresh token is expired.', async () => { const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false, headers: {} }); const state = { - username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.tokens.refresh.mockResolvedValue(null); @@ -1246,9 +967,7 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); - expectAuthenticateCall(mockOptions.client, { - headers: { authorization }, - }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(request.headers).not.toHaveProperty('authorization'); }); @@ -1256,84 +975,33 @@ describe('SAMLAuthenticationProvider', () => { it('re-capture URL for non-AJAX requests if refresh token is expired.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} }); const state = { - username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.tokens.refresh.mockResolvedValue(null); await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/internal/security/saml/capture-url-fragment', - { state: { redirectURL: '/mock-server-basepath/s/foo/some-path', realm: 'test-realm' } } + '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=saml&providerName=saml', + { state: null } ) ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); - it('initiates SAML handshake for non-AJAX requests if refresh token is expired and request path is too large.', async () => { - const request = httpServerMock.createKibanaRequest({ - path: `/s/foo/${'some-path'.repeat(10)}`, - headers: {}, - }); - const state = { - username: 'user', - accessToken: 'expired-token', - refreshToken: 'expired-refresh-token', - realm: 'test-realm', - }; - const authorization = `Bearer ${state.accessToken}`; - - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); - - const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( - LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) - ); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - - mockOptions.tokens.refresh.mockResolvedValue(null); - - await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.redirectTo( - 'https://idp-host/path/login?SAMLRequest=some%20request%20', - { state: { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } } - ) - ); - - expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); - - expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { - body: { realm: 'test-realm' }, - }); - - expect(mockOptions.logger.warn).toHaveBeenCalledTimes(1); - expect(mockOptions.logger.warn).toHaveBeenCalledWith( - 'Max URL path size should not exceed 100b but it was 118b. URL is not captured.' - ); - }); - it('fails if realm from state is different from the realm provider is configured with.', async () => { const request = httpServerMock.createKibanaRequest(); await expect(provider.authenticate(request, { realm: 'other-realm' })).resolves.toEqual( @@ -1351,11 +1019,18 @@ describe('SAMLAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); - await expect(provider.logout(request, {} as any)).resolves.toEqual( - DeauthenticationResult.notHandled() + + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('redirects to logged out view if state is `null` or does not include access token.', async () => { + const request = httpServerMock.createKibanaRequest(); + + await expect(provider.logout(request, null)).resolves.toEqual( + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) ); await expect(provider.logout(request, { somethingElse: 'x' } as any)).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut) ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); @@ -1371,7 +1046,6 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { - username: 'user', accessToken, refreshToken, realm: 'test-realm', @@ -1409,7 +1083,6 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { - username: 'user', accessToken, refreshToken, realm: 'test-realm', @@ -1431,7 +1104,6 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { - username: 'user', accessToken, refreshToken, realm: 'test-realm', @@ -1455,7 +1127,6 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { - username: 'user', accessToken, refreshToken, realm: 'test-realm', @@ -1475,7 +1146,6 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { - username: 'user', accessToken: 'x-saml-token', refreshToken: 'x-saml-refresh-token', realm: 'test-realm', @@ -1539,7 +1209,6 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { - username: 'user', accessToken, refreshToken, realm: 'test-realm', @@ -1560,7 +1229,6 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.logout(request, { - username: 'user', accessToken: 'x-saml-token', refreshToken: 'x-saml-refresh-token', realm: 'test-realm', diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index d121cd4979aa7..cf6772332b8b6 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -5,8 +5,8 @@ */ import Boom from 'boom'; -import { ByteSizeValue } from '@kbn/config-schema'; import { KibanaRequest } from '../../../../../../src/core/server'; +import { AuthenticatedUser } from '../../../common/model'; import { isInternalURL } from '../../../common/is_internal_url'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -19,15 +19,11 @@ import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './bas * The state supported by the provider (for the SAML handshake or established session). */ interface ProviderState extends Partial { - /** - * Username of the SAML authenticated user. - */ - username?: string; - /** * Unique identifier of the SAML request initiated the handshake. */ requestId?: string; + /** * Stores path component of the URL only or in a combination with URL fragment that was used to * initiate SAML handshake and where we should redirect user after successful authentication. @@ -59,7 +55,7 @@ export enum SAMLLogin { * Describes the parameters that are required by the provider to process the initial login request. */ type ProviderLoginAttempt = - | { type: SAMLLogin.LoginInitiatedByUser; redirectURLPath?: string; redirectURLFragment?: string } + | { type: SAMLLogin.LoginInitiatedByUser; redirectURL: string } | { type: SAMLLogin.LoginWithSAMLResponse; samlResponse: string; relayState?: string }; /** @@ -102,11 +98,6 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { */ private readonly realm: string; - /** - * Maximum size of the URL we store in the session during SAML handshake. - */ - private readonly maxRedirectURLSize: ByteSizeValue; - /** * Indicates if we should treat non-empty `RelayState` as a deep link in Kibana we should redirect * user to after successful IdP initiated login. `RelayState` is ignored for SP initiated login. @@ -115,11 +106,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { constructor( protected readonly options: Readonly, - samlOptions?: Readonly<{ - realm?: string; - maxRedirectURLSize?: ByteSizeValue; - useRelayStateDeepLink?: boolean; - }> + samlOptions?: Readonly<{ realm?: string; useRelayStateDeepLink?: boolean }> ) { super(options); @@ -127,12 +114,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { throw new Error('Realm name must be specified'); } - if (!samlOptions.maxRedirectURLSize) { - throw new Error('Maximum redirect URL size must be specified'); - } - this.realm = samlOptions.realm; - this.maxRedirectURLSize = samlOptions.maxRedirectURLSize; this.useRelayStateDeepLink = samlOptions.useRelayStateDeepLink ?? false; } @@ -158,14 +140,12 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } if (attempt.type === SAMLLogin.LoginInitiatedByUser) { - const redirectURLPath = attempt.redirectURLPath || state?.redirectURL; - if (!redirectURLPath) { - const message = 'State or login attempt does not include URL path to redirect to.'; + if (!attempt.redirectURL) { + const message = 'Login attempt should include non-empty `redirectURL` string.'; this.logger.debug(message); return AuthenticationResult.failed(Boom.badRequest(message)); } - - return this.captureRedirectURL(request, redirectURLPath, attempt.redirectURLFragment); + return this.authenticateViaHandshake(request, attempt.redirectURL); } const { samlResponse, relayState } = attempt; @@ -251,7 +231,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state State value previously stored by the provider. */ - public async logout(request: KibanaRequest, state?: ProviderState) { + public async logout(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); // Normally when there is no active session in Kibana, `logout` method shouldn't do anything @@ -269,36 +249,38 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // redirect to the `loggedOut` URL instead. const isIdPInitiatedSLORequest = isSAMLRequestQuery(request.query); const isSPInitiatedSLOResponse = isSAMLResponseQuery(request.query); - if (!state?.accessToken && !isIdPInitiatedSLORequest && !isSPInitiatedSLOResponse) { + if (state === undefined && !isIdPInitiatedSLORequest && !isSPInitiatedSLOResponse) { this.logger.debug('There is no SAML session to invalidate.'); return DeauthenticationResult.notHandled(); } - try { - // It may _theoretically_ (highly unlikely in practice though) happen that when user receives - // logout response they may already have a new SAML session (isSPInitiatedSLOResponse == true - // and state !== undefined). In this case case it'd be safer to trigger SP initiated logout - // for the new session as well. - const redirect = isIdPInitiatedSLORequest - ? await this.performIdPInitiatedSingleLogout(request) - : state - ? await this.performUserInitiatedSingleLogout(state.accessToken!, state.refreshToken!) - : // Once Elasticsearch can consume logout response we'll be sending it here. See https://github.com/elastic/elasticsearch/issues/40901 - null; - - // Having non-null `redirect` field within logout response means that IdP - // supports SAML Single Logout and we should redirect user to the specified - // location to properly complete logout. - if (redirect != null) { - this.logger.debug('Redirecting user to Identity Provider to complete logout.'); - return DeauthenticationResult.redirectTo(redirect); + if (state?.accessToken || isIdPInitiatedSLORequest || isSPInitiatedSLOResponse) { + try { + // It may _theoretically_ (highly unlikely in practice though) happen that when user receives + // logout response they may already have a new SAML session (isSPInitiatedSLOResponse == true + // and state !== undefined). In this case case it'd be safer to trigger SP initiated logout + // for the new session as well. + const redirect = isIdPInitiatedSLORequest + ? await this.performIdPInitiatedSingleLogout(request) + : state + ? await this.performUserInitiatedSingleLogout(state.accessToken!, state.refreshToken!) + : // Once Elasticsearch can consume logout response we'll be sending it here. See https://github.com/elastic/elasticsearch/issues/40901 + null; + + // Having non-null `redirect` field within logout response means that IdP + // supports SAML Single Logout and we should redirect user to the specified + // location to properly complete logout. + if (redirect != null) { + this.logger.debug('Redirecting user to Identity Provider to complete logout.'); + return DeauthenticationResult.redirectTo(redirect); + } + } catch (err) { + this.logger.debug(`Failed to deauthenticate user: ${err.message}`); + return DeauthenticationResult.failed(err); } - - return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); - } catch (err) { - this.logger.debug(`Failed to deauthenticate user: ${err.message}`); - return DeauthenticationResult.failed(err); } + + return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); } /** @@ -354,46 +336,24 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { : 'Login has been initiated by Identity Provider.' ); + let accessToken; + let refreshToken; try { // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/authenticate`. - const { - username, - access_token: accessToken, - refresh_token: refreshToken, - } = await this.options.client.callAsInternalUser('shield.samlAuthenticate', { - body: { - ids: !isIdPInitiatedLogin ? [stateRequestId] : [], - content: samlResponse, - realm: this.realm, - }, - }); - - // IdP can pass `RelayState` with the deep link in Kibana during IdP initiated login and - // depending on the configuration we may need to redirect user to this URL. - let redirectURLFromRelayState; - if (isIdPInitiatedLogin && relayState) { - if (!this.useRelayStateDeepLink) { - this.options.logger.debug( - `"RelayState" is provided, but deep links support is not enabled for "${this.type}/${this.options.name}" provider.` - ); - } else if (!isInternalURL(relayState, this.options.basePath.serverBasePath)) { - this.options.logger.debug( - `"RelayState" is provided, but it is not a valid Kibana internal URL.` - ); - } else { - this.options.logger.debug( - `User will be redirected to the Kibana internal URL specified in "RelayState".` - ); - redirectURLFromRelayState = relayState; + const authenticateResponse = await this.options.client.callAsInternalUser( + 'shield.samlAuthenticate', + { + body: { + ids: !isIdPInitiatedLogin ? [stateRequestId] : [], + content: samlResponse, + realm: this.realm, + }, } - } - - this.logger.debug('Login has been performed with SAML response.'); - return AuthenticationResult.redirectTo( - redirectURLFromRelayState || stateRedirectURL || `${this.options.basePath.get(request)}/`, - { state: { username, accessToken, refreshToken, realm: this.realm } } ); + + accessToken = authenticateResponse.access_token; + refreshToken = authenticateResponse.refresh_token; } catch (err) { this.logger.debug(`Failed to log in with SAML response: ${err.message}`); @@ -404,6 +364,43 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { ? AuthenticationResult.notHandled() : AuthenticationResult.failed(err); } + + // Now we need to retrieve full user information. + let user: Readonly; + try { + user = await this.getUser(request, { + authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + }); + } catch (err) { + this.logger.debug(`Failed to retrieve user using access token: ${err.message}`); + return AuthenticationResult.failed(err); + } + + // IdP can pass `RelayState` with the deep link in Kibana during IdP initiated login and + // depending on the configuration we may need to redirect user to this URL. + let redirectURLFromRelayState; + if (isIdPInitiatedLogin && relayState) { + if (!this.useRelayStateDeepLink) { + this.options.logger.debug( + `"RelayState" is provided, but deep links support is not enabled for "${this.type}/${this.options.name}" provider.` + ); + } else if (!isInternalURL(relayState, this.options.basePath.serverBasePath)) { + this.options.logger.debug( + `"RelayState" is provided, but it is not a valid Kibana internal URL.` + ); + } else { + this.options.logger.debug( + `User will be redirected to the Kibana internal URL specified in "RelayState".` + ); + redirectURLFromRelayState = relayState; + } + } + + this.logger.debug('Login has been performed with SAML response.'); + return AuthenticationResult.redirectTo( + redirectURLFromRelayState || stateRedirectURL || `${this.options.basePath.get(request)}/`, + { state: { accessToken, refreshToken, realm: this.realm }, user } + ); } /** @@ -444,8 +441,6 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { ); } - const newState = payloadAuthenticationResult.state as ProviderState; - // Now let's invalidate tokens from the existing session. try { this.logger.debug('Perform IdP initiated local logout.'); @@ -458,17 +453,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.failed(err); } - if (newState.username !== existingState.username) { - this.logger.debug( - 'Login initiated by Identity Provider is for a different user than currently authenticated.' - ); - return AuthenticationResult.redirectTo( - `${this.options.basePath.serverBasePath}/security/overwritten_session`, - { state: newState } - ); - } - - this.logger.debug('Login initiated by Identity Provider is for currently authenticated user.'); + this.logger.debug('IdP initiated login completed successfully.'); return payloadAuthenticationResult; } @@ -509,7 +494,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { */ private async authenticateViaRefreshToken( request: KibanaRequest, - { username, refreshToken }: ProviderState + { refreshToken }: ProviderState ) { this.logger.debug('Trying to refresh access token.'); @@ -555,7 +540,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Request has been authenticated via refreshed token.'); return AuthenticationResult.succeeded(user, { authHeaders, - state: { username, realm: this.realm, ...refreshedTokenPair }, + state: { realm: this.realm, ...refreshedTokenPair }, }); } catch (err) { this.logger.debug( @@ -640,52 +625,19 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { /** * Tries to capture full redirect URL (both path and fragment) and initiate SAML handshake. * @param request Request instance. - * @param [redirectURLPath] Optional URL path user is supposed to be redirected to after successful - * login. If not provided the URL path of the specified request is used. - * @param [redirectURLFragment] Optional URL fragment of the URL user is supposed to be redirected - * to after successful login. If not provided user will be redirected to the client-side page that - * will grab it and redirect user back to Kibana to initiate SAML handshake. */ - private captureRedirectURL( - request: KibanaRequest, - redirectURLPath = `${this.options.basePath.get(request)}${request.url.path}`, - redirectURLFragment?: string - ) { - // If the size of the path already exceeds the maximum allowed size of the URL to store in the - // session there is no reason to try to capture URL fragment and we start handshake immediately. - // In this case user will be redirected to the Kibana home/root after successful login. - let redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURLPath)); - if (this.maxRedirectURLSize.isLessThan(redirectURLSize)) { - this.logger.warn( - `Max URL path size should not exceed ${this.maxRedirectURLSize.toString()} but it was ${redirectURLSize.toString()}. URL is not captured.` - ); - return this.authenticateViaHandshake(request, ''); - } - - // If URL fragment wasn't specified at all, let's try to capture it. - if (redirectURLFragment === undefined) { - return AuthenticationResult.redirectTo( - `${this.options.basePath.serverBasePath}/internal/security/saml/capture-url-fragment`, - { state: { redirectURL: redirectURLPath, realm: this.realm } } - ); - } - - if (redirectURLFragment.length > 0 && !redirectURLFragment.startsWith('#')) { - this.logger.warn('Redirect URL fragment does not start with `#`.'); - redirectURLFragment = `#${redirectURLFragment}`; - } - - let redirectURL = `${redirectURLPath}${redirectURLFragment}`; - redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURL)); - if (this.maxRedirectURLSize.isLessThan(redirectURLSize)) { - this.logger.warn( - `Max URL size should not exceed ${this.maxRedirectURLSize.toString()} but it was ${redirectURLSize.toString()}. Only URL path is captured.` - ); - redirectURL = redirectURLPath; - } else { - this.logger.debug('Captured redirect URL.'); - } - - return this.authenticateViaHandshake(request, redirectURL); + private captureRedirectURL(request: KibanaRequest) { + return AuthenticationResult.redirectTo( + `${ + this.options.basePath.serverBasePath + }/internal/security/capture-url?next=${encodeURIComponent( + `${this.options.basePath.get(request)}${request.url.path}` + )}&providerType=${encodeURIComponent(this.type)}&providerName=${encodeURIComponent( + this.options.name + )}`, + // Here we indicate that current session, if any, should be invalidated. It is a no-op for the + // initial handshake, but is essential when both access and refresh tokens are expired. + { state: null } + ); } } diff --git a/x-pack/plugins/security/server/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts index f83331d84e43c..0264edf4fc082 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -427,8 +427,14 @@ describe('TokenAuthenticationProvider', () => { await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); + expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); + }); + + it('redirects to login view if state is `null`.', async () => { + const request = httpServerMock.createKibanaRequest(); + await expect(provider.logout(request, null)).resolves.toEqual( - DeauthenticationResult.notHandled() + DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT') ); expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index abf4c293c4c53..869fd69173e2e 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -128,17 +128,21 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { public async logout(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); - if (!state) { + // Having a `null` state means that provider was specifically called to do a logout, but when + // session isn't defined then provider is just being probed whether or not it can perform logout. + if (state === undefined) { this.logger.debug('There are no access and refresh tokens to invalidate.'); return DeauthenticationResult.notHandled(); } this.logger.debug('Token-based logout has been initiated by the user.'); - try { - await this.options.tokens.invalidate(state); - } catch (err) { - this.logger.debug(`Failed invalidating user's access token: ${err.message}`); - return DeauthenticationResult.failed(err); + if (state) { + try { + await this.options.tokens.invalidate(state); + } catch (err) { + this.logger.debug(`Failed invalidating user's access token: ${err.message}`); + return DeauthenticationResult.failed(err); + } } const queryString = request.url.search || `?msg=LOGGED_OUT`; diff --git a/x-pack/plugins/security/server/authorization/authorization_service.test.ts b/x-pack/plugins/security/server/authorization/authorization_service.test.ts index f67e0863086bb..2fdc2d169e972 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.test.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.test.ts @@ -13,8 +13,8 @@ import { mockRegisterPrivilegesWithCluster, } from './service.test.mocks'; -import { BehaviorSubject } from 'rxjs'; -import { CoreStatus, ServiceStatusLevels } from '../../../../../src/core/server'; +import { Subject } from 'rxjs'; +import { OnlineStatusRetryScheduler } from '../elasticsearch'; import { checkPrivilegesWithRequestFactory } from './check_privileges'; import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges_dynamically'; import { checkSavedObjectsPrivilegesWithRequestFactory } from './check_saved_objects_privileges'; @@ -22,6 +22,7 @@ import { authorizationModeFactory } from './mode'; import { privilegesFactory } from './privileges'; import { AuthorizationService } from '.'; +import { nextTick } from 'test_utils/enzyme_helpers'; import { coreMock, elasticsearchServiceMock, @@ -29,8 +30,6 @@ import { } from '../../../../../src/core/server/mocks'; import { featuresPluginMock } from '../../../features/server/mocks'; import { licenseMock } from '../../common/licensing/index.mock'; -import { SecurityLicense, SecurityLicenseFeatures } from '../../common/licensing'; -import { nextTick } from 'test_utils/enzyme_helpers'; const kibanaIndexName = '.a-kibana-index'; const application = `kibana-${kibanaIndexName}`; @@ -68,7 +67,6 @@ it(`#setup returns exposed services`, () => { const authz = authorizationService.setup({ http: mockCoreSetup.http, capabilities: mockCoreSetup.capabilities, - status: mockCoreSetup.status, clusterClient: mockClusterClient, license: mockLicense, loggers: loggingSystemMock.create(), @@ -115,31 +113,19 @@ it(`#setup returns exposed services`, () => { }); describe('#start', () => { - let statusSubject: BehaviorSubject; - let licenseSubject: BehaviorSubject; - let mockLicense: jest.Mocked; + let statusSubject: Subject; beforeEach(() => { - const mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); + statusSubject = new Subject(); - licenseSubject = new BehaviorSubject(({} as unknown) as SecurityLicenseFeatures); - mockLicense = licenseMock.create(); - mockLicense.isEnabled.mockReturnValue(false); - mockLicense.features$ = licenseSubject; - - statusSubject = new BehaviorSubject({ - elasticsearch: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, - savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, - }); + const mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); const mockCoreSetup = coreMock.createSetup(); - mockCoreSetup.status.core$ = statusSubject; const authorizationService = new AuthorizationService(); authorizationService.setup({ http: mockCoreSetup.http, capabilities: mockCoreSetup.capabilities, - status: mockCoreSetup.status, clusterClient: mockClusterClient, - license: mockLicense, + license: licenseMock.create(), loggers: loggingSystemMock.create(), kibanaIndexName, packageVersion: 'some-version', @@ -152,95 +138,64 @@ describe('#start', () => { const featuresStart = featuresPluginMock.createStart(); featuresStart.getFeatures.mockReturnValue([]); - authorizationService.start({ clusterClient: mockClusterClient, features: featuresStart }); + authorizationService.start({ + clusterClient: mockClusterClient, + features: featuresStart, + online$: statusSubject.asObservable(), + }); // ES and license aren't available yet. expect(mockRegisterPrivilegesWithCluster).not.toHaveBeenCalled(); }); it('registers cluster privileges', async () => { - // ES is available now, but not license. - statusSubject.next({ - elasticsearch: { level: ServiceStatusLevels.available, summary: 'Service is working' }, - savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, - }); - expect(mockRegisterPrivilegesWithCluster).not.toHaveBeenCalled(); - - // Both ES and license are available now. - mockLicense.isEnabled.mockReturnValue(true); - licenseSubject.next(({} as unknown) as SecurityLicenseFeatures); + const retryScheduler = jest.fn(); + statusSubject.next({ scheduleRetry: retryScheduler }); expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(1); - await nextTick(); - // New changes still trigger privileges re-registration. - licenseSubject.next(({} as unknown) as SecurityLicenseFeatures); + statusSubject.next({ scheduleRetry: retryScheduler }); expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(2); + expect(retryScheduler).not.toHaveBeenCalled(); }); it('schedules retries if fails to register cluster privileges', async () => { - jest.useFakeTimers(); - mockRegisterPrivilegesWithCluster.mockRejectedValue(new Error('Some error')); - // Both ES and license are available. - mockLicense.isEnabled.mockReturnValue(true); - statusSubject.next({ - elasticsearch: { level: ServiceStatusLevels.available, summary: 'Service is working' }, - savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, - }); - expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(1); - - // Next retry isn't performed immediately, retry happens only after a timeout. + const retryScheduler = jest.fn(); + statusSubject.next({ scheduleRetry: retryScheduler }); await nextTick(); + expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(100); - expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(2); + expect(retryScheduler).toHaveBeenCalledTimes(1); - // Delay between consequent retries is increasing. + statusSubject.next({ scheduleRetry: retryScheduler }); await nextTick(); - jest.advanceTimersByTime(100); + expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(2); - await nextTick(); - jest.advanceTimersByTime(100); - expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(3); + expect(retryScheduler).toHaveBeenCalledTimes(2); // When call finally succeeds retries aren't scheduled anymore. mockRegisterPrivilegesWithCluster.mockResolvedValue(undefined); + statusSubject.next({ scheduleRetry: retryScheduler }); await nextTick(); - jest.runAllTimers(); - expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(4); - await nextTick(); - jest.runAllTimers(); - expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(4); - // New changes still trigger privileges re-registration. - licenseSubject.next(({} as unknown) as SecurityLicenseFeatures); - expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(5); + expect(mockRegisterPrivilegesWithCluster).toHaveBeenCalledTimes(3); + expect(retryScheduler).toHaveBeenCalledTimes(2); }); }); -it('#stop unsubscribes from license and ES updates.', () => { +it('#stop unsubscribes from license and ES updates.', async () => { const mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); - - const licenseSubject = new BehaviorSubject(({} as unknown) as SecurityLicenseFeatures); - const mockLicense = licenseMock.create(); - mockLicense.isEnabled.mockReturnValue(false); - mockLicense.features$ = licenseSubject; - + const statusSubject = new Subject(); const mockCoreSetup = coreMock.createSetup(); - mockCoreSetup.status.core$ = new BehaviorSubject({ - elasticsearch: { level: ServiceStatusLevels.available, summary: 'Service is working' }, - savedObjects: { level: ServiceStatusLevels.available, summary: 'Service is working' }, - }); const authorizationService = new AuthorizationService(); authorizationService.setup({ http: mockCoreSetup.http, capabilities: mockCoreSetup.capabilities, - status: mockCoreSetup.status, clusterClient: mockClusterClient, - license: mockLicense, + license: licenseMock.create(), loggers: loggingSystemMock.create(), kibanaIndexName, packageVersion: 'some-version', @@ -252,12 +207,19 @@ it('#stop unsubscribes from license and ES updates.', () => { const featuresStart = featuresPluginMock.createStart(); featuresStart.getFeatures.mockReturnValue([]); - authorizationService.start({ clusterClient: mockClusterClient, features: featuresStart }); + authorizationService.start({ + clusterClient: mockClusterClient, + features: featuresStart, + online$: statusSubject.asObservable(), + }); authorizationService.stop(); - // After stop we don't register privileges even if all requirements are met. - mockLicense.isEnabled.mockReturnValue(true); - licenseSubject.next(({} as unknown) as SecurityLicenseFeatures); + // After stop we don't register privileges even if status changes. + const retryScheduler = jest.fn(); + statusSubject.next({ scheduleRetry: retryScheduler }); + await nextTick(); + expect(mockRegisterPrivilegesWithCluster).not.toHaveBeenCalled(); + expect(retryScheduler).not.toHaveBeenCalled(); }); diff --git a/x-pack/plugins/security/server/authorization/authorization_service.ts b/x-pack/plugins/security/server/authorization/authorization_service.ts index cae273ecac338..4190499cbd5f4 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.ts @@ -4,16 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { combineLatest, BehaviorSubject, Subscription } from 'rxjs'; -import { distinctUntilChanged, filter } from 'rxjs/operators'; +import { Subscription, Observable } from 'rxjs'; import { UICapabilities } from 'ui/capabilities'; import { LoggerFactory, KibanaRequest, ILegacyClusterClient, - ServiceStatusLevels, Logger, - StatusServiceSetup, HttpServiceSetup, CapabilitiesSetup, } from '../../../../../src/core/server'; @@ -44,6 +41,7 @@ import { validateReservedPrivileges } from './validate_reserved_privileges'; import { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; import { APPLICATION_PREFIX } from '../../common/constants'; import { SecurityLicense } from '../../common/licensing'; +import { OnlineStatusRetryScheduler } from '../elasticsearch'; export { Actions } from './actions'; export { CheckSavedObjectsPrivileges } from './check_saved_objects_privileges'; @@ -52,7 +50,6 @@ export { featurePrivilegeIterator } from './privileges'; interface AuthorizationServiceSetupParams { packageVersion: string; http: HttpServiceSetup; - status: StatusServiceSetup; capabilities: CapabilitiesSetup; clusterClient: ILegacyClusterClient; license: SecurityLicense; @@ -65,6 +62,7 @@ interface AuthorizationServiceSetupParams { interface AuthorizationServiceStartParams { features: FeaturesPluginStart; clusterClient: ILegacyClusterClient; + online$: Observable; } export interface AuthorizationServiceSetup { @@ -79,8 +77,6 @@ export interface AuthorizationServiceSetup { export class AuthorizationService { private logger!: Logger; - private license!: SecurityLicense; - private status!: StatusServiceSetup; private applicationName!: string; private privileges!: PrivilegesService; @@ -89,7 +85,6 @@ export class AuthorizationService { setup({ http, capabilities, - status, packageVersion, clusterClient, license, @@ -99,8 +94,6 @@ export class AuthorizationService { getSpacesService, }: AuthorizationServiceSetupParams): AuthorizationServiceSetup { this.logger = loggers.get('authorization'); - this.license = license; - this.status = status; this.applicationName = `${APPLICATION_PREFIX}${kibanaIndexName}`; const mode = authorizationModeFactory(license); @@ -158,12 +151,23 @@ export class AuthorizationService { return authz; } - start({ clusterClient, features }: AuthorizationServiceStartParams) { + start({ clusterClient, features, online$ }: AuthorizationServiceStartParams) { const allFeatures = features.getFeatures(); validateFeaturePrivileges(allFeatures); validateReservedPrivileges(allFeatures); - this.registerPrivileges(clusterClient); + this.statusSubscription = online$.subscribe(async ({ scheduleRetry }) => { + try { + await registerPrivilegesWithCluster( + this.logger, + this.privileges, + this.applicationName, + clusterClient + ); + } catch (err) { + scheduleRetry(); + } + }); } stop() { @@ -172,50 +176,4 @@ export class AuthorizationService { this.statusSubscription = undefined; } } - - private registerPrivileges(clusterClient: ILegacyClusterClient) { - const RETRY_SCALE_DURATION = 100; - const RETRY_TIMEOUT_MAX = 10000; - const retries$ = new BehaviorSubject(0); - let retryTimeout: NodeJS.Timeout; - - // Register cluster privileges once Elasticsearch is available and Security plugin is enabled. - this.statusSubscription = combineLatest([ - this.status.core$, - this.license.features$, - retries$.asObservable().pipe( - // We shouldn't emit new value if retry counter is reset. This comparator isn't called for - // the initial value. - distinctUntilChanged((prev, curr) => prev === curr || curr === 0) - ), - ]) - .pipe( - filter( - ([status]) => - this.license.isEnabled() && status.elasticsearch.level === ServiceStatusLevels.available - ) - ) - .subscribe(async () => { - // If status or license change occurred before retry timeout we should cancel it. - if (retryTimeout) { - clearTimeout(retryTimeout); - } - - try { - await registerPrivilegesWithCluster( - this.logger, - this.privileges, - this.applicationName, - clusterClient - ); - retries$.next(0); - } catch (err) { - const retriesElapsed = retries$.getValue() + 1; - retryTimeout = setTimeout( - () => retries$.next(retriesElapsed), - Math.min(retriesElapsed * RETRY_SCALE_DURATION, RETRY_TIMEOUT_MAX) - ); - } - }); - } } diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 6ba33b2cccb7c..520081ae30d8d 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -50,6 +50,7 @@ describe('config schema', () => { "loginAssistanceMessage": "", "secureCookies": false, "session": Object { + "cleanupInterval": "PT1H", "idleTimeout": null, "lifespan": null, }, @@ -95,6 +96,7 @@ describe('config schema', () => { "loginAssistanceMessage": "", "secureCookies": false, "session": Object { + "cleanupInterval": "PT1H", "idleTimeout": null, "lifespan": null, }, @@ -139,6 +141,7 @@ describe('config schema', () => { "loginAssistanceMessage": "", "secureCookies": false, "session": Object { + "cleanupInterval": "PT1H", "idleTimeout": null, "lifespan": null, }, @@ -272,9 +275,6 @@ describe('config schema', () => { "saml", ], "saml": Object { - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, "realm": "realm-1", }, "selector": Object {}, @@ -294,13 +294,10 @@ describe('config schema', () => { authc: { providers: ['saml'], saml: { realm: 'realm-1' } }, }).authc.saml ).toMatchInlineSnapshot(` - Object { - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, - "realm": "realm-1", - } - `); + Object { + "realm": "realm-1", + } + `); expect( ConfigSchema.validate({ @@ -665,9 +662,6 @@ describe('config schema', () => { "saml": Object { "saml1": Object { "enabled": true, - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, "order": 0, "realm": "saml1", "showInSelector": true, @@ -685,9 +679,6 @@ describe('config schema', () => { }, "saml3": Object { "enabled": true, - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, "order": 2, "realm": "saml3", "showInSelector": true, @@ -774,9 +765,6 @@ describe('config schema', () => { "saml": Object { "basic1": Object { "enabled": false, - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, "order": 3, "realm": "saml3", "showInSelector": true, @@ -784,9 +772,6 @@ describe('config schema', () => { }, "saml1": Object { "enabled": true, - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, "order": 1, "realm": "saml1", "showInSelector": true, @@ -794,9 +779,6 @@ describe('config schema', () => { }, "saml2": Object { "enabled": true, - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, "order": 2, "realm": "saml2", "showInSelector": true, @@ -807,6 +789,16 @@ describe('config schema', () => { `); }); }); + + describe('session', () => { + it('should throw error if xpack.security.session.cleanupInterval is less than 10 seconds', () => { + expect(() => + ConfigSchema.validate({ session: { cleanupInterval: '9s' } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[session.cleanupInterval]: the value must be greater or equal to 10 seconds."` + ); + }); + }); }); describe('createConfig()', () => { @@ -901,9 +893,6 @@ describe('createConfig()', () => { "saml": Object { "saml": Object { "enabled": true, - "maxRedirectURLSize": ByteSizeValue { - "valueInBytes": 2048, - }, "order": 0, "realm": "saml-realm", "showInSelector": true, diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 051a3d2ab1342..dcfe4825fb035 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -96,7 +96,7 @@ const providersConfigSchema = schema.object( schema.object({ ...getCommonProviderSchemaProperties(), realm: schema.string(), - maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }), + maxRedirectURLSize: schema.maybe(schema.byteSize()), useRelayStateDeepLink: schema.boolean({ defaultValue: false }), }) ) @@ -149,6 +149,14 @@ export const ConfigSchema = schema.object({ session: schema.object({ idleTimeout: schema.nullable(schema.duration()), lifespan: schema.nullable(schema.duration()), + cleanupInterval: schema.duration({ + defaultValue: '1h', + validate(value) { + if (value.asSeconds() < 10) { + return 'the value must be greater or equal to 10 seconds.'; + } + }, + }), }), secureCookies: schema.boolean({ defaultValue: false }), sameSiteCookies: schema.maybe( @@ -181,7 +189,7 @@ export const ConfigSchema = schema.object({ 'saml', schema.object({ realm: schema.string(), - maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }), + maxRedirectURLSize: schema.maybe(schema.byteSize()), }) ), http: schema.object({ diff --git a/x-pack/plugins/security/server/elasticsearch_client_plugin.ts b/x-pack/plugins/security/server/elasticsearch/elasticsearch_client_plugin.ts similarity index 100% rename from x-pack/plugins/security/server/elasticsearch_client_plugin.ts rename to x-pack/plugins/security/server/elasticsearch/elasticsearch_client_plugin.ts diff --git a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts new file mode 100644 index 0000000000000..073b0b6225478 --- /dev/null +++ b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.test.ts @@ -0,0 +1,222 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { BehaviorSubject } from 'rxjs'; +import { + ILegacyCustomClusterClient, + ServiceStatusLevels, + CoreStatus, +} from '../../../../../src/core/server'; +import { SecurityLicense, SecurityLicenseFeatures } from '../../common/licensing'; +import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; +import { ElasticsearchService } from './elasticsearch_service'; + +import { + coreMock, + elasticsearchServiceMock, + loggingSystemMock, +} from '../../../../../src/core/server/mocks'; +import { licenseMock } from '../../common/licensing/index.mock'; +import { nextTick } from 'test_utils/enzyme_helpers'; + +describe('ElasticsearchService', () => { + let service: ElasticsearchService; + beforeEach(() => { + service = new ElasticsearchService(loggingSystemMock.createLogger()); + }); + + describe('setup()', () => { + it('exposes proper contract', () => { + const mockCoreSetup = coreMock.createSetup(); + const mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); + mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); + + expect( + service.setup({ + elasticsearch: mockCoreSetup.elasticsearch, + status: mockCoreSetup.status, + license: licenseMock.create(), + }) + ).toEqual({ clusterClient: mockClusterClient }); + + expect(mockCoreSetup.elasticsearch.legacy.createClient).toHaveBeenCalledTimes(1); + expect(mockCoreSetup.elasticsearch.legacy.createClient).toHaveBeenCalledWith('security', { + plugins: [elasticsearchClientPlugin], + }); + }); + }); + + describe('start()', () => { + let mockClusterClient: ILegacyCustomClusterClient; + let mockLicense: jest.Mocked; + let mockStatusSubject: BehaviorSubject; + let mockLicenseSubject: BehaviorSubject; + beforeEach(() => { + const mockCoreSetup = coreMock.createSetup(); + mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); + mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); + + mockLicenseSubject = new BehaviorSubject(({} as unknown) as SecurityLicenseFeatures); + mockLicense = licenseMock.create(); + mockLicense.isEnabled.mockReturnValue(false); + mockLicense.features$ = mockLicenseSubject; + + mockStatusSubject = new BehaviorSubject({ + elasticsearch: { + level: ServiceStatusLevels.unavailable, + summary: 'Service is NOT working', + }, + savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, + }); + mockCoreSetup.status.core$ = mockStatusSubject; + + service.setup({ + elasticsearch: mockCoreSetup.elasticsearch, + status: mockCoreSetup.status, + license: mockLicense, + }); + }); + + it('exposes proper contract', () => { + expect(service.start()).toEqual({ + clusterClient: mockClusterClient, + watchOnlineStatus$: expect.any(Function), + }); + }); + + it('`watchOnlineStatus$` allows tracking of Elasticsearch status', () => { + const mockHandler = jest.fn(); + service.start().watchOnlineStatus$().subscribe(mockHandler); + + // Neither ES nor license is available yet. + expect(mockHandler).not.toHaveBeenCalled(); + + // ES is available now, but not license. + mockStatusSubject.next({ + elasticsearch: { level: ServiceStatusLevels.available, summary: 'Service is working' }, + savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, + }); + expect(mockHandler).not.toHaveBeenCalled(); + + // Both ES and license are available. + mockLicense.isEnabled.mockReturnValue(true); + mockStatusSubject.next({ + elasticsearch: { level: ServiceStatusLevels.available, summary: 'Service is working' }, + savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, + }); + expect(mockHandler).toHaveBeenCalledTimes(1); + }); + + it('`watchOnlineStatus$` allows to schedule retry', async () => { + jest.useFakeTimers(); + + // Both ES and license are available. + mockLicense.isEnabled.mockReturnValue(true); + mockStatusSubject.next({ + elasticsearch: { level: ServiceStatusLevels.available, summary: 'Service is working' }, + savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, + }); + + const mockHandler = jest.fn(); + service.start().watchOnlineStatus$().subscribe(mockHandler); + expect(mockHandler).toHaveBeenCalledTimes(1); + + const [[{ scheduleRetry }]] = mockHandler.mock.calls; + + // Next retry isn't performed immediately, retry happens only after a timeout. + scheduleRetry(); + await nextTick(); + expect(mockHandler).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(100); + expect(mockHandler).toHaveBeenCalledTimes(2); + + // Delay between consequent retries is increasing. + scheduleRetry(); + await nextTick(); + jest.advanceTimersByTime(100); + expect(mockHandler).toHaveBeenCalledTimes(2); + await nextTick(); + jest.advanceTimersByTime(100); + expect(mockHandler).toHaveBeenCalledTimes(3); + + // Delay between consequent retries is increasing. + scheduleRetry(); + await nextTick(); + jest.advanceTimersByTime(200); + expect(mockHandler).toHaveBeenCalledTimes(3); + await nextTick(); + jest.advanceTimersByTime(100); + expect(mockHandler).toHaveBeenCalledTimes(4); + + // If `scheduleRetry` isn't called retries aren't scheduled anymore. + await nextTick(); + jest.runAllTimers(); + expect(mockHandler).toHaveBeenCalledTimes(4); + + // New changes still trigger handler once again and reset retry timer. + mockLicenseSubject.next(({} as unknown) as SecurityLicenseFeatures); + expect(mockHandler).toHaveBeenCalledTimes(5); + + // Retry timer is reset. + scheduleRetry(); + await nextTick(); + expect(mockHandler).toHaveBeenCalledTimes(5); + jest.advanceTimersByTime(100); + expect(mockHandler).toHaveBeenCalledTimes(6); + }); + + it('`watchOnlineStatus$` cancels scheduled retry if status changes before retry timeout fires', async () => { + jest.useFakeTimers(); + + // Both ES and license are available. + mockLicense.isEnabled.mockReturnValue(true); + mockStatusSubject.next({ + elasticsearch: { level: ServiceStatusLevels.available, summary: 'Service is working' }, + savedObjects: { level: ServiceStatusLevels.unavailable, summary: 'Service is NOT working' }, + }); + + const mockHandler = jest.fn(); + service.start().watchOnlineStatus$().subscribe(mockHandler); + expect(mockHandler).toHaveBeenCalledTimes(1); + + const [[{ scheduleRetry }]] = mockHandler.mock.calls; + + // Schedule a retry. + scheduleRetry(); + await nextTick(); + expect(mockHandler).toHaveBeenCalledTimes(1); + + // New changes should immediately call handler. + mockLicenseSubject.next(({} as unknown) as SecurityLicenseFeatures); + expect(mockHandler).toHaveBeenCalledTimes(2); + + // Retry timeout should have been cancelled. + await nextTick(); + jest.runAllTimers(); + expect(mockHandler).toHaveBeenCalledTimes(2); + }); + }); + + describe('stop()', () => { + it('properly closes cluster client instance', () => { + const mockCoreSetup = coreMock.createSetup(); + const mockClusterClient = elasticsearchServiceMock.createLegacyCustomClusterClient(); + mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); + + service.setup({ + elasticsearch: mockCoreSetup.elasticsearch, + status: mockCoreSetup.status, + license: licenseMock.create(), + }); + + expect(mockClusterClient.close).not.toHaveBeenCalled(); + + service.stop(); + + expect(mockClusterClient.close).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts new file mode 100644 index 0000000000000..42a83b2e5b527 --- /dev/null +++ b/x-pack/plugins/security/server/elasticsearch/elasticsearch_service.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; +import { distinctUntilChanged, filter, map, shareReplay, tap } from 'rxjs/operators'; +import { + ILegacyClusterClient, + ILegacyCustomClusterClient, + Logger, + ServiceStatusLevels, + StatusServiceSetup, + ElasticsearchServiceSetup as CoreElasticsearchServiceSetup, +} from '../../../../../src/core/server'; +import { SecurityLicense } from '../../common/licensing'; +import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; + +export interface ElasticsearchServiceSetupParams { + readonly elasticsearch: CoreElasticsearchServiceSetup; + readonly status: StatusServiceSetup; + readonly license: SecurityLicense; +} + +export interface ElasticsearchServiceSetup { + readonly clusterClient: ILegacyClusterClient; +} + +export interface ElasticsearchServiceStart { + readonly clusterClient: ILegacyClusterClient; + readonly watchOnlineStatus$: () => Observable; +} + +export interface OnlineStatusRetryScheduler { + scheduleRetry: () => void; +} + +/** + * Service responsible for interactions with the Elasticsearch. + */ +export class ElasticsearchService { + readonly #logger: Logger; + #clusterClient?: ILegacyCustomClusterClient; + #coreStatus$!: Observable; + + constructor(logger: Logger) { + this.#logger = logger; + } + + setup({ + elasticsearch, + status, + license, + }: ElasticsearchServiceSetupParams): ElasticsearchServiceSetup { + this.#clusterClient = elasticsearch.legacy.createClient('security', { + plugins: [elasticsearchClientPlugin], + }); + + this.#coreStatus$ = combineLatest([status.core$, license.features$]).pipe( + map( + ([coreStatus]) => + license.isEnabled() && coreStatus.elasticsearch.level === ServiceStatusLevels.available + ), + shareReplay(1) + ); + + return { clusterClient: this.#clusterClient }; + } + + start(): ElasticsearchServiceStart { + return { + clusterClient: this.#clusterClient!, + + // We'll need to get rid of this as soon as Core's Elasticsearch service exposes this + // functionality in the scope of https://github.com/elastic/kibana/issues/41983. + watchOnlineStatus$: () => { + const RETRY_SCALE_DURATION = 100; + const RETRY_TIMEOUT_MAX = 10000; + const retries$ = new BehaviorSubject(0); + + const retryScheduler = { + scheduleRetry: () => { + const retriesElapsed = retries$.getValue() + 1; + const nextRetryTimeout = Math.min( + retriesElapsed * RETRY_SCALE_DURATION, + RETRY_TIMEOUT_MAX + ); + + this.#logger.debug(`Scheduling re-try in ${nextRetryTimeout} ms.`); + + retryTimeout = setTimeout(() => retries$.next(retriesElapsed), nextRetryTimeout); + }, + }; + + let retryTimeout: NodeJS.Timeout; + return combineLatest([ + this.#coreStatus$.pipe( + tap(() => { + // If status or license change occurred before retry timeout we should cancel + // it and reset retry counter. + if (retryTimeout) { + clearTimeout(retryTimeout); + } + + if (retries$.value > 0) { + retries$.next(0); + } + }) + ), + retries$.asObservable().pipe( + // We shouldn't emit new value if retry counter is reset. This comparator isn't called for + // the initial value. + distinctUntilChanged((prev, curr) => prev === curr || curr === 0) + ), + ]).pipe( + filter(([isAvailable]) => isAvailable), + map(() => retryScheduler) + ); + }, + }; + } + + stop() { + if (this.#clusterClient) { + this.#clusterClient.close(); + this.#clusterClient = undefined; + } + } +} diff --git a/x-pack/plugins/security/server/elasticsearch/index.ts b/x-pack/plugins/security/server/elasticsearch/index.ts new file mode 100644 index 0000000000000..793bdc1c6ad26 --- /dev/null +++ b/x-pack/plugins/security/server/elasticsearch/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { + ElasticsearchService, + ElasticsearchServiceSetup, + ElasticsearchServiceStart, + OnlineStatusRetryScheduler, +} from './elasticsearch_service'; diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index d357519c5ccce..00ad962115901 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -36,6 +36,7 @@ export const config: PluginConfigDescriptor> = { deprecations: ({ rename, unused }) => [ rename('sessionTimeout', 'session.idleTimeout'), unused('authorization.legacyFallback.enabled'), + unused('authc.saml.maxRedirectURLSize'), // Deprecation warning for the old array-based format of `xpack.security.authc.providers`. (settings, fromPath, log) => { if (Array.isArray(settings?.xpack?.security?.authc?.providers)) { @@ -65,6 +66,19 @@ export const config: PluginConfigDescriptor> = { } return settings; }, + (settings, fromPath, log) => { + const samlProviders = (settings?.xpack?.security?.authc?.providers?.saml ?? {}) as Record< + string, + any + >; + if (Object.values(samlProviders).find((provider) => !!provider.maxRedirectURLSize)) { + log( + '`xpack.security.authc.providers.saml..maxRedirectURLSize` is deprecated and is no longer used' + ); + } + + return settings; + }, ], exposeToBrowser: { loginAssistanceMessage: true, diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index db015d246f591..8d13f81075714 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -7,10 +7,11 @@ import { of } from 'rxjs'; import { ByteSizeValue } from '@kbn/config-schema'; import { ILegacyCustomClusterClient } from '../../../../src/core/server'; -import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; +import { ConfigSchema } from './config'; import { Plugin, PluginSetupDependencies } from './plugin'; import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/mocks'; +import { taskManagerMock } from '../../task_manager/server/mocks'; describe('Security Plugin', () => { let plugin: Plugin; @@ -19,20 +20,15 @@ describe('Security Plugin', () => { let mockDependencies: PluginSetupDependencies; beforeEach(() => { plugin = new Plugin( - coreMock.createPluginInitializerContext({ - cookieName: 'sid', - session: { - idleTimeout: 1500, - lifespan: null, - }, - audit: { enabled: false }, - authc: { - selector: { enabled: false }, - providers: ['saml', 'token'], - saml: { realm: 'saml1', maxRedirectURLSize: new ByteSizeValue(2048) }, - http: { enabled: true, autoSchemesEnabled: true, schemes: ['apikey'] }, - }, - }) + coreMock.createPluginInitializerContext( + ConfigSchema.validate({ + session: { idleTimeout: 1500 }, + authc: { + providers: ['saml', 'token'], + saml: { realm: 'saml1', maxRedirectURLSize: new ByteSizeValue(2048) }, + }, + }) + ) ); mockCoreSetup = coreMock.createSetup(); @@ -48,6 +44,7 @@ describe('Security Plugin', () => { mockDependencies = ({ licensing: { license$: of({}), featureUsage: { register: jest.fn() } }, + taskManager: taskManagerMock.createSetup(), } as unknown) as PluginSetupDependencies; }); @@ -116,26 +113,13 @@ describe('Security Plugin', () => { } `); }); - - it('properly creates cluster client instance', async () => { - await plugin.setup(mockCoreSetup, mockDependencies); - - expect(mockCoreSetup.elasticsearch.legacy.createClient).toHaveBeenCalledTimes(1); - expect(mockCoreSetup.elasticsearch.legacy.createClient).toHaveBeenCalledWith('security', { - plugins: [elasticsearchClientPlugin], - }); - }); }); describe('stop()', () => { beforeEach(async () => await plugin.setup(mockCoreSetup, mockDependencies)); - it('properly closes cluster client instance', async () => { - expect(mockClusterClient.close).not.toHaveBeenCalled(); - + it('close does not throw', async () => { await plugin.stop(); - - expect(mockClusterClient.close).toHaveBeenCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 1753eb7b62ed1..7d94e03916fa1 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -9,7 +9,6 @@ import { first, map } from 'rxjs/operators'; import { TypeOf } from '@kbn/config-schema'; import { deepFreeze, - ILegacyCustomClusterClient, CoreSetup, CoreStart, Logger, @@ -21,6 +20,7 @@ import { PluginStartContract as FeaturesPluginStart, } from '../../features/server'; import { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/server'; +import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; import { Authentication, setupAuthentication } from './authentication'; import { AuthorizationService, AuthorizationServiceSetup } from './authorization'; @@ -29,8 +29,9 @@ import { defineRoutes } from './routes'; import { SecurityLicenseService, SecurityLicense } from '../common/licensing'; import { setupSavedObjects } from './saved_objects'; import { AuditService, SecurityAuditLogger, AuditServiceSetup } from './audit'; -import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; import { SecurityFeatureUsageService, SecurityFeatureUsageServiceStart } from './feature_usage'; +import { ElasticsearchService } from './elasticsearch'; +import { SessionManagementService } from './session_management'; export type SpacesService = Pick< SpacesPluginSetup['spacesService'], @@ -72,11 +73,13 @@ export interface SecurityPluginSetup { export interface PluginSetupDependencies { features: FeaturesPluginSetup; licensing: LicensingPluginSetup; + taskManager: TaskManagerSetupContract; } export interface PluginStartDependencies { features: FeaturesPluginStart; licensing: LicensingPluginStart; + taskManager: TaskManagerStartContract; } /** @@ -84,7 +87,6 @@ export interface PluginStartDependencies { */ export class Plugin { private readonly logger: Logger; - private clusterClient?: ILegacyCustomClusterClient; private spacesService?: SpacesService | symbol = Symbol('not accessed'); private securityLicenseService?: SecurityLicenseService; @@ -99,6 +101,12 @@ export class Plugin { private readonly auditService = new AuditService(this.initializerContext.logger.get('audit')); private readonly authorizationService = new AuthorizationService(); + private readonly elasticsearchService = new ElasticsearchService( + this.initializerContext.logger.get('elasticsearch') + ); + private readonly sessionManagementService = new SessionManagementService( + this.initializerContext.logger.get('session') + ); private readonly getSpacesService = () => { // Changing property value from Symbol to undefined denotes the fact that property was accessed. @@ -115,7 +123,7 @@ export class Plugin { public async setup( core: CoreSetup, - { features, licensing }: PluginSetupDependencies + { features, licensing, taskManager }: PluginSetupDependencies ) { const [config, legacyConfig] = await combineLatest([ this.initializerContext.config.create>().pipe( @@ -130,35 +138,45 @@ export class Plugin { .pipe(first()) .toPromise(); - this.clusterClient = core.elasticsearch.legacy.createClient('security', { - plugins: [elasticsearchClientPlugin], - }); - this.securityLicenseService = new SecurityLicenseService(); const { license } = this.securityLicenseService.setup({ license$: licensing.license$, }); + const { clusterClient } = this.elasticsearchService.setup({ + elasticsearch: core.elasticsearch, + license, + status: core.status, + }); + this.featureUsageService.setup({ featureUsage: licensing.featureUsage }); const audit = this.auditService.setup({ license, config: config.audit }); const auditLogger = new SecurityAuditLogger(audit.getLogger()); + const { session } = this.sessionManagementService.setup({ + config, + clusterClient, + http: core.http, + kibanaIndexName: legacyConfig.kibana.index, + taskManager, + }); + const authc = await setupAuthentication({ auditLogger, getFeatureUsageService: this.getFeatureUsageService, http: core.http, - clusterClient: this.clusterClient, + clusterClient, config, license, loggers: this.initializerContext.logger, + session, }); const authz = this.authorizationService.setup({ http: core.http, capabilities: core.capabilities, - status: core.status, - clusterClient: this.clusterClient, + clusterClient, license, loggers: this.initializerContext.logger, kibanaIndexName: legacyConfig.kibana.index, @@ -179,11 +197,12 @@ export class Plugin { basePath: core.http.basePath, httpResources: core.http.resources, logger: this.initializerContext.logger.get('routes'), - clusterClient: this.clusterClient, + clusterClient, config, authc, authz, license, + session, getFeatures: () => core .getStartServices() @@ -225,22 +244,22 @@ export class Plugin { }); } - public start(core: CoreStart, { features, licensing }: PluginStartDependencies) { + public start(core: CoreStart, { features, licensing, taskManager }: PluginStartDependencies) { this.logger.debug('Starting plugin'); + this.featureUsageServiceStart = this.featureUsageService.start({ featureUsage: licensing.featureUsage, }); - this.authorizationService.start({ features, clusterClient: this.clusterClient! }); + + const { clusterClient, watchOnlineStatus$ } = this.elasticsearchService.start(); + + this.sessionManagementService.start({ online$: watchOnlineStatus$(), taskManager }); + this.authorizationService.start({ features, clusterClient, online$: watchOnlineStatus$() }); } public stop() { this.logger.debug('Stopping plugin'); - if (this.clusterClient) { - this.clusterClient.close(); - this.clusterClient = undefined; - } - if (this.securityLicenseService) { this.securityLicenseService.stop(); this.securityLicenseService = undefined; @@ -249,8 +268,11 @@ export class Plugin { if (this.featureUsageServiceStart) { this.featureUsageServiceStart = undefined; } + this.auditService.stop(); this.authorizationService.stop(); + this.elasticsearchService.stop(); + this.sessionManagementService.stop(); } private wasSpacesServiceAccessed() { diff --git a/x-pack/plugins/security/server/routes/authentication/basic.test.ts b/x-pack/plugins/security/server/routes/authentication/basic.test.ts deleted file mode 100644 index 944bc567de586..0000000000000 --- a/x-pack/plugins/security/server/routes/authentication/basic.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Type } from '@kbn/config-schema'; -import { - IRouter, - kibanaResponseFactory, - RequestHandler, - RequestHandlerContext, - RouteConfig, -} from '../../../../../../src/core/server'; -import { Authentication, AuthenticationResult } from '../../authentication'; -import { defineBasicRoutes } from './basic'; - -import { httpServerMock } from '../../../../../../src/core/server/mocks'; -import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; -import { routeDefinitionParamsMock } from '../index.mock'; - -describe('Basic authentication routes', () => { - let router: jest.Mocked; - let authc: jest.Mocked; - let mockContext: RequestHandlerContext; - beforeEach(() => { - const routeParamsMock = routeDefinitionParamsMock.create(); - router = routeParamsMock.router; - - authc = routeParamsMock.authc; - authc.isProviderTypeEnabled.mockImplementation((provider) => provider === 'basic'); - - mockContext = ({ - licensing: { - license: { check: jest.fn().mockReturnValue({ check: 'valid' }) }, - }, - } as unknown) as RequestHandlerContext; - - defineBasicRoutes(routeParamsMock); - }); - - describe('login', () => { - let routeHandler: RequestHandler; - let routeConfig: RouteConfig; - - const mockRequest = httpServerMock.createKibanaRequest({ - body: { username: 'user', password: 'password' }, - }); - - beforeEach(() => { - const [loginRouteConfig, loginRouteHandler] = router.post.mock.calls.find( - ([{ path }]) => path === '/internal/security/login' - )!; - - routeConfig = loginRouteConfig; - routeHandler = loginRouteHandler; - }); - - it('correctly defines route.', async () => { - expect(routeConfig.options).toEqual({ authRequired: false }); - expect(routeConfig.validate).toEqual({ - body: expect.any(Type), - query: undefined, - params: undefined, - }); - - const bodyValidator = (routeConfig.validate as any).body as Type; - expect(bodyValidator.validate({ username: 'user', password: 'password' })).toEqual({ - username: 'user', - password: 'password', - }); - - expect(() => bodyValidator.validate({})).toThrowErrorMatchingInlineSnapshot( - `"[username]: expected value of type [string] but got [undefined]"` - ); - expect(() => bodyValidator.validate({ username: 'user' })).toThrowErrorMatchingInlineSnapshot( - `"[password]: expected value of type [string] but got [undefined]"` - ); - expect(() => - bodyValidator.validate({ password: 'password' }) - ).toThrowErrorMatchingInlineSnapshot( - `"[username]: expected value of type [string] but got [undefined]"` - ); - expect(() => - bodyValidator.validate({ username: '', password: '' }) - ).toThrowErrorMatchingInlineSnapshot( - `"[username]: value has length [0] but it must have a minimum length of [1]."` - ); - expect(() => - bodyValidator.validate({ username: 'user', password: '' }) - ).toThrowErrorMatchingInlineSnapshot( - `"[password]: value has length [0] but it must have a minimum length of [1]."` - ); - expect(() => - bodyValidator.validate({ username: '', password: 'password' }) - ).toThrowErrorMatchingInlineSnapshot( - `"[username]: value has length [0] but it must have a minimum length of [1]."` - ); - }); - - it('returns 500 if authentication throws unhandled exception.', async () => { - const unhandledException = new Error('Something went wrong.'); - authc.login.mockRejectedValue(unhandledException); - - const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); - - expect(response.status).toBe(500); - expect(response.payload).toEqual(unhandledException); - expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: { type: 'basic' }, - value: { username: 'user', password: 'password' }, - }); - }); - - it('returns 401 if authentication fails.', async () => { - const failureReason = new Error('Something went wrong.'); - authc.login.mockResolvedValue(AuthenticationResult.failed(failureReason)); - - const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); - - expect(response.status).toBe(401); - expect(response.payload).toEqual(failureReason); - expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: { type: 'basic' }, - value: { username: 'user', password: 'password' }, - }); - }); - - it('returns 401 if authentication is not handled.', async () => { - authc.login.mockResolvedValue(AuthenticationResult.notHandled()); - - const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); - - expect(response.status).toBe(401); - expect(response.payload).toEqual('Unauthorized'); - expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: { type: 'basic' }, - value: { username: 'user', password: 'password' }, - }); - }); - - describe('authentication succeeds', () => { - it(`returns user data`, async () => { - authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); - - const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); - - expect(response.status).toBe(204); - expect(response.payload).toBeUndefined(); - expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: { type: 'basic' }, - value: { username: 'user', password: 'password' }, - }); - }); - - it('prefers `token` authentication provider if it is enabled', async () => { - authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); - authc.isProviderTypeEnabled.mockImplementation( - (provider) => provider === 'token' || provider === 'basic' - ); - - const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); - - expect(response.status).toBe(204); - expect(response.payload).toBeUndefined(); - expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: { type: 'token' }, - value: { username: 'user', password: 'password' }, - }); - }); - }); - }); -}); diff --git a/x-pack/plugins/security/server/routes/authentication/basic.ts b/x-pack/plugins/security/server/routes/authentication/basic.ts deleted file mode 100644 index ccc6a8df24d6e..0000000000000 --- a/x-pack/plugins/security/server/routes/authentication/basic.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema } from '@kbn/config-schema'; -import { wrapIntoCustomErrorResponse } from '../../errors'; -import { createLicensedRouteHandler } from '../licensed_route_handler'; -import { RouteDefinitionParams } from '..'; - -/** - * Defines routes required for Basic/Token authentication. - */ -export function defineBasicRoutes({ router, authc, config }: RouteDefinitionParams) { - router.post( - { - path: '/internal/security/login', - validate: { - body: schema.object({ - username: schema.string({ minLength: 1 }), - password: schema.string({ minLength: 1 }), - }), - }, - options: { authRequired: false }, - }, - createLicensedRouteHandler(async (context, request, response) => { - // We should prefer `token` over `basic` if possible. - const loginAttempt = { - provider: { type: authc.isProviderTypeEnabled('token') ? 'token' : 'basic' }, - value: request.body, - }; - - try { - const authenticationResult = await authc.login(request, loginAttempt); - if (!authenticationResult.succeeded()) { - return response.unauthorized({ body: authenticationResult.error }); - } - - return response.noContent(); - } catch (error) { - return response.customError(wrapIntoCustomErrorResponse(error)); - } - }) - ); -} diff --git a/x-pack/plugins/security/server/routes/authentication/common.test.ts b/x-pack/plugins/security/server/routes/authentication/common.test.ts index 5a0401e6320b4..8d800595d28ed 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.test.ts @@ -181,12 +181,12 @@ describe('Common authentication routes', () => { }); }); - describe('login_with', () => { + describe('login', () => { let routeHandler: RequestHandler; let routeConfig: RouteConfig; beforeEach(() => { const [acsRouteConfig, acsRouteHandler] = router.post.mock.calls.find( - ([{ path }]) => path === '/internal/security/login_with' + ([{ path }]) => path === '/internal/security/login' )!; routeConfig = acsRouteConfig; @@ -226,6 +226,39 @@ describe('Common authentication routes', () => { currentURL: '', }); + for (const [providerType, providerName] of [ + ['basic', 'basic1'], + ['token', 'token1'], + ]) { + expect( + bodyValidator.validate({ + providerType, + providerName, + currentURL: '', + params: { username: 'some-user', password: 'some-password' }, + }) + ).toEqual({ + providerType, + providerName, + currentURL: '', + params: { username: 'some-user', password: 'some-password' }, + }); + + expect( + bodyValidator.validate({ + providerType, + providerName, + currentURL: '/some-url', + params: { username: 'some-user', password: 'some-password' }, + }) + ).toEqual({ + providerType, + providerName, + currentURL: '/some-url', + params: { username: 'some-user', password: 'some-password' }, + }); + } + expect(() => bodyValidator.validate({})).toThrowErrorMatchingInlineSnapshot( `"[providerType]: expected value of type [string] but got [undefined]"` ); @@ -250,6 +283,123 @@ describe('Common authentication routes', () => { UnknownArg: 'arg', }) ).toThrowErrorMatchingInlineSnapshot(`"[UnknownArg]: definition for this key is missing"`); + + expect(() => + bodyValidator.validate({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '/some-url', + params: { username: 'some-user', password: 'some-password' }, + }) + ).toThrowErrorMatchingInlineSnapshot(`"[params]: a value wasn't expected to be present"`); + + expect(() => + bodyValidator.validate({ + providerType: 'basic', + providerName: 'basic1', + currentURL: '/some-url', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.username]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'basic', + providerName: 'basic1', + currentURL: '/some-url', + params: { username: 'some-user' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.password]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'basic', + providerName: 'basic1', + currentURL: '/some-url', + params: { password: 'some-password' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.username]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'basic', + providerName: 'basic1', + currentURL: '/some-url', + params: { username: '', password: 'some-password' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.username]: value has length [0] but it must have a minimum length of [1]."` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'basic', + providerName: 'basic1', + currentURL: '/some-url', + params: { username: 'some-user', password: '' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.password]: value has length [0] but it must have a minimum length of [1]."` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'token', + providerName: 'token1', + currentURL: '/some-url', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.username]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'token', + providerName: 'token1', + currentURL: '/some-url', + params: { username: 'some-user' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.password]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'token', + providerName: 'token1', + currentURL: '/some-url', + params: { password: 'some-password' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.username]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'token', + providerName: 'token1', + currentURL: '/some-url', + params: { username: '', password: 'some-password' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.username]: value has length [0] but it must have a minimum length of [1]."` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'token', + providerName: 'token1', + currentURL: '/some-url', + params: { username: 'some-user', password: '' }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[params.password]: value has length [0] but it must have a minimum length of [1]."` + ); }); it('returns 500 if login throws unhandled exception.', async () => { @@ -378,10 +528,10 @@ describe('Common authentication routes', () => { expect(authc.login).toHaveBeenCalledTimes(1); expect(authc.login).toHaveBeenCalledWith(request, { provider: { name: 'saml1' }, + redirectURL: '/mock-server-basepath/some-url#/app/nav', value: { type: SAMLLogin.LoginInitiatedByUser, - redirectURLPath: '/mock-server-basepath/some-url', - redirectURLFragment: '#/app/nav', + redirectURL: '/mock-server-basepath/some-url#/app/nav', }, }); }); @@ -406,13 +556,66 @@ describe('Common authentication routes', () => { expect(authc.login).toHaveBeenCalledTimes(1); expect(authc.login).toHaveBeenCalledWith(request, { provider: { name: 'oidc1' }, + redirectURL: '/mock-server-basepath/some-url#/app/nav', value: { type: OIDCLogin.LoginInitiatedByUser, - redirectURLPath: '/mock-server-basepath/some-url', + redirectURL: '/mock-server-basepath/some-url#/app/nav', }, }); }); + it('correctly performs Basic login.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const request = httpServerMock.createKibanaRequest({ + body: { + providerType: 'basic', + providerName: 'basic1', + currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav', + params: { username: 'some-user', password: 'some-password' }, + }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: 'http://redirect-to/path' }, + options: { body: { location: 'http://redirect-to/path' } }, + }); + + expect(authc.login).toHaveBeenCalledTimes(1); + expect(authc.login).toHaveBeenCalledWith(request, { + provider: { name: 'basic1' }, + redirectURL: '/mock-server-basepath/some-url#/app/nav', + value: { username: 'some-user', password: 'some-password' }, + }); + }); + + it('correctly performs Token login.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const request = httpServerMock.createKibanaRequest({ + body: { + providerType: 'token', + providerName: 'token1', + currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav', + params: { username: 'some-user', password: 'some-password' }, + }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: 'http://redirect-to/path' }, + options: { body: { location: 'http://redirect-to/path' } }, + }); + + expect(authc.login).toHaveBeenCalledTimes(1); + expect(authc.login).toHaveBeenCalledWith(request, { + provider: { name: 'token1' }, + redirectURL: '/mock-server-basepath/some-url#/app/nav', + value: { username: 'some-user', password: 'some-password' }, + }); + }); + it('correctly performs generic login.', async () => { authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); @@ -433,6 +636,7 @@ describe('Common authentication routes', () => { expect(authc.login).toHaveBeenCalledTimes(1); expect(authc.login).toHaveBeenCalledWith(request, { provider: { name: 'some-name' }, + redirectURL: '/mock-server-basepath/some-url#/app/nav', }); }); }); diff --git a/x-pack/plugins/security/server/routes/authentication/common.ts b/x-pack/plugins/security/server/routes/authentication/common.ts index ad38a158af2b9..a37f20c9ef82c 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.ts @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; +import { schema, TypeOf } from '@kbn/config-schema'; import { parseNext } from '../../../common/parse_next'; -import { canRedirectRequest, OIDCLogin, SAMLLogin } from '../../authentication'; -import { wrapIntoCustomErrorResponse } from '../../errors'; -import { createLicensedRouteHandler } from '../licensed_route_handler'; import { + canRedirectRequest, + OIDCLogin, + SAMLLogin, + BasicAuthenticationProvider, OIDCAuthenticationProvider, SAMLAuthenticationProvider, -} from '../../authentication/providers'; + TokenAuthenticationProvider, +} from '../../authentication'; +import { wrapIntoCustomErrorResponse } from '../../errors'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; import { RouteDefinitionParams } from '..'; /** @@ -83,19 +87,29 @@ export function defineCommonRoutes({ ); } - function getLoginAttemptForProviderType(providerType: string, redirectURL: string) { - const [redirectURLPath] = redirectURL.split('#'); - const redirectURLFragment = - redirectURL.length > redirectURLPath.length - ? redirectURL.substring(redirectURLPath.length) - : ''; + const basicParamsSchema = schema.object({ + username: schema.string({ minLength: 1 }), + password: schema.string({ minLength: 1 }), + }); + function getLoginAttemptForProviderType( + providerType: T, + redirectURL: string, + params: T extends 'basic' | 'token' ? TypeOf : {} + ) { if (providerType === SAMLAuthenticationProvider.type) { - return { type: SAMLLogin.LoginInitiatedByUser, redirectURLPath, redirectURLFragment }; + return { type: SAMLLogin.LoginInitiatedByUser, redirectURL }; } if (providerType === OIDCAuthenticationProvider.type) { - return { type: OIDCLogin.LoginInitiatedByUser, redirectURLPath }; + return { type: OIDCLogin.LoginInitiatedByUser, redirectURL }; + } + + if ( + providerType === BasicAuthenticationProvider.type || + providerType === TokenAuthenticationProvider.type + ) { + return params; } return undefined; @@ -103,25 +117,35 @@ export function defineCommonRoutes({ router.post( { - path: '/internal/security/login_with', + path: '/internal/security/login', validate: { body: schema.object({ providerType: schema.string(), providerName: schema.string(), currentURL: schema.string(), + params: schema.conditional( + schema.siblingRef('providerType'), + schema.oneOf([ + schema.literal(BasicAuthenticationProvider.type), + schema.literal(TokenAuthenticationProvider.type), + ]), + basicParamsSchema, + schema.never() + ), }), }, options: { authRequired: false }, }, createLicensedRouteHandler(async (context, request, response) => { - const { providerType, providerName, currentURL } = request.body; + const { providerType, providerName, currentURL, params } = request.body; logger.info(`Logging in with provider "${providerName}" (${providerType})`); const redirectURL = parseNext(currentURL, basePath.serverBasePath); try { const authenticationResult = await authc.login(request, { provider: { name: providerName }, - value: getLoginAttemptForProviderType(providerType, redirectURL), + redirectURL, + value: getLoginAttemptForProviderType(providerType, redirectURL, params), }); if (authenticationResult.redirected() || authenticationResult.succeeded()) { diff --git a/x-pack/plugins/security/server/routes/authentication/index.ts b/x-pack/plugins/security/server/routes/authentication/index.ts index d09f65525f44e..6527fd0220584 100644 --- a/x-pack/plugins/security/server/routes/authentication/index.ts +++ b/x-pack/plugins/security/server/routes/authentication/index.ts @@ -4,21 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { defineSessionRoutes } from './session'; import { defineSAMLRoutes } from './saml'; -import { defineBasicRoutes } from './basic'; import { defineCommonRoutes } from './common'; import { defineOIDCRoutes } from './oidc'; import { RouteDefinitionParams } from '..'; export function defineAuthenticationRoutes(params: RouteDefinitionParams) { - defineSessionRoutes(params); defineCommonRoutes(params); - if (params.authc.isProviderTypeEnabled('basic') || params.authc.isProviderTypeEnabled('token')) { - defineBasicRoutes(params); - } - if (params.authc.isProviderTypeEnabled('saml')) { defineSAMLRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/authentication/saml.ts b/x-pack/plugins/security/server/routes/authentication/saml.ts index ce7516c2c9d88..58ec7f559bc28 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.ts @@ -12,79 +12,7 @@ import { RouteDefinitionParams } from '..'; /** * Defines routes required for SAML authentication. */ -export function defineSAMLRoutes({ - router, - httpResources, - logger, - authc, - basePath, -}: RouteDefinitionParams) { - httpResources.register( - { - path: '/internal/security/saml/capture-url-fragment', - validate: false, - options: { authRequired: false }, - }, - (context, request, response) => { - // We're also preventing `favicon.ico` request since it can cause new SAML handshake. - return response.renderHtml({ - body: ` - - Kibana SAML Login - - - `, - }); - } - ); - httpResources.register( - { - path: '/internal/security/saml/capture-url-fragment.js', - validate: false, - options: { authRequired: false }, - }, - (context, request, response) => { - return response.renderJs({ - body: ` - window.location.replace( - '${basePath.serverBasePath}/internal/security/saml/start?redirectURLFragment=' + encodeURIComponent(window.location.hash) - ); - `, - }); - } - ); - - router.get( - { - path: '/internal/security/saml/start', - validate: { - query: schema.object({ redirectURLFragment: schema.string() }), - }, - options: { authRequired: false }, - }, - async (context, request, response) => { - try { - const authenticationResult = await authc.login(request, { - provider: { type: SAMLAuthenticationProvider.type }, - value: { - type: SAMLLogin.LoginInitiatedByUser, - redirectURLFragment: request.query.redirectURLFragment, - }, - }); - - // When authenticating using SAML we _expect_ to redirect to the SAML Identity provider. - if (authenticationResult.redirected()) { - return response.redirected({ headers: { location: authenticationResult.redirectURL! } }); - } - - return response.unauthorized(); - } catch (err) { - logger.error(err); - return response.internalError(); - } - } - ); - +export function defineSAMLRoutes({ router, logger, authc }: RouteDefinitionParams) { router.post( { path: '/api/security/saml/callback', diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index 24de2af5e9703..b4698708f86fe 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -14,6 +14,7 @@ import { authenticationMock } from '../authentication/index.mock'; import { authorizationMock } from '../authorization/index.mock'; import { ConfigSchema, createConfig } from '../config'; import { licenseMock } from '../../common/licensing/index.mock'; +import { sessionMock } from '../session_management/session.mock'; export const routeDefinitionParamsMock = { create: (config: Record = {}) => ({ @@ -31,5 +32,6 @@ export const routeDefinitionParamsMock = { httpResources: httpResourcesMock.createRegistrar(), getFeatures: jest.fn(), getFeatureUsageService: jest.fn(), + session: sessionMock.create(), }), }; diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index 82c0186898d38..a3f046ae4f9e6 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -6,8 +6,8 @@ import { Feature } from '../../../features/server'; import { - CoreSetup, HttpResources, + IBasePath, ILegacyClusterClient, IRouter, Logger, @@ -23,21 +23,24 @@ import { defineApiKeysRoutes } from './api_keys'; import { defineIndicesRoutes } from './indices'; import { defineUsersRoutes } from './users'; import { defineRoleMappingRoutes } from './role_mapping'; +import { defineSessionManagementRoutes } from './session_management'; import { defineViewRoutes } from './views'; import { SecurityFeatureUsageServiceStart } from '../feature_usage'; +import { Session } from '../session_management'; /** * Describes parameters used to define HTTP routes. */ export interface RouteDefinitionParams { router: IRouter; - basePath: CoreSetup['http']['basePath']; + basePath: IBasePath; httpResources: HttpResources; logger: Logger; clusterClient: ILegacyClusterClient; config: ConfigType; authc: Authentication; authz: AuthorizationServiceSetup; + session: PublicMethodsOf; license: SecurityLicense; getFeatures: () => Promise; getFeatureUsageService: () => SecurityFeatureUsageServiceStart; @@ -46,6 +49,7 @@ export interface RouteDefinitionParams { export function defineRoutes(params: RouteDefinitionParams) { defineAuthenticationRoutes(params); defineAuthorizationRoutes(params); + defineSessionManagementRoutes(params); defineApiKeysRoutes(params); defineIndicesRoutes(params); defineUsersRoutes(params); diff --git a/x-pack/plugins/security/server/routes/session_management/extend.test.ts b/x-pack/plugins/security/server/routes/session_management/extend.test.ts new file mode 100644 index 0000000000000..235fce152510c --- /dev/null +++ b/x-pack/plugins/security/server/routes/session_management/extend.test.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 { + IRouter, + kibanaResponseFactory, + RequestHandler, + RequestHandlerContext, + RouteConfig, +} from '../../../../../../src/core/server'; +import { defineSessionExtendRoutes } from './extend'; + +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../index.mock'; + +describe('Extend session routes', () => { + let router: jest.Mocked; + beforeEach(() => { + const routeParamsMock = routeDefinitionParamsMock.create(); + router = routeParamsMock.router; + + defineSessionExtendRoutes(routeParamsMock); + }); + + describe('extend session', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [extendRouteConfig, extendRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => path === '/internal/security/session' + )!; + + routeConfig = extendRouteConfig; + routeHandler = extendRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toBeUndefined(); + expect(routeConfig.validate).toBe(false); + }); + + it('always returns 302.', async () => { + await expect( + routeHandler( + ({} as unknown) as RequestHandlerContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ) + ).resolves.toEqual({ + status: 302, + options: { headers: { location: '/mock-server-basepath/internal/security/session' } }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/authentication/session.ts b/x-pack/plugins/security/server/routes/session_management/extend.ts similarity index 57% rename from x-pack/plugins/security/server/routes/authentication/session.ts rename to x-pack/plugins/security/server/routes/session_management/extend.ts index cdebc19d7cf8d..722636aa9934a 100644 --- a/x-pack/plugins/security/server/routes/authentication/session.ts +++ b/x-pack/plugins/security/server/routes/session_management/extend.ts @@ -7,26 +7,9 @@ import { RouteDefinitionParams } from '..'; /** - * Defines routes required for all authentication realms. + * Defines routes required for the session extension. */ -export function defineSessionRoutes({ router, logger, authc, basePath }: RouteDefinitionParams) { - router.get( - { - path: '/internal/security/session', - validate: false, - }, - async (_context, request, response) => { - try { - const sessionInfo = await authc.getSessionInfo(request); - // This is an authenticated request, so sessionInfo will always be non-null. - return response.ok({ body: sessionInfo! }); - } catch (err) { - logger.error(`Error retrieving user session: ${err.message}`); - return response.internalError(); - } - } - ); - +export function defineSessionExtendRoutes({ router, basePath }: RouteDefinitionParams) { router.post( { path: '/internal/security/session', diff --git a/x-pack/plugins/security/server/routes/session_management/index.ts b/x-pack/plugins/security/server/routes/session_management/index.ts new file mode 100644 index 0000000000000..aeed027972ed0 --- /dev/null +++ b/x-pack/plugins/security/server/routes/session_management/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { defineSessionExtendRoutes } from './extend'; +import { defineSessionInfoRoutes } from './info'; +import { RouteDefinitionParams } from '..'; + +export function defineSessionManagementRoutes(params: RouteDefinitionParams) { + defineSessionInfoRoutes(params); + defineSessionExtendRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/session_management/info.test.ts b/x-pack/plugins/security/server/routes/session_management/info.test.ts new file mode 100644 index 0000000000000..fa9cba61df018 --- /dev/null +++ b/x-pack/plugins/security/server/routes/session_management/info.test.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + IRouter, + kibanaResponseFactory, + RequestHandler, + RequestHandlerContext, + RouteConfig, +} from '../../../../../../src/core/server'; +import { Session } from '../../session_management'; +import { defineSessionInfoRoutes } from './info'; + +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { sessionMock } from '../../session_management/session.mock'; +import { routeDefinitionParamsMock } from '../index.mock'; + +describe('Info session routes', () => { + let router: jest.Mocked; + let session: jest.Mocked>; + beforeEach(() => { + const routeParamsMock = routeDefinitionParamsMock.create(); + router = routeParamsMock.router; + session = routeParamsMock.session; + + defineSessionInfoRoutes(routeParamsMock); + }); + + describe('extend session', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [extendRouteConfig, extendRouteHandler] = router.get.mock.calls.find( + ([{ path }]) => path === '/internal/security/session' + )!; + + routeConfig = extendRouteConfig; + routeHandler = extendRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toBeUndefined(); + expect(routeConfig.validate).toBe(false); + }); + + it('returns 500 if unhandled exception is thrown when session is retrieved.', async () => { + const unhandledException = new Error('Something went wrong.'); + session.get.mockRejectedValue(unhandledException); + + const request = httpServerMock.createKibanaRequest(); + await expect( + routeHandler(({} as unknown) as RequestHandlerContext, request, kibanaResponseFactory) + ).resolves.toEqual({ + status: 500, + options: {}, + payload: 'Internal Error', + }); + + expect(session.get).toHaveBeenCalledWith(request); + }); + + it('returns session info.', async () => { + session.get.mockResolvedValue( + sessionMock.createValue({ idleTimeoutExpiration: 100, lifespanExpiration: 200 }) + ); + + const dateSpy = jest.spyOn(Date, 'now'); + dateSpy.mockReturnValue(1234); + + const expectedBody = { + now: 1234, + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: 100, + lifespanExpiration: 200, + }; + await expect( + routeHandler( + ({} as unknown) as RequestHandlerContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ) + ).resolves.toEqual({ + status: 200, + payload: expectedBody, + options: { body: expectedBody }, + }); + }); + + it('returns empty response if session is not available.', async () => { + session.get.mockResolvedValue(null); + + await expect( + routeHandler( + ({} as unknown) as RequestHandlerContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ) + ).resolves.toEqual({ status: 204, options: {} }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/session_management/info.ts b/x-pack/plugins/security/server/routes/session_management/info.ts new file mode 100644 index 0000000000000..381127284f780 --- /dev/null +++ b/x-pack/plugins/security/server/routes/session_management/info.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 { SessionInfo } from '../../../common/types'; +import { RouteDefinitionParams } from '..'; + +/** + * Defines routes required for the session info. + */ +export function defineSessionInfoRoutes({ router, logger, session }: RouteDefinitionParams) { + router.get( + { path: '/internal/security/session', validate: false }, + async (_context, request, response) => { + try { + const sessionValue = await session.get(request); + if (sessionValue) { + return response.ok({ + body: { + // We can't rely on the client's system clock, so in addition to returning expiration timestamps, we also return + // the current server time -- that way the client can calculate the relative time to expiration. + now: Date.now(), + idleTimeoutExpiration: sessionValue.idleTimeoutExpiration, + lifespanExpiration: sessionValue.lifespanExpiration, + provider: sessionValue.provider, + } as SessionInfo, + }); + } + + return response.noContent(); + } catch (err) { + logger.error(`Error retrieving user session: ${err.message}`); + return response.internalError(); + } + } + ); +} diff --git a/x-pack/plugins/security/server/routes/users/change_password.test.ts b/x-pack/plugins/security/server/routes/users/change_password.test.ts index 21c7fc1340437..51bee1c74afa7 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.test.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.test.ts @@ -17,15 +17,18 @@ import { ScopeableRequest, } from '../../../../../../src/core/server'; import { Authentication, AuthenticationResult } from '../../authentication'; +import { Session } from '../../session_management'; import { defineChangeUserPasswordRoutes } from './change_password'; import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { sessionMock } from '../../session_management/session.mock'; import { routeDefinitionParamsMock } from '../index.mock'; describe('Change password', () => { let router: jest.Mocked; let authc: jest.Mocked; + let session: jest.Mocked>; let mockClusterClient: jest.Mocked; let mockScopedClusterClient: jest.Mocked; let routeHandler: RequestHandler; @@ -46,15 +49,11 @@ describe('Change password', () => { const routeParamsMock = routeDefinitionParamsMock.create(); router = routeParamsMock.router; authc = routeParamsMock.authc; + session = routeParamsMock.session; - authc.getCurrentUser.mockReturnValue(mockAuthenticatedUser({ username: 'user' })); + authc.getCurrentUser.mockReturnValue(mockAuthenticatedUser(mockAuthenticatedUser())); authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); - authc.getSessionInfo.mockResolvedValue({ - now: Date.now(), - idleTimeoutExpiration: null, - lifespanExpiration: null, - provider: { type: 'basic', name: 'basic' }, - }); + session.get.mockResolvedValue(sessionMock.createValue()); mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); mockClusterClient = routeParamsMock.clusterClient; @@ -199,6 +198,9 @@ describe('Change password', () => { }); authc.getCurrentUser.mockReturnValue(mockUser); authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockUser)); + session.get.mockResolvedValue( + sessionMock.createValue({ provider: { type: 'token', name: 'token1' } }) + ); const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); @@ -220,7 +222,7 @@ describe('Change password', () => { }); it('successfully changes own password but does not re-login if current session does not exist.', async () => { - authc.getSessionInfo.mockResolvedValue(null); + session.get.mockResolvedValue(null); const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); expect(response.status).toBe(204); diff --git a/x-pack/plugins/security/server/routes/users/change_password.ts b/x-pack/plugins/security/server/routes/users/change_password.ts index e915cd8759ff1..be868f841eeeb 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.ts @@ -16,6 +16,7 @@ import { RouteDefinitionParams } from '..'; export function defineChangeUserPasswordRoutes({ authc, + session, router, clusterClient, }: RouteDefinitionParams) { @@ -37,7 +38,7 @@ export function defineChangeUserPasswordRoutes({ const currentUser = authc.getCurrentUser(request); const isUserChangingOwnPassword = currentUser && currentUser.username === username && canUserChangePassword(currentUser); - const currentSession = isUserChangingOwnPassword ? await authc.getSessionInfo(request) : null; + const currentSession = isUserChangingOwnPassword ? await session.get(request) : null; // If user is changing their own password they should provide a proof of knowledge their // current password via sending it in `Authorization: Basic base64(username:current password)` @@ -81,7 +82,7 @@ export function defineChangeUserPasswordRoutes({ if (isUserChangingOwnPassword && currentSession) { try { const authenticationResult = await authc.login(request, { - provider: { name: currentUser!.authentication_provider }, + provider: { name: currentSession.provider.name }, value: { username, password: newPassword }, }); diff --git a/x-pack/plugins/security/server/routes/views/access_agreement.test.ts b/x-pack/plugins/security/server/routes/views/access_agreement.test.ts index 3d616575b8413..9b73358223b3d 100644 --- a/x-pack/plugins/security/server/routes/views/access_agreement.test.ts +++ b/x-pack/plugins/security/server/routes/views/access_agreement.test.ts @@ -16,24 +16,25 @@ import { import { SecurityLicense, SecurityLicenseFeatures } from '../../../common/licensing'; import { AuthenticationProvider } from '../../../common/types'; import { ConfigType } from '../../config'; +import { Session } from '../../session_management'; import { defineAccessAgreementRoutes } from './access_agreement'; import { httpResourcesMock, httpServerMock } from '../../../../../../src/core/server/mocks'; +import { sessionMock } from '../../session_management/session.mock'; import { routeDefinitionParamsMock } from '../index.mock'; -import { Authentication } from '../../authentication'; describe('Access agreement view routes', () => { let httpResources: jest.Mocked; let router: jest.Mocked; let config: ConfigType; - let authc: jest.Mocked; + let session: jest.Mocked>; let license: jest.Mocked; let mockContext: RequestHandlerContext; beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); router = routeParamsMock.router; httpResources = routeParamsMock.httpResources; - authc = routeParamsMock.authc; + session = routeParamsMock.session; config = routeParamsMock.config; license = routeParamsMock.license; @@ -125,7 +126,7 @@ describe('Access agreement view routes', () => { it('returns empty `accessAgreement` if session info is not available.', async () => { const request = httpServerMock.createKibanaRequest(); - authc.getSessionInfo.mockResolvedValue(null); + session.get.mockResolvedValue(null); await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ options: { body: { accessAgreement: '' } }, @@ -159,12 +160,7 @@ describe('Access agreement view routes', () => { ]; for (const [sessionProvider, expectedAccessAgreement] of cases) { - authc.getSessionInfo.mockResolvedValue({ - now: Date.now(), - idleTimeoutExpiration: null, - lifespanExpiration: null, - provider: sessionProvider, - }); + session.get.mockResolvedValue(sessionMock.createValue({ provider: sessionProvider })); await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ options: { body: { accessAgreement: expectedAccessAgreement } }, diff --git a/x-pack/plugins/security/server/routes/views/access_agreement.ts b/x-pack/plugins/security/server/routes/views/access_agreement.ts index 49e1ff42a28a2..80a1c2a20cf59 100644 --- a/x-pack/plugins/security/server/routes/views/access_agreement.ts +++ b/x-pack/plugins/security/server/routes/views/access_agreement.ts @@ -12,7 +12,7 @@ import { RouteDefinitionParams } from '..'; * Defines routes required for the Access Agreement view. */ export function defineAccessAgreementRoutes({ - authc, + session, httpResources, license, config, @@ -46,12 +46,12 @@ export function defineAccessAgreementRoutes({ // authenticated with the help of HTTP authentication), that means we should safely check if // we have it and can get a corresponding configuration. try { - const session = await authc.getSessionInfo(request); + const sessionValue = await session.get(request); const accessAgreement = - (session && + (sessionValue && config.authc.providers[ - session.provider.type as keyof ConfigType['authc']['providers'] - ]?.[session.provider.name]?.accessAgreement?.message) || + sessionValue.provider.type as keyof ConfigType['authc']['providers'] + ]?.[sessionValue.provider.name]?.accessAgreement?.message) || ''; return response.ok({ body: { accessAgreement } }); diff --git a/x-pack/plugins/security/server/routes/views/capture_url.test.ts b/x-pack/plugins/security/server/routes/views/capture_url.test.ts new file mode 100644 index 0000000000000..2b2aab3407eb3 --- /dev/null +++ b/x-pack/plugins/security/server/routes/views/capture_url.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Type } from '@kbn/config-schema'; +import { + RouteConfig, + HttpResources, + HttpResourcesRequestHandler, + RequestHandlerContext, +} from '../../../../../../src/core/server'; +import { defineCaptureURLRoutes } from './capture_url'; + +import { httpResourcesMock, httpServerMock } from '../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../index.mock'; + +describe('Capture URL view routes', () => { + let httpResources: jest.Mocked; + beforeEach(() => { + const routeParamsMock = routeDefinitionParamsMock.create(); + httpResources = routeParamsMock.httpResources; + + defineCaptureURLRoutes(routeParamsMock); + }); + + let routeHandler: HttpResourcesRequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [viewRouteConfig, viewRouteHandler] = httpResources.register.mock.calls.find( + ([{ path }]) => path === '/internal/security/capture-url' + )!; + + routeConfig = viewRouteConfig; + routeHandler = viewRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toEqual({ authRequired: false }); + + expect(routeConfig.validate).toEqual({ + body: undefined, + query: expect.any(Type), + params: undefined, + }); + + const queryValidator = (routeConfig.validate as any).query as Type; + expect( + queryValidator.validate({ providerType: 'basic', providerName: 'basic1', next: '/some-url' }) + ).toEqual({ providerType: 'basic', providerName: 'basic1', next: '/some-url' }); + + expect(queryValidator.validate({ providerType: 'basic', providerName: 'basic1' })).toEqual({ + providerType: 'basic', + providerName: 'basic1', + }); + + expect(() => queryValidator.validate({ providerType: '' })).toThrowErrorMatchingInlineSnapshot( + `"[providerType]: value has length [0] but it must have a minimum length of [1]."` + ); + + expect(() => + queryValidator.validate({ providerType: 'basic' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[providerName]: expected value of type [string] but got [undefined]"` + ); + + expect(() => queryValidator.validate({ providerName: '' })).toThrowErrorMatchingInlineSnapshot( + `"[providerType]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + queryValidator.validate({ providerName: 'basic1' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[providerType]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + queryValidator.validate({ providerType: 'basic', providerName: '' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[providerName]: value has length [0] but it must have a minimum length of [1]."` + ); + + expect(() => + queryValidator.validate({ providerType: '', providerName: 'basic1' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[providerType]: value has length [0] but it must have a minimum length of [1]."` + ); + }); + + it('renders view.', async () => { + const request = httpServerMock.createKibanaRequest(); + const responseFactory = httpResourcesMock.createResponseFactory(); + + await routeHandler(({} as unknown) as RequestHandlerContext, request, responseFactory); + + expect(responseFactory.renderAnonymousCoreApp).toHaveBeenCalledWith(); + }); +}); diff --git a/x-pack/plugins/security/server/routes/views/capture_url.ts b/x-pack/plugins/security/server/routes/views/capture_url.ts new file mode 100644 index 0000000000000..690c68dcd59aa --- /dev/null +++ b/x-pack/plugins/security/server/routes/views/capture_url.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDefinitionParams } from '..'; + +/** + * Defines routes required for the Capture URL view. + */ +export function defineCaptureURLRoutes({ httpResources }: RouteDefinitionParams) { + httpResources.register( + { + path: '/internal/security/capture-url', + validate: { + query: schema.object({ + providerType: schema.string({ minLength: 1 }), + providerName: schema.string({ minLength: 1 }), + next: schema.maybe(schema.string()), + }), + }, + options: { authRequired: false }, + }, + (context, request, response) => response.renderAnonymousCoreApp() + ); +} diff --git a/x-pack/plugins/security/server/routes/views/index.test.ts b/x-pack/plugins/security/server/routes/views/index.test.ts index 0c0117dec5390..fa2088a80b183 100644 --- a/x-pack/plugins/security/server/routes/views/index.test.ts +++ b/x-pack/plugins/security/server/routes/views/index.test.ts @@ -25,6 +25,7 @@ describe('View routes', () => { "/security/logged_out", "/logout", "/security/overwritten_session", + "/internal/security/capture-url", ] `); expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` @@ -51,6 +52,7 @@ describe('View routes', () => { "/security/logged_out", "/logout", "/security/overwritten_session", + "/internal/security/capture-url", ] `); expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` @@ -78,6 +80,7 @@ describe('View routes', () => { "/security/logged_out", "/logout", "/security/overwritten_session", + "/internal/security/capture-url", ] `); expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` @@ -105,6 +108,7 @@ describe('View routes', () => { "/security/logged_out", "/logout", "/security/overwritten_session", + "/internal/security/capture-url", ] `); expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` diff --git a/x-pack/plugins/security/server/routes/views/index.ts b/x-pack/plugins/security/server/routes/views/index.ts index b9de58d47fe40..64d288dfc7c7d 100644 --- a/x-pack/plugins/security/server/routes/views/index.ts +++ b/x-pack/plugins/security/server/routes/views/index.ts @@ -10,6 +10,7 @@ import { defineLoggedOutRoutes } from './logged_out'; import { defineLoginRoutes } from './login'; import { defineLogoutRoutes } from './logout'; import { defineOverwrittenSessionRoutes } from './overwritten_session'; +import { defineCaptureURLRoutes } from './capture_url'; import { RouteDefinitionParams } from '..'; export function defineViewRoutes(params: RouteDefinitionParams) { @@ -26,4 +27,5 @@ export function defineViewRoutes(params: RouteDefinitionParams) { defineLoggedOutRoutes(params); defineLogoutRoutes(params); defineOverwrittenSessionRoutes(params); + defineCaptureURLRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/views/logged_out.test.ts b/x-pack/plugins/security/server/routes/views/logged_out.test.ts index 7cb73c49f9cbc..c160c4a26a187 100644 --- a/x-pack/plugins/security/server/routes/views/logged_out.test.ts +++ b/x-pack/plugins/security/server/routes/views/logged_out.test.ts @@ -5,19 +5,20 @@ */ import { HttpResourcesRequestHandler, RouteConfig } from '../../../../../../src/core/server'; -import { Authentication } from '../../authentication'; +import { Session } from '../../session_management'; import { defineLoggedOutRoutes } from './logged_out'; import { httpServerMock, httpResourcesMock } from '../../../../../../src/core/server/mocks'; +import { sessionMock } from '../../session_management/session.mock'; import { routeDefinitionParamsMock } from '../index.mock'; describe('LoggedOut view routes', () => { - let authc: jest.Mocked; + let session: jest.Mocked>; let routeHandler: HttpResourcesRequestHandler; let routeConfig: RouteConfig; beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); - authc = routeParamsMock.authc; + session = routeParamsMock.session; defineLoggedOutRoutes(routeParamsMock); @@ -38,12 +39,7 @@ describe('LoggedOut view routes', () => { }); it('redirects user to the root page if they have a session already.', async () => { - authc.getSessionInfo.mockResolvedValue({ - provider: { type: 'basic', name: 'basic' }, - now: 0, - idleTimeoutExpiration: null, - lifespanExpiration: null, - }); + session.get.mockResolvedValue(sessionMock.createValue()); const request = httpServerMock.createKibanaRequest(); @@ -54,17 +50,17 @@ describe('LoggedOut view routes', () => { headers: { location: '/mock-server-basepath/' }, }); - expect(authc.getSessionInfo).toHaveBeenCalledWith(request); + expect(session.get).toHaveBeenCalledWith(request); }); it('renders view if user does not have an active session.', async () => { - authc.getSessionInfo.mockResolvedValue(null); + session.get.mockResolvedValue(null); const request = httpServerMock.createKibanaRequest(); const responseFactory = httpResourcesMock.createResponseFactory(); await routeHandler({} as any, request, responseFactory); - expect(authc.getSessionInfo).toHaveBeenCalledWith(request); + expect(session.get).toHaveBeenCalledWith(request); expect(responseFactory.renderAnonymousCoreApp).toHaveBeenCalledWith(); }); }); diff --git a/x-pack/plugins/security/server/routes/views/logged_out.ts b/x-pack/plugins/security/server/routes/views/logged_out.ts index 43c2f01b1b53d..b35154e6a0f2a 100644 --- a/x-pack/plugins/security/server/routes/views/logged_out.ts +++ b/x-pack/plugins/security/server/routes/views/logged_out.ts @@ -17,7 +17,7 @@ import { RouteDefinitionParams } from '..'; */ export function defineLoggedOutRoutes({ logger, - authc, + session, httpResources, basePath, }: RouteDefinitionParams) { @@ -30,7 +30,7 @@ export function defineLoggedOutRoutes({ async (context, request, response) => { // Authentication flow isn't triggered automatically for this route, so we should explicitly // check whether user has an active session already. - const isUserAlreadyLoggedIn = (await authc.getSessionInfo(request)) !== null; + const isUserAlreadyLoggedIn = (await session.get(request)) !== null; if (isUserAlreadyLoggedIn) { logger.debug('User is already authenticated, redirecting...'); return response.redirected({ diff --git a/x-pack/plugins/security/server/session_management/index.mock.ts b/x-pack/plugins/security/server/session_management/index.mock.ts new file mode 100644 index 0000000000000..ea7e77071d136 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/index.mock.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 { sessionMock } from './session.mock'; +export { sessionCookieMock } from './session_cookie.mock'; +export { sessionIndexMock } from './session_index.mock'; diff --git a/x-pack/plugins/security/server/session_management/index.ts b/x-pack/plugins/security/server/session_management/index.ts new file mode 100644 index 0000000000000..ee7ed914947a0 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/index.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. + */ + +export { Session, SessionValue } from './session'; +export { + SessionManagementServiceSetup, + SessionManagementService, +} from './session_management_service'; diff --git a/x-pack/plugins/security/server/session_management/session.mock.ts b/x-pack/plugins/security/server/session_management/session.mock.ts new file mode 100644 index 0000000000000..c09d24ba315c8 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session.mock.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { Session, SessionValue } from './session'; +import { sessionIndexMock } from './session_index.mock'; + +export const sessionMock = { + create: (): jest.Mocked> => ({ + get: jest.fn(), + create: jest.fn(), + update: jest.fn(), + extend: jest.fn(), + clear: jest.fn(), + }), + + createValue: (sessionValue: Partial = {}): SessionValue => ({ + sid: 'some-long-sid', + username: mockAuthenticatedUser().username, + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: null, + lifespanExpiration: null, + state: undefined, + metadata: { index: sessionIndexMock.createValue(sessionValue.metadata?.index) }, + ...sessionValue, + }), +}; diff --git a/x-pack/plugins/security/server/session_management/session.test.ts b/x-pack/plugins/security/server/session_management/session.test.ts new file mode 100644 index 0000000000000..c4d2342df36dc --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session.test.ts @@ -0,0 +1,794 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import crypto from 'crypto'; +import nodeCrypto from '@elastic/node-crypto'; +import { ConfigSchema, createConfig } from '../config'; +import { Session, SessionValueContentToEncrypt } from './session'; +import { SessionIndex } from './session_index'; +import { SessionCookie } from './session_cookie'; + +import { loggingSystemMock, httpServerMock } from '../../../../../src/core/server/mocks'; +import { sessionMock, sessionCookieMock, sessionIndexMock } from './index.mock'; +import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; + +describe('Session', () => { + const now = 123456; + const mockEncryptionKey = 'a'.repeat(32); + const encryptContent = (contentToEncrypt: SessionValueContentToEncrypt, aad: string) => + nodeCrypto({ encryptionKey: mockEncryptionKey }).encrypt(JSON.stringify(contentToEncrypt), aad); + + let mockSessionIndex: jest.Mocked>; + let mockSessionCookie: jest.Mocked>; + let session: Session; + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => now); + + let callCount = 0; + jest.spyOn(crypto, 'randomBytes').mockImplementation((num, callback) => { + // We still need _some_ randomness here to distinguish generated bytes for SID and AAD. + const buffer = Buffer.from([++callCount, ...Array(num - 1).keys()]); + if (typeof callback !== 'function') { + return buffer; + } + callback(null, buffer); + }); + + mockSessionCookie = sessionCookieMock.create(); + mockSessionIndex = sessionIndexMock.create(); + + session = new Session({ + logger: loggingSystemMock.createLogger(), + config: createConfig( + ConfigSchema.validate({ + encryptionKey: mockEncryptionKey, + session: { idleTimeout: 123, lifespan: 456 }, + }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + sessionCookie: mockSessionCookie, + sessionIndex: mockSessionIndex, + }); + }); + + describe('#get', () => { + const mockAAD = Buffer.from([2, ...Array(255).keys()]).toString('base64'); + + it('returns `null` if session cookie does not exist', async () => { + mockSessionCookie.get.mockResolvedValue(null); + mockSessionIndex.get.mockResolvedValue( + sessionIndexMock.createValue({ + content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + }) + ); + + await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull(); + }); + + it('clears session value if session is expired because of idle timeout', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + aad: mockAAD, + idleTimeoutExpiration: now - 1, + lifespanExpiration: now + 1, + }) + ); + mockSessionIndex.get.mockResolvedValue( + sessionIndexMock.createValue({ + content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + }) + ); + + await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull(); + expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); + expect(mockSessionIndex.clear).toHaveBeenCalledTimes(1); + }); + + it('clears session value if session is expired because of lifespan', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + aad: mockAAD, + idleTimeoutExpiration: now + 1, + lifespanExpiration: now - 1, + }) + ); + mockSessionIndex.get.mockResolvedValue( + sessionIndexMock.createValue({ + content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + }) + ); + + await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull(); + expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); + expect(mockSessionIndex.clear).toHaveBeenCalledTimes(1); + }); + + it('clears session value if session cookie does not have corresponding session index value', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + aad: mockAAD, + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + }) + ); + mockSessionIndex.get.mockResolvedValue(null); + + await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull(); + expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); + }); + + it('clears session value if session index value content cannot be decrypted', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + aad: mockAAD, + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + }) + ); + mockSessionIndex.get.mockResolvedValue(sessionIndexMock.createValue({ content: 'Uh! Oh!' })); + + await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull(); + expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); + expect(mockSessionIndex.clear).toHaveBeenCalledTimes(1); + }); + + it('clears session value if session index value content cannot be decrypted because of wrong AAD', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + aad: 'some-wrong-aad', + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + }) + ); + mockSessionIndex.get.mockResolvedValue( + sessionIndexMock.createValue({ + content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + }) + ); + + await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull(); + expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); + expect(mockSessionIndex.clear).toHaveBeenCalledTimes(1); + }); + + it('returns session value with decrypted content', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + aad: mockAAD, + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + }) + ); + + const mockSessionIndexValue = sessionIndexMock.createValue({ + idleTimeoutExpiration: now - 1, + lifespanExpiration: now + 1, + content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + }); + mockSessionIndex.get.mockResolvedValue(mockSessionIndexValue); + + await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toEqual({ + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + metadata: { index: mockSessionIndexValue }, + provider: { name: 'basic1', type: 'basic' }, + sid: 'some-long-sid', + state: 'some-state', + username: 'some-user', + }); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + }); + }); + + describe('#create', () => { + it('creates session value', async () => { + const mockSID = Buffer.from([1, ...Array(31).keys()]).toString('base64'); + const mockAAD = Buffer.from([2, ...Array(31).keys()]).toString('base64'); + + const mockSessionIndexValue = sessionIndexMock.createValue({ + sid: mockSID, + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 456, + }); + mockSessionIndex.create.mockResolvedValue(mockSessionIndexValue); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.create(mockRequest, { + username: mockAuthenticatedUser().username, + provider: { type: 'basic', name: 'basic1' }, + state: 'some-state', + }) + ).resolves.toEqual({ + sid: mockSID, + username: 'user', + state: 'some-state', + provider: { name: 'basic1', type: 'basic' }, + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 456, + metadata: { index: mockSessionIndexValue }, + }); + + // Properly creates session index value. + expect(mockSessionIndex.create).toHaveBeenCalledTimes(1); + expect(mockSessionIndex.create).toHaveBeenCalledWith({ + sid: mockSID, + content: + 'AwABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9PgQAAQIDBAUGBwgJCpgMitlj6jACf9fYYa66WkuUpJsdgbWevIEfo6mN827f0lGcKDNPzN+vDMMPFetOkRITDI+NMz7e3JcMofnDboRnvg==', + provider: { name: 'basic1', type: 'basic' }, + usernameHash: '8ac76453d769d4fd14b3f41ad4933f9bd64321972cd002de9b847e117435b08b', + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 456, + }); + + // Properly creates session cookie value. + expect(mockSessionCookie.set).toHaveBeenCalledTimes(1); + expect(mockSessionCookie.set).toHaveBeenCalledWith(mockRequest, { + sid: mockSID, + aad: mockAAD, + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 456, + }); + }); + }); + + describe('#update', () => { + const mockAAD = Buffer.from([2, ...Array(255).keys()]).toString('base64'); + + it('returns `null` if there is no corresponding session cookie value', async () => { + mockSessionCookie.get.mockResolvedValue(null); + // To make sure we aren't even calling this method. + mockSessionIndex.update.mockResolvedValue( + sessionIndexMock.createValue({ + content: await encryptContent({ username: 'some-user', state: 'some-state' }, mockAAD), + }) + ); + + await expect( + session.update(httpServerMock.createKibanaRequest(), sessionMock.createValue()) + ).resolves.toBeNull(); + }); + + it('returns `null` and clears cookie if there is no corresponding session index value', async () => { + mockSessionCookie.get.mockResolvedValue(sessionCookieMock.createValue()); + mockSessionIndex.update.mockResolvedValue(null); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect(session.update(mockRequest, sessionMock.createValue())).resolves.toBeNull(); + + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); + expect(mockSessionCookie.clear).toHaveBeenCalledWith(mockRequest); + }); + + it('updates session value', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + aad: mockAAD, + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + }) + ); + + const mockSessionIndexValue = sessionIndexMock.createValue({ + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 1, + metadata: { primaryTerm: 2, sequenceNumber: 2 }, + }); + mockSessionIndex.update.mockResolvedValue(mockSessionIndexValue); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.update( + mockRequest, + sessionMock.createValue({ + username: 'new-user', + state: 'new-state', + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 1, + }) + ) + ).resolves.toEqual({ + sid: 'some-long-sid', + username: 'new-user', + state: 'new-state', + provider: { name: 'basic1', type: 'basic' }, + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 1, + metadata: { index: mockSessionIndexValue }, + }); + + // Properly updates session index value. + expect(mockSessionIndex.update).toHaveBeenCalledTimes(1); + expect(mockSessionIndex.update).toHaveBeenCalledWith({ + sid: 'some-long-sid', + content: + 'AQABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9PgIAAQIDBAUGBwgJCt8yPPMsaNAxn7qtLtc57UN967e9FpjmJgEIipe6nD20F47TtNIZnAuzd75zc8TNWvPMgRTzpHnYz7cT9m5ouv2V8TZ+ow==', + provider: { name: 'basic1', type: 'basic' }, + usernameHash: '35133597af273830c3f139c72501e676338f28a39dca8ff62d5c2b8bfba75f69', + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 1, + metadata: { primaryTerm: 1, sequenceNumber: 1 }, + }); + + // Properly updates session cookie value. + expect(mockSessionCookie.set).toHaveBeenCalledTimes(1); + expect(mockSessionCookie.set).toHaveBeenCalledWith(mockRequest, { + sid: 'some-long-sid', + aad: mockAAD, + path: '/mock-base-path', + idleTimeoutExpiration: now + 123, + lifespanExpiration: now + 1, + }); + }); + + it('properly extends session expiration if idle timeout is defined.', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ aad: mockAAD, idleTimeoutExpiration: now + 1 }) + ); + mockSessionIndex.update.mockResolvedValue( + sessionIndexMock.createValue({ + idleTimeoutExpiration: now + 123, + }) + ); + + session = new Session({ + logger: loggingSystemMock.createLogger(), + config: createConfig( + ConfigSchema.validate({ session: { idleTimeout: 123 } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + sessionCookie: mockSessionCookie, + sessionIndex: mockSessionIndex, + }); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.update(mockRequest, sessionMock.createValue({ idleTimeoutExpiration: now + 1 })) + ).resolves.toEqual( + expect.objectContaining({ idleTimeoutExpiration: now + 123, lifespanExpiration: null }) + ); + expect(mockSessionIndex.update).toHaveBeenCalledWith( + expect.objectContaining({ idleTimeoutExpiration: now + 123, lifespanExpiration: null }) + ); + expect(mockSessionCookie.set).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ idleTimeoutExpiration: now + 123, lifespanExpiration: null }) + ); + }); + + describe('conditionally updates the session lifespan expiration', () => { + const hr = 1000 * 60 * 60; + async function updateSession( + lifespan: number | null, + oldExpiration: number | null, + newExpiration: number | null + ) { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ aad: mockAAD, lifespanExpiration: oldExpiration }) + ); + mockSessionIndex.update.mockResolvedValue( + sessionIndexMock.createValue({ lifespanExpiration: newExpiration }) + ); + + session = new Session({ + logger: loggingSystemMock.createLogger(), + config: createConfig( + ConfigSchema.validate({ session: { lifespan } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + sessionCookie: mockSessionCookie, + sessionIndex: mockSessionIndex, + }); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.update( + mockRequest, + sessionMock.createValue({ lifespanExpiration: oldExpiration }) + ) + ).resolves.toEqual( + expect.objectContaining({ + idleTimeoutExpiration: null, + lifespanExpiration: newExpiration, + }) + ); + expect(mockSessionIndex.update).toHaveBeenCalledWith( + expect.objectContaining({ + idleTimeoutExpiration: null, + lifespanExpiration: newExpiration, + }) + ); + expect(mockSessionCookie.set).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ + idleTimeoutExpiration: null, + lifespanExpiration: newExpiration, + }) + ); + + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + } + + it('does not change a non-null lifespan expiration when configured to non-null value.', async () => { + await updateSession(hr * 8, 1234, 1234); + }); + + it('does not change a null lifespan expiration when configured to null value.', async () => { + await updateSession(null, null, null); + }); + + it('does change a non-null lifespan expiration when configured to null value.', async () => { + await updateSession(null, 1234, null); + }); + + it('does change a null lifespan expiration when configured to non-null value', async () => { + await updateSession(hr * 8, null, now + hr * 8); + }); + }); + }); + + describe('#extend', () => { + it('returns `null` if there is no corresponding session cookie value', async () => { + mockSessionCookie.get.mockResolvedValue(null); + + await expect( + session.extend(httpServerMock.createKibanaRequest(), sessionMock.createValue()) + ).resolves.toBeNull(); + + expect(mockSessionCookie.set).not.toHaveBeenCalled(); + expect(mockSessionIndex.update).not.toHaveBeenCalled(); + }); + + it('returns specified session unmodified if neither idle timeout nor lifespan is specified', async () => { + mockSessionCookie.get.mockResolvedValue(sessionCookieMock.createValue()); + mockSessionIndex.update.mockResolvedValue(sessionIndexMock.createValue()); + + session = new Session({ + logger: loggingSystemMock.createLogger(), + config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { + isTLSEnabled: false, + }), + sessionCookie: mockSessionCookie, + sessionIndex: mockSessionIndex, + }); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect(session.extend(mockRequest, sessionMock.createValue())).resolves.toEqual( + sessionMock.createValue() + ); + expect(mockSessionIndex.update).not.toHaveBeenCalled(); + expect(mockSessionCookie.set).not.toHaveBeenCalled(); + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + }); + + it('properly extends session expiration if both idle timeout and lifespan are defined.', async () => { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 2, + }) + ); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.extend( + mockRequest, + sessionMock.createValue({ + idleTimeoutExpiration: now + 1, + lifespanExpiration: now + 2, + metadata: { + index: sessionIndexMock.createValue({ + idleTimeoutExpiration: now - 123, + lifespanExpiration: now + 2, + }), + }, + }) + ) + ).resolves.toEqual( + expect.objectContaining({ idleTimeoutExpiration: now + 123, lifespanExpiration: now + 2 }) + ); + expect(mockSessionIndex.update).not.toHaveBeenCalled(); + expect(mockSessionCookie.set).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ idleTimeoutExpiration: now + 123, lifespanExpiration: now + 2 }) + ); + }); + + describe('updates the session idle timeout expiration', () => { + beforeEach(() => { + session = new Session({ + logger: loggingSystemMock.createLogger(), + config: createConfig( + ConfigSchema.validate({ session: { idleTimeout: 123 } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + sessionCookie: mockSessionCookie, + sessionIndex: mockSessionIndex, + }); + }); + + it('does not update session index value if idle timeout is below threshold.', async () => { + const expectedNewExpiration = now + 123; + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ idleTimeoutExpiration: now + 1 }) + ); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.extend( + mockRequest, + sessionMock.createValue({ + idleTimeoutExpiration: expectedNewExpiration - 123, + metadata: { + index: sessionIndexMock.createValue({ + idleTimeoutExpiration: expectedNewExpiration - 2 * 123, + }), + }, + }) + ) + ).resolves.toEqual( + expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) + ); + expect(mockSessionIndex.update).not.toHaveBeenCalled(); + expect(mockSessionCookie.set).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) + ); + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + }); + + it('returns `null` and clears cookie if session index value does not exist.', async () => { + const expectedNewExpiration = now + 123; + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ idleTimeoutExpiration: now + 1 }) + ); + mockSessionIndex.update.mockResolvedValue(null); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.extend( + mockRequest, + sessionMock.createValue({ idleTimeoutExpiration: expectedNewExpiration - 2 * 123 - 1 }) + ) + ).resolves.toBeNull(); + + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); + expect(mockSessionCookie.clear).toHaveBeenCalledWith(mockRequest); + }); + + it('updates session index value if idle timeout exceeds threshold.', async () => { + const expectedNewExpiration = now + 123; + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ idleTimeoutExpiration: now + 1 }) + ); + + const mockSessionIndexValue = sessionIndexMock.createValue({ + idleTimeoutExpiration: expectedNewExpiration, + metadata: { primaryTerm: 2, sequenceNumber: 2 }, + }); + mockSessionIndex.update.mockResolvedValue(mockSessionIndexValue); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.extend( + mockRequest, + sessionMock.createValue({ + idleTimeoutExpiration: expectedNewExpiration - 123, + metadata: { + index: sessionIndexMock.createValue({ + idleTimeoutExpiration: expectedNewExpiration - 2 * 123 - 1, + }), + }, + }) + ) + ).resolves.toEqual( + expect.objectContaining({ + idleTimeoutExpiration: expectedNewExpiration, + metadata: { index: mockSessionIndexValue }, + }) + ); + expect(mockSessionIndex.update).toHaveBeenCalledWith( + expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) + ); + expect(mockSessionCookie.set).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) + ); + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + }); + + it('updates session index value if idle timeout was not configured before.', async () => { + const expectedNewExpiration = now + 123; + mockSessionCookie.get.mockResolvedValue(sessionCookieMock.createValue()); + + const mockSessionIndexValue = sessionIndexMock.createValue({ + idleTimeoutExpiration: expectedNewExpiration, + metadata: { primaryTerm: 2, sequenceNumber: 2 }, + }); + mockSessionIndex.update.mockResolvedValue(mockSessionIndexValue); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect(session.extend(mockRequest, sessionMock.createValue())).resolves.toEqual( + expect.objectContaining({ + idleTimeoutExpiration: expectedNewExpiration, + metadata: { index: mockSessionIndexValue }, + }) + ); + expect(mockSessionIndex.update).toHaveBeenCalledWith( + expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) + ); + expect(mockSessionCookie.set).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) + ); + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + }); + + it('updates session index value if idle timeout is not configured anymore.', async () => { + const expectedNewExpiration = null; + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ idleTimeoutExpiration: now + 1 }) + ); + + const mockSessionIndexValue = sessionIndexMock.createValue({ + idleTimeoutExpiration: expectedNewExpiration, + metadata: { primaryTerm: 2, sequenceNumber: 2 }, + }); + mockSessionIndex.update.mockResolvedValue(mockSessionIndexValue); + + session = new Session({ + logger: loggingSystemMock.createLogger(), + config: createConfig( + ConfigSchema.validate({ session: { idleTimeout: null } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + sessionCookie: mockSessionCookie, + sessionIndex: mockSessionIndex, + }); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.extend(mockRequest, sessionMock.createValue({ idleTimeoutExpiration: now + 1 })) + ).resolves.toEqual( + expect.objectContaining({ + idleTimeoutExpiration: expectedNewExpiration, + metadata: { index: mockSessionIndexValue }, + }) + ); + expect(mockSessionIndex.update).toHaveBeenCalledWith( + expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) + ); + expect(mockSessionCookie.set).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ idleTimeoutExpiration: expectedNewExpiration }) + ); + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + }); + }); + + describe('conditionally updates the session lifespan expiration', () => { + const hr = 1000 * 60 * 60; + async function updateSession( + lifespan: number | null, + oldExpiration: number | null, + newExpiration: number | null + ) { + mockSessionCookie.get.mockResolvedValue( + sessionCookieMock.createValue({ lifespanExpiration: oldExpiration }) + ); + mockSessionIndex.update.mockResolvedValue( + sessionIndexMock.createValue({ lifespanExpiration: newExpiration }) + ); + + session = new Session({ + logger: loggingSystemMock.createLogger(), + config: createConfig( + ConfigSchema.validate({ session: { lifespan } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + sessionCookie: mockSessionCookie, + sessionIndex: mockSessionIndex, + }); + + const mockRequest = httpServerMock.createKibanaRequest(); + await expect( + session.extend( + mockRequest, + sessionMock.createValue({ lifespanExpiration: oldExpiration }) + ) + ).resolves.toEqual( + expect.objectContaining({ + idleTimeoutExpiration: null, + lifespanExpiration: newExpiration, + }) + ); + + if (oldExpiration === newExpiration) { + expect(mockSessionIndex.update).not.toHaveBeenCalled(); + expect(mockSessionCookie.set).not.toHaveBeenCalled(); + } else { + // We update session index only when lifespan configuration changes. + if (oldExpiration === null || newExpiration === null) { + expect(mockSessionIndex.update).toHaveBeenCalledWith( + expect.objectContaining({ + idleTimeoutExpiration: null, + lifespanExpiration: newExpiration, + }) + ); + } else { + expect(mockSessionIndex.update).not.toHaveBeenCalled(); + } + + expect(mockSessionCookie.set).toHaveBeenCalledWith( + mockRequest, + expect.objectContaining({ + idleTimeoutExpiration: null, + lifespanExpiration: newExpiration, + }) + ); + } + + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + } + + it('does not change a non-null lifespan expiration when configured to non-null value.', async () => { + await updateSession(hr * 8, 1234, 1234); + }); + + it('does not change a null lifespan expiration when configured to null value.', async () => { + await updateSession(null, null, null); + }); + + it('does change a non-null lifespan expiration when configured to null value.', async () => { + await updateSession(null, 1234, null); + }); + + it('does change a null lifespan expiration when configured to non-null value', async () => { + await updateSession(hr * 8, null, now + hr * 8); + }); + }); + }); + + describe('#clear', () => { + it('does not clear anything if session does not exist', async () => { + mockSessionCookie.get.mockResolvedValue(null); + + await session.clear(httpServerMock.createKibanaRequest()); + + expect(mockSessionIndex.clear).not.toHaveBeenCalled(); + expect(mockSessionCookie.clear).not.toHaveBeenCalled(); + }); + + it('clears both session cookie and session index', async () => { + mockSessionCookie.get.mockResolvedValue(sessionCookieMock.createValue()); + + const mockRequest = httpServerMock.createKibanaRequest(); + await session.clear(mockRequest); + + expect(mockSessionIndex.clear).toHaveBeenCalledTimes(1); + expect(mockSessionIndex.clear).toHaveBeenCalledWith('some-long-sid'); + + expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1); + expect(mockSessionCookie.clear).toHaveBeenCalledWith(mockRequest); + }); + }); +}); diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts new file mode 100644 index 0000000000000..57c6509147665 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -0,0 +1,414 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import nodeCrypto, { Crypto } from '@elastic/node-crypto'; +import { promisify } from 'util'; +import { randomBytes, createHash } from 'crypto'; +import { Duration } from 'moment'; +import { KibanaRequest, Logger } from '../../../../../src/core/server'; +import { AuthenticationProvider } from '../../common/types'; +import { ConfigType } from '../config'; +import { SessionIndex, SessionIndexValue } from './session_index'; +import { SessionCookie } from './session_cookie'; + +/** + * The shape of the value that represents user's session information. + */ +export interface SessionValue { + /** + * Unique session ID. + */ + sid: string; + + /** + * Username this session belongs. It's defined only if session is authenticated, otherwise session + * is considered unauthenticated (e.g. intermediate session used during SSO handshake). + */ + username?: string; + + /** + * Name and type of the provider this session belongs to. + */ + provider: AuthenticationProvider; + + /** + * The Unix time in ms when the session should be considered expired. If `null`, session will stay + * active until the browser is closed. + */ + idleTimeoutExpiration: number | null; + + /** + * The Unix time in ms which is the max total lifespan of the session. If `null`, session expire + * time can be extended indefinitely. + */ + lifespanExpiration: number | null; + + /** + * Session value that is fed to the authentication provider. The shape is unknown upfront and + * entirely determined by the authentication provider that owns the current session. + */ + state: unknown; + + /** + * Indicates whether user acknowledged access agreement or not. + */ + accessAgreementAcknowledged?: boolean; + + /** + * Additional information about the session value. + */ + metadata: { index: SessionIndexValue }; +} + +export interface SessionOptions { + readonly logger: Logger; + readonly sessionIndex: PublicMethodsOf; + readonly sessionCookie: PublicMethodsOf; + readonly config: Pick; +} + +export interface SessionValueContentToEncrypt { + username?: string; + state: unknown; +} + +/** + * The SIDs and AAD must be unpredictable to prevent guessing attacks, where an attacker is able to + * guess or predict the ID of a valid session through statistical analysis techniques. That's why we + * generate SIDs and AAD using a secure PRNG and current OWASP guidance suggests a minimum of 16 + * bytes (128 bits), but to be on the safe side we decided to use 32 bytes (256 bits). + */ +const SID_BYTE_LENGTH = 32; +const AAD_BYTE_LENGTH = 32; + +export class Session { + /** + * Session idle timeout in ms. If `null`, a session will stay active until its max lifespan is reached. + */ + private readonly idleTimeout: Duration | null; + + /** + * Timeout after which idle timeout property is updated in the index. + */ + private readonly idleIndexUpdateTimeout: number | null; + + /** + * Session max lifespan in ms. If `null` session may live indefinitely. + */ + private readonly lifespan: Duration | null; + + /** + * Used to encrypt and decrypt portion of the session value using configured encryption key. + */ + private readonly crypto: Crypto; + + /** + * Promise-based version of the NodeJS native `randomBytes`. + */ + private readonly randomBytes = promisify(randomBytes); + + constructor(private readonly options: Readonly) { + this.crypto = nodeCrypto({ encryptionKey: this.options.config.encryptionKey }); + this.idleTimeout = this.options.config.session.idleTimeout; + this.lifespan = this.options.config.session.lifespan; + + // The timeout after which we update index is two times longer than configured idle timeout + // since index updates are costly and we want to minimize them. + this.idleIndexUpdateTimeout = this.options.config.session.idleTimeout + ? this.options.config.session.idleTimeout.asMilliseconds() * 2 + : null; + } + + /** + * Extracts session value for the specified request. Under the hood it can clear session if it is + * invalid or created by the legacy versions of Kibana. + * @param request Request instance to get session value for. + */ + async get(request: KibanaRequest) { + const sessionCookieValue = await this.options.sessionCookie.get(request); + if (!sessionCookieValue) { + return null; + } + + const sessionLogger = this.getLoggerForSID(sessionCookieValue.sid); + const now = Date.now(); + if ( + (sessionCookieValue.idleTimeoutExpiration && + sessionCookieValue.idleTimeoutExpiration < now) || + (sessionCookieValue.lifespanExpiration && sessionCookieValue.lifespanExpiration < now) + ) { + sessionLogger.debug('Session has expired and will be invalidated.'); + await this.clear(request); + return null; + } + + const sessionIndexValue = await this.options.sessionIndex.get(sessionCookieValue.sid); + if (!sessionIndexValue) { + sessionLogger.debug( + 'Session value is not available in the index, session cookie will be invalidated.' + ); + await this.options.sessionCookie.clear(request); + return null; + } + + let decryptedContent: SessionValueContentToEncrypt; + try { + decryptedContent = JSON.parse( + (await this.crypto.decrypt(sessionIndexValue.content, sessionCookieValue.aad)) as string + ); + } catch (err) { + sessionLogger.warn( + `Unable to decrypt session content, session will be invalidated: ${err.message}` + ); + await this.clear(request); + return null; + } + + return { + ...Session.sessionIndexValueToSessionValue(sessionIndexValue, decryptedContent), + // Unlike session index, session cookie contains the most up to date idle timeout expiration. + idleTimeoutExpiration: sessionCookieValue.idleTimeoutExpiration, + }; + } + + /** + * Creates new session document in the session index encrypting sensitive state. + * @param request Request instance to create session value for. + * @param sessionValue Session value parameters. + */ + async create( + request: KibanaRequest, + sessionValue: Readonly< + Omit + > + ) { + const [sid, aad] = await Promise.all([ + this.randomBytes(SID_BYTE_LENGTH).then((sidBuffer) => sidBuffer.toString('base64')), + this.randomBytes(AAD_BYTE_LENGTH).then((aadBuffer) => aadBuffer.toString('base64')), + ]); + + const sessionLogger = this.getLoggerForSID(sid); + sessionLogger.debug('Creating a new session.'); + + const sessionExpirationInfo = this.calculateExpiry(); + const { username, state, ...publicSessionValue } = sessionValue; + + // First try to store session in the index and only then in the cookie to make sure cookie is + // only updated if server side session is created successfully. + const sessionIndexValue = await this.options.sessionIndex.create({ + ...publicSessionValue, + ...sessionExpirationInfo, + sid, + usernameHash: username && createHash('sha3-256').update(username).digest('hex'), + content: await this.crypto.encrypt(JSON.stringify({ username, state }), aad), + }); + + await this.options.sessionCookie.set(request, { ...sessionExpirationInfo, sid, aad }); + + sessionLogger.debug('Successfully created a new session.'); + + return Session.sessionIndexValueToSessionValue(sessionIndexValue, { username, state }); + } + + /** + * Creates or updates session value for the specified request. + * @param request Request instance to set session value for. + * @param sessionValue Session value parameters. + */ + async update(request: KibanaRequest, sessionValue: Readonly) { + const sessionCookieValue = await this.options.sessionCookie.get(request); + const sessionLogger = this.getLoggerForSID(sessionValue.sid); + if (!sessionCookieValue) { + sessionLogger.warn('Session cannot be updated since it does not exist.'); + return null; + } + + const sessionExpirationInfo = this.calculateExpiry(sessionCookieValue.lifespanExpiration); + const { username, state, metadata, ...publicSessionInfo } = sessionValue; + + // First try to store session in the index and only then in the cookie to make sure cookie is + // only updated if server side session is created successfully. + const sessionIndexValue = await this.options.sessionIndex.update({ + ...sessionValue.metadata.index, + ...publicSessionInfo, + ...sessionExpirationInfo, + usernameHash: username && createHash('sha3-256').update(username).digest('hex'), + content: await this.crypto.encrypt( + JSON.stringify({ username, state }), + sessionCookieValue.aad + ), + }); + + // Session may be already invalidated by another concurrent request, in this case we should + // clear cookie for the request as well. + if (sessionIndexValue === null) { + sessionLogger.warn('Session cannot be updated as it has been invalidated already.'); + await this.options.sessionCookie.clear(request); + return null; + } + + await this.options.sessionCookie.set(request, { + ...sessionCookieValue, + ...sessionExpirationInfo, + }); + + sessionLogger.debug('Successfully updated existing session.'); + + return Session.sessionIndexValueToSessionValue(sessionIndexValue, { username, state }); + } + + /** + * Extends existing session. + * @param request Request instance to set session value for. + * @param sessionValue Session value parameters. + */ + async extend(request: KibanaRequest, sessionValue: Readonly) { + const sessionCookieValue = await this.options.sessionCookie.get(request); + const sessionLogger = this.getLoggerForSID(sessionValue.sid); + if (!sessionCookieValue) { + sessionLogger.warn('Session cannot be extended since it does not exist.'); + return null; + } + + // We calculate actual expiration values based on the information extracted from the portion of + // the session value that is stored in the cookie since it always contains the most recent value. + const sessionExpirationInfo = this.calculateExpiry(sessionCookieValue.lifespanExpiration); + if ( + sessionExpirationInfo.idleTimeoutExpiration === sessionValue.idleTimeoutExpiration && + sessionExpirationInfo.lifespanExpiration === sessionValue.lifespanExpiration + ) { + return sessionValue; + } + + // Session index updates are costly and should be minimized, but these are the cases when we + // should update session index: + let updateSessionIndex = false; + if ( + (sessionExpirationInfo.idleTimeoutExpiration === null && + sessionValue.idleTimeoutExpiration !== null) || + (sessionExpirationInfo.idleTimeoutExpiration !== null && + sessionValue.idleTimeoutExpiration === null) + ) { + // 1. If idle timeout wasn't configured when session was initially created and is configured + // now or vice versa. + sessionLogger.debug( + 'Session idle timeout configuration has changed, session index will be updated.' + ); + updateSessionIndex = true; + } else if ( + (sessionExpirationInfo.lifespanExpiration === null && + sessionValue.lifespanExpiration !== null) || + (sessionExpirationInfo.lifespanExpiration !== null && + sessionValue.lifespanExpiration === null) + ) { + // 2. If lifespan wasn't configured when session was initially created and is configured now + // or vice versa. + sessionLogger.debug( + 'Session lifespan configuration has changed, session index will be updated.' + ); + updateSessionIndex = true; + } else if ( + this.idleIndexUpdateTimeout !== null && + this.idleIndexUpdateTimeout < + sessionExpirationInfo.idleTimeoutExpiration! - + sessionValue.metadata.index.idleTimeoutExpiration! + ) { + // 3. If idle timeout was updated a while ago. + sessionLogger.debug( + 'Session idle timeout stored in the index is too old and will be updated.' + ); + updateSessionIndex = true; + } + + // First try to store session in the index and only then in the cookie to make sure cookie is + // only updated if server side session is created successfully. + if (updateSessionIndex) { + const sessionIndexValue = await this.options.sessionIndex.update({ + ...sessionValue.metadata.index, + ...sessionExpirationInfo, + }); + + // Session may be already invalidated by another concurrent request, in this case we should + // clear cookie for the request as well. + if (sessionIndexValue === null) { + sessionLogger.warn('Session cannot be extended as it has been invalidated already.'); + await this.options.sessionCookie.clear(request); + return null; + } + + sessionValue.metadata.index = sessionIndexValue; + } + + await this.options.sessionCookie.set(request, { + ...sessionCookieValue, + ...sessionExpirationInfo, + }); + + sessionLogger.debug('Successfully extended existing session.'); + + return { ...sessionValue, ...sessionExpirationInfo } as Readonly; + } + + /** + * Clears session value for the specified request. + * @param request Request instance to clear session value for. + */ + async clear(request: KibanaRequest) { + const sessionCookieValue = await this.options.sessionCookie.get(request); + if (!sessionCookieValue) { + return; + } + + const sessionLogger = this.getLoggerForSID(sessionCookieValue.sid); + sessionLogger.debug('Invalidating session.'); + + await Promise.all([ + this.options.sessionCookie.clear(request), + this.options.sessionIndex.clear(sessionCookieValue.sid), + ]); + + sessionLogger.debug('Successfully invalidated session.'); + } + + private calculateExpiry( + currentLifespanExpiration?: number | null + ): { idleTimeoutExpiration: number | null; lifespanExpiration: number | null } { + const now = Date.now(); + // if we are renewing an existing session, use its `lifespanExpiration` -- otherwise, set this value + // based on the configured server `lifespan`. + // note, if the server had a `lifespan` set and then removes it, remove `lifespanExpiration` on renewed sessions + // also, if the server did not have a `lifespan` set and then adds it, add `lifespanExpiration` on renewed sessions + const lifespanExpiration = + currentLifespanExpiration && this.lifespan + ? currentLifespanExpiration + : this.lifespan && now + this.lifespan.asMilliseconds(); + const idleTimeoutExpiration = this.idleTimeout && now + this.idleTimeout.asMilliseconds(); + + return { idleTimeoutExpiration, lifespanExpiration }; + } + + /** + * Converts value retrieved from the index to the value returned to the API consumers. + * @param sessionIndexValue The value returned from the index. + * @param decryptedContent Decrypted session value content. + */ + private static sessionIndexValueToSessionValue( + sessionIndexValue: Readonly, + { username, state }: SessionValueContentToEncrypt + ): Readonly { + // Extract values that are specific to session index value. + const { usernameHash, content, ...publicSessionValue } = sessionIndexValue; + return { ...publicSessionValue, username, state, metadata: { index: sessionIndexValue } }; + } + + /** + * Creates logger scoped to a specified session ID. + * @param sid Session ID to create logger for. + */ + private getLoggerForSID(sid: string) { + return this.options.logger.get(sid?.slice(-10)); + } +} diff --git a/x-pack/plugins/security/server/session_management/session_cookie.mock.ts b/x-pack/plugins/security/server/session_management/session_cookie.mock.ts new file mode 100644 index 0000000000000..026117f227561 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_cookie.mock.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SessionCookie, SessionCookieValue } from './session_cookie'; + +export const sessionCookieMock = { + create: (): jest.Mocked> => ({ + get: jest.fn(), + set: jest.fn(), + clear: jest.fn(), + }), + + createValue: (sessionValue: Partial = {}): SessionCookieValue => ({ + sid: 'some-long-sid', + aad: 'some-aad', + idleTimeoutExpiration: null, + lifespanExpiration: null, + path: '/mock-base-path', + ...sessionValue, + }), +}; diff --git a/x-pack/plugins/security/server/session_management/session_cookie.test.ts b/x-pack/plugins/security/server/session_management/session_cookie.test.ts new file mode 100644 index 0000000000000..584988d201a22 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_cookie.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SessionStorage } from '../../../../../src/core/server'; +import { SessionCookie, SessionCookieOptions } from './session_cookie'; + +import { + loggingSystemMock, + httpServiceMock, + sessionStorageMock, + httpServerMock, +} from '../../../../../src/core/server/mocks'; +import { sessionCookieMock } from './session_cookie.mock'; + +describe('Session cookie', () => { + let sessionCookieOptions: SessionCookieOptions; + let sessionCookie: SessionCookie; + let mockSessionStorageFactory: ReturnType; + let mockSessionStorage: jest.Mocked>; + beforeEach(() => { + const config = { + encryptionKey: 'ab'.repeat(16), + secureCookies: true, + cookieName: 'my-sid-cookie', + sameSiteCookies: 'Strict' as 'Strict', + }; + + const httpSetupMock = httpServiceMock.createSetupContract(); + mockSessionStorage = sessionStorageMock.create(); + mockSessionStorageFactory = sessionStorageMock.createFactory(); + mockSessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + httpSetupMock.createCookieSessionStorageFactory.mockResolvedValue(mockSessionStorageFactory); + + sessionCookieOptions = { + logger: loggingSystemMock.createLogger(), + serverBasePath: '/mock-base-path', + config, + createCookieSessionStorageFactory: httpSetupMock.createCookieSessionStorageFactory, + }; + + sessionCookie = new SessionCookie(sessionCookieOptions); + }); + + describe('#constructor', () => { + it('properly creates CookieSessionStorageFactory', () => { + expect(sessionCookieOptions.createCookieSessionStorageFactory).toHaveBeenCalledTimes(1); + expect(sessionCookieOptions.createCookieSessionStorageFactory).toHaveBeenCalledWith({ + encryptionKey: sessionCookieOptions.config.encryptionKey, + isSecure: sessionCookieOptions.config.secureCookies, + name: sessionCookieOptions.config.cookieName, + sameSite: sessionCookieOptions.config.sameSiteCookies, + validate: expect.any(Function), + }); + }); + + it('cookie validator properly handles cookies with different base path', () => { + const [ + [{ validate }], + ] = (sessionCookieOptions.createCookieSessionStorageFactory as jest.Mock).mock.calls; + + expect( + validate(sessionCookieMock.createValue({ path: sessionCookieOptions.serverBasePath })) + ).toEqual({ isValid: true }); + + expect( + validate([ + sessionCookieMock.createValue({ path: sessionCookieOptions.serverBasePath }), + sessionCookieMock.createValue({ path: sessionCookieOptions.serverBasePath }), + ]) + ).toEqual({ isValid: true }); + + expect(validate(sessionCookieMock.createValue({ path: '/some-old-path' }))).toEqual({ + isValid: false, + path: '/some-old-path', + }); + + expect( + validate([ + sessionCookieMock.createValue({ path: sessionCookieOptions.serverBasePath }), + sessionCookieMock.createValue({ path: '/some-old-path' }), + ]) + ).toEqual({ isValid: false, path: '/some-old-path' }); + }); + }); + + describe('#get', () => { + it('returns `null` if session storage returns `null`', async () => { + mockSessionStorage.get.mockResolvedValue(null); + + const request = httpServerMock.createKibanaRequest(); + await expect(sessionCookie.get(request)).resolves.toBeNull(); + + expect(mockSessionStorageFactory.asScoped).toHaveBeenCalledWith(request); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); + + it('returns value if session is in compatible format', async () => { + const sessionValue = sessionCookieMock.createValue(); + mockSessionStorage.get.mockResolvedValue(sessionValue); + + const request = httpServerMock.createKibanaRequest(); + await expect(sessionCookie.get(request)).resolves.toBe(sessionValue); + + expect(mockSessionStorageFactory.asScoped).toHaveBeenCalledWith(request); + expect(mockSessionStorage.clear).not.toHaveBeenCalled(); + }); + + it('returns `null` and clears session value if it is in incompatible format', async () => { + const invalidValue = sessionCookieMock.createValue(); + delete invalidValue.sid; + + mockSessionStorage.get.mockResolvedValue(invalidValue); + + const request = httpServerMock.createKibanaRequest(); + await expect(sessionCookie.get(request)).resolves.toBeNull(); + + expect(mockSessionStorageFactory.asScoped).toHaveBeenCalledWith(request); + expect(mockSessionStorage.clear).toHaveBeenCalledTimes(1); + }); + }); + + describe('#set', () => { + it('properly sets value in the session storage', async () => { + const sessionValue = sessionCookieMock.createValue(); + + const request = httpServerMock.createKibanaRequest(); + await sessionCookie.set(request, sessionValue); + + expect(mockSessionStorageFactory.asScoped).toHaveBeenCalledWith(request); + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + ...sessionValue, + path: '/mock-base-path', + }); + }); + }); + + describe('#clear', () => { + it('properly clears value in the session storage', async () => { + const request = httpServerMock.createKibanaRequest(); + await sessionCookie.clear(request); + + expect(mockSessionStorageFactory.asScoped).toHaveBeenCalledWith(request); + expect(mockSessionStorage.clear).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/security/server/session_management/session_cookie.ts b/x-pack/plugins/security/server/session_management/session_cookie.ts new file mode 100644 index 0000000000000..b7c8211dbd4c1 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_cookie.ts @@ -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 { + HttpServiceSetup, + KibanaRequest, + Logger, + SessionStorageFactory, +} from '../../../../../src/core/server'; +import { ConfigType } from '../config'; + +/** + * Represents shape of the session value stored in the cookie. + */ +export interface SessionCookieValue { + /** + * Unique session ID. + */ + sid: string; + + /** + * Unique random value used as Additional authenticated data (AAD) while encrypting/decrypting + * sensitive or PII session content stored in the Elasticsearch index. This value is only stored + * in the user cookie. + */ + aad: string; + + /** + * Kibana server base path the session was created for. + */ + path: string; + + /** + * The Unix time in ms when the session should be considered expired. If `null`, session will stay + * active until the max lifespan is reached. + */ + idleTimeoutExpiration: number | null; + + /** + * The Unix time in ms which is the max total lifespan of the session. If `null`, session expire + * time can be extended indefinitely. + */ + lifespanExpiration: number | null; +} + +export interface SessionCookieOptions { + logger: Logger; + serverBasePath: string; + createCookieSessionStorageFactory: HttpServiceSetup['createCookieSessionStorageFactory']; + config: Pick; +} + +export class SessionCookie { + /** + * Promise containing initialized cookie session storage factory. + */ + private readonly cookieSessionValueStorage: Promise< + SessionStorageFactory> + >; + + /** + * Session cookie logger. + */ + private readonly logger: Logger; + + /** + * Base path of the Kibana server instance. + */ + private readonly serverBasePath: string; + + constructor({ + config, + createCookieSessionStorageFactory, + logger, + serverBasePath, + }: Readonly) { + this.logger = logger; + this.serverBasePath = serverBasePath; + + this.cookieSessionValueStorage = createCookieSessionStorageFactory({ + encryptionKey: config.encryptionKey, + isSecure: config.secureCookies, + name: config.cookieName, + sameSite: config.sameSiteCookies, + validate: (sessionValue: SessionCookieValue | SessionCookieValue[]) => { + // ensure that this cookie was created with the current Kibana configuration + const invalidSessionValue = (Array.isArray(sessionValue) + ? sessionValue + : [sessionValue] + ).find((sess) => sess.path !== undefined && sess.path !== serverBasePath); + + if (invalidSessionValue) { + this.logger.debug(`Outdated session value with path "${invalidSessionValue.path}"`); + return { isValid: false, path: invalidSessionValue.path }; + } + + return { isValid: true }; + }, + }); + } + + /** + * Extracts session value for the specified request. + * @param request Request instance to get session value for. + */ + async get(request: KibanaRequest) { + const sessionStorage = (await this.cookieSessionValueStorage).asScoped(request); + const sessionValue = await sessionStorage.get(); + + // If we detect that cookie session value is in incompatible format, then we should clear such + // cookie. + if (sessionValue && !SessionCookie.isSupportedSessionValue(sessionValue)) { + sessionStorage.clear(); + return null; + } + + return sessionValue; + } + + /** + * Creates or updates session value for the specified request. + * @param request Request instance to set session value for. + * @param sessionValue Session value parameters. + */ + async set(request: KibanaRequest, sessionValue: Readonly>) { + (await this.cookieSessionValueStorage) + .asScoped(request) + .set({ ...sessionValue, path: this.serverBasePath }); + } + + /** + * Clears session value for the specified request. + * @param request Request instance to clear session value for. + */ + async clear(request: KibanaRequest) { + (await this.cookieSessionValueStorage).asScoped(request).clear(); + } + + /** + * Determines if session value was created by the current Kibana version. Previous versions had a different session value format. + * @param sessionValue The session value to check. + */ + private static isSupportedSessionValue(sessionValue: any): sessionValue is SessionCookieValue { + return typeof sessionValue?.sid === 'string' && typeof sessionValue?.aad === 'string'; + } +} diff --git a/x-pack/plugins/security/server/session_management/session_index.mock.ts b/x-pack/plugins/security/server/session_management/session_index.mock.ts new file mode 100644 index 0000000000000..81dbe4b7410b8 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_index.mock.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 { SessionIndex, SessionIndexValue } from './session_index'; + +export const sessionIndexMock = { + create: (): jest.Mocked> => ({ + get: jest.fn(), + create: jest.fn(), + update: jest.fn(), + clear: jest.fn(), + initialize: jest.fn(), + cleanUp: jest.fn(), + }), + + createValue: (sessionValue: Partial = {}): SessionIndexValue => ({ + sid: 'some-long-sid', + usernameHash: 'some-username-hash', + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: null, + lifespanExpiration: null, + content: 'some-encrypted-content', + metadata: { primaryTerm: 1, sequenceNumber: 1 }, + ...sessionValue, + }), +}; diff --git a/x-pack/plugins/security/server/session_management/session_index.test.ts b/x-pack/plugins/security/server/session_management/session_index.test.ts new file mode 100644 index 0000000000000..f4ff5a8bddb74 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_index.test.ts @@ -0,0 +1,518 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ILegacyClusterClient } from '../../../../../src/core/server'; +import { ConfigSchema, createConfig } from '../config'; +import { getSessionIndexTemplate, SessionIndex } from './session_index'; + +import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; +import { sessionIndexMock } from './session_index.mock'; + +describe('Session index', () => { + let mockClusterClient: jest.Mocked; + let sessionIndex: SessionIndex; + const indexName = '.kibana_some_tenant_security_session_1'; + const indexTemplateName = '.kibana_some_tenant_security_session_index_template_1'; + beforeEach(() => { + mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); + const sessionIndexOptions = { + logger: loggingSystemMock.createLogger(), + kibanaIndexName: '.kibana_some_tenant', + config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { + isTLSEnabled: false, + }), + clusterClient: mockClusterClient, + }; + + sessionIndex = new SessionIndex(sessionIndexOptions); + }); + + describe('#initialize', () => { + function assertExistenceChecksPerformed() { + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.existsTemplate', { + name: indexTemplateName, + }); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.exists', { + index: getSessionIndexTemplate(indexName).index_patterns, + }); + } + + it('debounces initialize calls', async () => { + mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { + if (method === 'indices.existsTemplate' || method === 'indices.exists') { + return true; + } + + throw new Error('Unexpected call'); + }); + + await Promise.all([ + sessionIndex.initialize(), + sessionIndex.initialize(), + sessionIndex.initialize(), + sessionIndex.initialize(), + ]); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + assertExistenceChecksPerformed(); + }); + + it('creates neither index template nor index if they exist', async () => { + mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { + if (method === 'indices.existsTemplate' || method === 'indices.exists') { + return true; + } + + throw new Error('Unexpected call'); + }); + + await sessionIndex.initialize(); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + assertExistenceChecksPerformed(); + }); + + it('creates both index template and index if they do not exist', async () => { + mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { + if (method === 'indices.existsTemplate' || method === 'indices.exists') { + return false; + } + }); + + await sessionIndex.initialize(); + + const expectedIndexTemplate = getSessionIndexTemplate(indexName); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(4); + assertExistenceChecksPerformed(); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.putTemplate', { + name: indexTemplateName, + body: expectedIndexTemplate, + }); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.create', { + index: expectedIndexTemplate.index_patterns, + }); + }); + + it('creates only index template if it does not exist even if index exists', async () => { + mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { + if (method === 'indices.existsTemplate') { + return false; + } + + if (method === 'indices.exists') { + return true; + } + }); + + await sessionIndex.initialize(); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(3); + assertExistenceChecksPerformed(); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.putTemplate', { + name: indexTemplateName, + body: getSessionIndexTemplate(indexName), + }); + }); + + it('creates only index if it does not exist even if index template exists', async () => { + mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { + if (method === 'indices.existsTemplate') { + return true; + } + + if (method === 'indices.exists') { + return false; + } + }); + + await sessionIndex.initialize(); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(3); + assertExistenceChecksPerformed(); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.create', { + index: getSessionIndexTemplate(indexName).index_patterns, + }); + }); + + it('does not fail if tries to create index when it exists already', async () => { + mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { + if (method === 'indices.existsTemplate') { + return true; + } + + if (method === 'indices.exists') { + return false; + } + + if (method === 'indices.create') { + // eslint-disable-next-line no-throw-literal + throw { body: { error: { type: 'resource_already_exists_exception' } } }; + } + }); + + await sessionIndex.initialize(); + }); + }); + + describe('cleanUp', () => { + const now = 123456; + beforeEach(() => { + mockClusterClient.callAsInternalUser.mockResolvedValue({}); + jest.spyOn(Date, 'now').mockImplementation(() => now); + }); + + it('throws if call to Elasticsearch fails', async () => { + const failureReason = new Error('Uh oh.'); + mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + + await expect(sessionIndex.cleanUp()).rejects.toBe(failureReason); + }); + + it('when neither `lifespan` nor `idleTimeout` is configured', async () => { + await sessionIndex.cleanUp(); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { + index: indexName, + refresh: 'wait_for', + ignore: [409, 404], + body: { + query: { + bool: { + should: [ + { range: { lifespanExpiration: { lte: now } } }, + { range: { idleTimeoutExpiration: { lte: now } } }, + ], + }, + }, + }, + }); + }); + + it('when only `lifespan` is configured', async () => { + sessionIndex = new SessionIndex({ + logger: loggingSystemMock.createLogger(), + kibanaIndexName: '.kibana_some_tenant', + config: createConfig( + ConfigSchema.validate({ session: { lifespan: 456 } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + clusterClient: mockClusterClient, + }); + + await sessionIndex.cleanUp(); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { + index: indexName, + refresh: 'wait_for', + ignore: [409, 404], + body: { + query: { + bool: { + should: [ + { range: { lifespanExpiration: { lte: now } } }, + { bool: { must_not: { exists: { field: 'lifespanExpiration' } } } }, + { range: { idleTimeoutExpiration: { lte: now } } }, + ], + }, + }, + }, + }); + }); + + it('when only `idleTimeout` is configured', async () => { + const idleTimeout = 123; + sessionIndex = new SessionIndex({ + logger: loggingSystemMock.createLogger(), + kibanaIndexName: '.kibana_some_tenant', + config: createConfig( + ConfigSchema.validate({ session: { idleTimeout } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + clusterClient: mockClusterClient, + }); + + await sessionIndex.cleanUp(); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { + index: indexName, + refresh: 'wait_for', + ignore: [409, 404], + body: { + query: { + bool: { + should: [ + { range: { lifespanExpiration: { lte: now } } }, + { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + ], + }, + }, + }, + }); + }); + + it('when both `lifespan` and `idleTimeout` are configured', async () => { + const idleTimeout = 123; + sessionIndex = new SessionIndex({ + logger: loggingSystemMock.createLogger(), + kibanaIndexName: '.kibana_some_tenant', + config: createConfig( + ConfigSchema.validate({ session: { idleTimeout, lifespan: 456 } }), + loggingSystemMock.createLogger(), + { isTLSEnabled: false } + ), + clusterClient: mockClusterClient, + }); + + await sessionIndex.cleanUp(); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('deleteByQuery', { + index: indexName, + refresh: 'wait_for', + ignore: [409, 404], + body: { + query: { + bool: { + should: [ + { range: { lifespanExpiration: { lte: now } } }, + { bool: { must_not: { exists: { field: 'lifespanExpiration' } } } }, + { range: { idleTimeoutExpiration: { lte: now - 3 * idleTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } }, + ], + }, + }, + }, + }); + }); + }); + + describe('#get', () => { + it('throws if call to Elasticsearch fails', async () => { + const failureReason = new Error('Uh oh.'); + mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + + await expect(sessionIndex.get('some-sid')).rejects.toBe(failureReason); + }); + + it('returns `null` if index is not found', async () => { + mockClusterClient.callAsInternalUser.mockResolvedValue({ status: 404 }); + + await expect(sessionIndex.get('some-sid')).resolves.toBeNull(); + }); + + it('returns `null` if session index value document is not found', async () => { + mockClusterClient.callAsInternalUser.mockResolvedValue({ + found: false, + status: 200, + }); + + await expect(sessionIndex.get('some-sid')).resolves.toBeNull(); + }); + + it('properly returns session index value', async () => { + const indexDocumentSource = { + usernameHash: 'some-username-hash', + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: 123, + lifespanExpiration: null, + content: 'some-encrypted-content', + }; + + mockClusterClient.callAsInternalUser.mockResolvedValue({ + found: true, + status: 200, + _source: indexDocumentSource, + _primary_term: 1, + _seq_no: 456, + }); + + await expect(sessionIndex.get('some-sid')).resolves.toEqual({ + ...indexDocumentSource, + sid: 'some-sid', + metadata: { primaryTerm: 1, sequenceNumber: 456 }, + }); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('get', { + id: 'some-sid', + ignore: [404], + index: indexName, + }); + }); + }); + + describe('#create', () => { + it('throws if call to Elasticsearch fails', async () => { + const failureReason = new Error('Uh oh.'); + mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + + await expect( + sessionIndex.create({ + sid: 'some-long-sid', + usernameHash: 'some-username-hash', + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: null, + lifespanExpiration: null, + content: 'some-encrypted-content', + }) + ).rejects.toBe(failureReason); + }); + + it('properly stores session value in the index', async () => { + mockClusterClient.callAsInternalUser.mockResolvedValue({ + _primary_term: 321, + _seq_no: 654, + }); + + const sid = 'some-long-sid'; + const sessionValue = { + usernameHash: 'some-username-hash', + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: null, + lifespanExpiration: null, + content: 'some-encrypted-content', + }; + + await expect(sessionIndex.create({ sid, ...sessionValue })).resolves.toEqual({ + ...sessionValue, + sid, + metadata: { primaryTerm: 321, sequenceNumber: 654 }, + }); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('create', { + id: sid, + index: indexName, + body: sessionValue, + refresh: 'wait_for', + }); + }); + }); + + describe('#update', () => { + it('throws if call to Elasticsearch fails', async () => { + const failureReason = new Error('Uh oh.'); + mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + + await expect(sessionIndex.update(sessionIndexMock.createValue())).rejects.toBe(failureReason); + }); + + it('refetches latest session value if update fails due to conflict', async () => { + const latestSessionValue = { + usernameHash: 'some-username-hash', + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: 100, + lifespanExpiration: 200, + content: 'some-updated-encrypted-content', + }; + + mockClusterClient.callAsInternalUser.mockImplementation(async (method) => { + if (method === 'get') { + return { + found: true, + status: 200, + _source: latestSessionValue, + _primary_term: 321, + _seq_no: 654, + }; + } + + if (method === 'index') { + return { status: 409 }; + } + }); + + const sid = 'some-long-sid'; + const metadata = { primaryTerm: 123, sequenceNumber: 456 }; + const sessionValue = { + usernameHash: 'some-username-hash', + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: null, + lifespanExpiration: null, + content: 'some-encrypted-content', + }; + + await expect(sessionIndex.update({ sid, metadata, ...sessionValue })).resolves.toEqual({ + ...latestSessionValue, + sid, + metadata: { primaryTerm: 321, sequenceNumber: 654 }, + }); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('index', { + id: sid, + index: indexName, + body: sessionValue, + ifSeqNo: 456, + ifPrimaryTerm: 123, + refresh: 'wait_for', + ignore: [409], + }); + }); + + it('properly stores session value in the index', async () => { + mockClusterClient.callAsInternalUser.mockResolvedValue({ + _primary_term: 321, + _seq_no: 654, + status: 200, + }); + + const sid = 'some-long-sid'; + const metadata = { primaryTerm: 123, sequenceNumber: 456 }; + const sessionValue = { + usernameHash: 'some-username-hash', + provider: { type: 'basic', name: 'basic1' }, + idleTimeoutExpiration: null, + lifespanExpiration: null, + content: 'some-encrypted-content', + }; + + await expect(sessionIndex.update({ sid, metadata, ...sessionValue })).resolves.toEqual({ + ...sessionValue, + sid, + metadata: { primaryTerm: 321, sequenceNumber: 654 }, + }); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('index', { + id: sid, + index: indexName, + body: sessionValue, + ifSeqNo: 456, + ifPrimaryTerm: 123, + refresh: 'wait_for', + ignore: [409], + }); + }); + }); + + describe('#clear', () => { + it('throws if call to Elasticsearch fails', async () => { + const failureReason = new Error('Uh oh.'); + mockClusterClient.callAsInternalUser.mockRejectedValue(failureReason); + + await expect(sessionIndex.clear('some-long-sid')).rejects.toBe(failureReason); + }); + + it('properly removes session value from the index', async () => { + await sessionIndex.clear('some-long-sid'); + + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('delete', { + id: 'some-long-sid', + index: indexName, + refresh: 'wait_for', + ignore: [404], + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/session_management/session_index.ts b/x-pack/plugins/security/server/session_management/session_index.ts new file mode 100644 index 0000000000000..191e71f14d66d --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_index.ts @@ -0,0 +1,395 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ILegacyClusterClient, Logger } from '../../../../../src/core/server'; +import { AuthenticationProvider } from '../../common/types'; +import { ConfigType } from '../config'; + +export interface SessionIndexOptions { + readonly clusterClient: ILegacyClusterClient; + readonly kibanaIndexName: string; + readonly config: Pick; + readonly logger: Logger; +} + +/** + * Version of the current session index template. + */ +const SESSION_INDEX_TEMPLATE_VERSION = 1; + +/** + * Returns index template that is used for the current version of the session index. + */ +export function getSessionIndexTemplate(indexName: string) { + return Object.freeze({ + index_patterns: indexName, + order: 1000, + settings: { + index: { + number_of_shards: 1, + number_of_replicas: 0, + auto_expand_replicas: '0-1', + priority: 1000, + refresh_interval: '1s', + hidden: true, + }, + }, + mappings: { + dynamic: 'strict', + properties: { + usernameHash: { type: 'keyword' }, + provider: { properties: { name: { type: 'keyword' }, type: { type: 'keyword' } } }, + idleTimeoutExpiration: { type: 'date' }, + lifespanExpiration: { type: 'date' }, + accessAgreementAcknowledged: { type: 'boolean' }, + content: { type: 'binary' }, + }, + }, + }); +} + +/** + * Represents shape of the session value stored in the index. + */ +export interface SessionIndexValue { + /** + * Unique session ID. + */ + sid: string; + + /** + * Hash of the username. It's defined only if session is authenticated, otherwise session + * is considered unauthenticated (e.g. intermediate session used during SSO handshake). + */ + usernameHash?: string; + + /** + * Name and type of the provider this session belongs to. + */ + provider: AuthenticationProvider; + + /** + * The Unix time in ms when the session should be considered expired. If `null`, session will stay + * active until the browser is closed. + */ + idleTimeoutExpiration: number | null; + + /** + * The Unix time in ms which is the max total lifespan of the session. If `null`, session expire + * time can be extended indefinitely. + */ + lifespanExpiration: number | null; + + /** + * Indicates whether user acknowledged access agreement or not. + */ + accessAgreementAcknowledged?: boolean; + + /** + * Content of the session value represented as an encrypted JSON string. + */ + content: string; + + /** + * Additional index specific information about the session value. + */ + metadata: SessionIndexValueMetadata; +} + +/** + * Additional index specific information about the session value. + */ +interface SessionIndexValueMetadata { + /** + * Primary term of the last modification of the document. + */ + primaryTerm: number; + + /** + * Sequence number of the last modification of the document. + */ + sequenceNumber: number; +} + +export class SessionIndex { + /** + * Name of the index to store session information in. + */ + private readonly indexName = `${this.options.kibanaIndexName}_security_session_${SESSION_INDEX_TEMPLATE_VERSION}`; + + /** + * Timeout after which session with the expired idle timeout _may_ be removed from the index + * during regular cleanup routine. + */ + private readonly idleIndexCleanupTimeout: number | null; + + /** + * Promise that tracks session index initialization process. We'll need to get rid of this as soon + * as Core provides support for plugin statuses (https://github.com/elastic/kibana/issues/41983). + * With this we won't mark Security as `Green` until index is fully initialized and hence consumers + * won't be able to call any APIs we provide. + */ + private indexInitialization?: Promise; + + constructor(private readonly options: Readonly) { + // This timeout is intentionally larger than the `idleIndexUpdateTimeout` (idleTimeout * 2) + // configured in `Session` to be sure that the session value is definitely expired and may be + // safely cleaned up. + this.idleIndexCleanupTimeout = this.options.config.session.idleTimeout + ? this.options.config.session.idleTimeout.asMilliseconds() * 3 + : null; + } + + /** + * Retrieves session value with the specified ID from the index. If session value isn't found + * `null` will be returned. + * @param sid Session ID. + */ + async get(sid: string) { + try { + const response = await this.options.clusterClient.callAsInternalUser('get', { + id: sid, + ignore: [404], + index: this.indexName, + }); + + const docNotFound = response.found === false; + const indexNotFound = response.status === 404; + if (docNotFound || indexNotFound) { + this.options.logger.debug('Cannot find session value with the specified ID.'); + return null; + } + + return { + ...response._source, + sid, + metadata: { primaryTerm: response._primary_term, sequenceNumber: response._seq_no }, + } as Readonly; + } catch (err) { + this.options.logger.error(`Failed to retrieve session value: ${err.message}`); + throw err; + } + } + + /** + * Creates a new document for the specified session value. + * @param sessionValue Session index value. + */ + async create(sessionValue: Readonly>) { + if (this.indexInitialization) { + this.options.logger.warn( + 'Attempted to create a new session while session index is initializing.' + ); + await this.indexInitialization; + } + + const { sid, ...sessionValueToStore } = sessionValue; + try { + const { + _primary_term: primaryTerm, + _seq_no: sequenceNumber, + } = await this.options.clusterClient.callAsInternalUser('create', { + id: sid, + // We cannot control whether index is created automatically during this operation or not. + // But we can reduce probability of getting into a weird state when session is being created + // while session index is missing for some reason. This way we'll recreate index with a + // proper name and alias. But this will only work if we still have a proper index template. + index: this.indexName, + body: sessionValueToStore, + refresh: 'wait_for', + }); + + return { ...sessionValue, metadata: { primaryTerm, sequenceNumber } } as SessionIndexValue; + } catch (err) { + this.options.logger.error(`Failed to create session value: ${err.message}`); + throw err; + } + } + + /** + * Re-indexes updated session value. + * @param sessionValue Session index value. + */ + async update(sessionValue: Readonly) { + const { sid, metadata, ...sessionValueToStore } = sessionValue; + try { + const response = await this.options.clusterClient.callAsInternalUser('index', { + id: sid, + index: this.indexName, + body: sessionValueToStore, + ifSeqNo: metadata.sequenceNumber, + ifPrimaryTerm: metadata.primaryTerm, + refresh: 'wait_for', + ignore: [409], + }); + + // We don't want to override changes that were made after we fetched session value or + // re-create it if has been deleted already. If we detect such a case we discard changes and + // return latest copy of the session value instead or `null` if doesn't exist anymore. + const sessionIndexValueUpdateConflict = response.status === 409; + if (sessionIndexValueUpdateConflict) { + this.options.logger.debug( + 'Cannot update session value due to conflict, session either does not exist or was already updated.' + ); + return await this.get(sid); + } + + return { + ...sessionValue, + metadata: { primaryTerm: response._primary_term, sequenceNumber: response._seq_no }, + } as SessionIndexValue; + } catch (err) { + this.options.logger.error(`Failed to update session value: ${err.message}`); + throw err; + } + } + + /** + * Clears session value with the specified ID. + * @param sid Session ID to clear. + */ + async clear(sid: string) { + try { + // We don't specify primary term and sequence number as delete should always take precedence + // over any updates that could happen in the meantime. + await this.options.clusterClient.callAsInternalUser('delete', { + id: sid, + index: this.indexName, + refresh: 'wait_for', + ignore: [404], + }); + } catch (err) { + this.options.logger.error(`Failed to clear session value: ${err.message}`); + throw err; + } + } + + /** + * Initializes index that is used to store session values. + */ + async initialize() { + if (this.indexInitialization) { + return await this.indexInitialization; + } + + const sessionIndexTemplateName = `${this.options.kibanaIndexName}_security_session_index_template_${SESSION_INDEX_TEMPLATE_VERSION}`; + return (this.indexInitialization = new Promise(async (resolve) => { + // Check if required index template exists. + let indexTemplateExists = false; + try { + indexTemplateExists = await this.options.clusterClient.callAsInternalUser( + 'indices.existsTemplate', + { name: sessionIndexTemplateName } + ); + } catch (err) { + this.options.logger.error( + `Failed to check if session index template exists: ${err.message}` + ); + throw err; + } + + // Create index template if it doesn't exist. + if (indexTemplateExists) { + this.options.logger.debug('Session index template already exists.'); + } else { + try { + await this.options.clusterClient.callAsInternalUser('indices.putTemplate', { + name: sessionIndexTemplateName, + body: getSessionIndexTemplate(this.indexName), + }); + this.options.logger.debug('Successfully created session index template.'); + } catch (err) { + this.options.logger.error(`Failed to create session index template: ${err.message}`); + throw err; + } + } + + // Check if required index exists. We cannot be sure that automatic creation of indices is + // always enabled, so we create session index explicitly. + let indexExists = false; + try { + indexExists = await this.options.clusterClient.callAsInternalUser('indices.exists', { + index: this.indexName, + }); + } catch (err) { + this.options.logger.error(`Failed to check if session index exists: ${err.message}`); + throw err; + } + + // Create index if it doesn't exist. + if (indexExists) { + this.options.logger.debug('Session index already exists.'); + } else { + try { + await this.options.clusterClient.callAsInternalUser('indices.create', { + index: this.indexName, + }); + this.options.logger.debug('Successfully created session index.'); + } catch (err) { + // There can be a race condition if index is created by another Kibana instance. + if (err?.body?.error?.type === 'resource_already_exists_exception') { + this.options.logger.debug('Session index already exists.'); + } else { + this.options.logger.error(`Failed to create session index: ${err.message}`); + throw err; + } + } + } + + // Notify any consumers that are awaiting on this promise and immediately reset it. + resolve(); + this.indexInitialization = undefined; + })); + } + + /** + * Trigger a removal of any outdated session values. + */ + async cleanUp() { + this.options.logger.debug(`Running cleanup routine.`); + + const now = Date.now(); + + // Always try to delete sessions with expired lifespan (even if it's not configured right now). + const deleteQueries: object[] = [{ range: { lifespanExpiration: { lte: now } } }]; + + // If lifespan is configured we should remove any sessions that were created without one. + if (this.options.config.session.lifespan) { + deleteQueries.push({ bool: { must_not: { exists: { field: 'lifespanExpiration' } } } }); + } + + // If idle timeout is configured we should delete all sessions without specified idle timeout + // or if that session hasn't been updated for a while meaning that session is expired. + if (this.idleIndexCleanupTimeout) { + deleteQueries.push( + { range: { idleTimeoutExpiration: { lte: now - this.idleIndexCleanupTimeout } } }, + { bool: { must_not: { exists: { field: 'idleTimeoutExpiration' } } } } + ); + } else { + // Otherwise just delete all expired sessions that were previously created with the idle + // timeout. + deleteQueries.push({ range: { idleTimeoutExpiration: { lte: now } } }); + } + + try { + const response = await this.options.clusterClient.callAsInternalUser('deleteByQuery', { + index: this.indexName, + refresh: 'wait_for', + ignore: [409, 404], + body: { query: { bool: { should: deleteQueries } } }, + }); + + if (response.deleted > 0) { + this.options.logger.debug( + `Cleaned up ${response.deleted} invalid or expired session values.` + ); + } + } catch (err) { + this.options.logger.error(`Failed to clean up sessions: ${err.message}`); + throw err; + } + } +} diff --git a/x-pack/plugins/security/server/session_management/session_management_service.test.ts b/x-pack/plugins/security/server/session_management/session_management_service.test.ts new file mode 100644 index 0000000000000..df528e3f97cb4 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_management_service.test.ts @@ -0,0 +1,313 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Subject } from 'rxjs'; +import { ConfigSchema, createConfig } from '../config'; +import { OnlineStatusRetryScheduler } from '../elasticsearch'; +import { + SessionManagementService, + SESSION_INDEX_CLEANUP_TASK_NAME, +} from './session_management_service'; +import { Session } from './session'; +import { SessionIndex } from './session_index'; + +import { nextTick } from 'test_utils/enzyme_helpers'; +import { + coreMock, + elasticsearchServiceMock, + loggingSystemMock, +} from '../../../../../src/core/server/mocks'; +import { taskManagerMock } from '../../../task_manager/server/mocks'; +import { TaskManagerStartContract } from '../../../task_manager/server'; + +describe('SessionManagementService', () => { + let service: SessionManagementService; + beforeEach(() => { + service = new SessionManagementService(loggingSystemMock.createLogger()); + }); + + describe('setup()', () => { + it('exposes proper contract', () => { + const mockCoreSetup = coreMock.createSetup(); + const mockTaskManager = taskManagerMock.createSetup(); + + expect( + service.setup({ + clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), + http: mockCoreSetup.http, + config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { + isTLSEnabled: false, + }), + kibanaIndexName: '.kibana', + taskManager: mockTaskManager, + }) + ).toEqual({ session: expect.any(Session) }); + + expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1); + expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledWith({ + [SESSION_INDEX_CLEANUP_TASK_NAME]: { + title: 'Cleanup expired or invalid user sessions', + type: SESSION_INDEX_CLEANUP_TASK_NAME, + createTaskRunner: expect.any(Function), + }, + }); + }); + + it('registers proper session index cleanup task runner', () => { + const mockSessionIndexCleanUp = jest.spyOn(SessionIndex.prototype, 'cleanUp'); + const mockTaskManager = taskManagerMock.createSetup(); + + const mockClusterClient = elasticsearchServiceMock.createLegacyClusterClient(); + mockClusterClient.callAsInternalUser.mockResolvedValue({}); + service.setup({ + clusterClient: mockClusterClient, + http: coreMock.createSetup().http, + config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { + isTLSEnabled: false, + }), + kibanaIndexName: '.kibana', + taskManager: mockTaskManager, + }); + + const [ + [ + { + [SESSION_INDEX_CLEANUP_TASK_NAME]: { createTaskRunner }, + }, + ], + ] = mockTaskManager.registerTaskDefinitions.mock.calls; + expect(mockSessionIndexCleanUp).not.toHaveBeenCalled(); + + const runner = createTaskRunner({} as any); + runner.run(); + expect(mockSessionIndexCleanUp).toHaveBeenCalledTimes(1); + + runner.run(); + expect(mockSessionIndexCleanUp).toHaveBeenCalledTimes(2); + }); + }); + + describe('start()', () => { + let mockSessionIndexInitialize: jest.SpyInstance; + let mockTaskManager: jest.Mocked; + beforeEach(() => { + mockSessionIndexInitialize = jest.spyOn(SessionIndex.prototype, 'initialize'); + + mockTaskManager = taskManagerMock.createStart(); + mockTaskManager.ensureScheduled.mockResolvedValue(undefined as any); + + const mockCoreSetup = coreMock.createSetup(); + service.setup({ + clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), + http: mockCoreSetup.http, + config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { + isTLSEnabled: false, + }), + kibanaIndexName: '.kibana', + taskManager: taskManagerMock.createSetup(), + }); + }); + + afterEach(() => { + mockSessionIndexInitialize.mockReset(); + }); + + it('exposes proper contract', () => { + const mockStatusSubject = new Subject(); + expect( + service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }) + ).toBeUndefined(); + }); + + it('initializes session index and schedules session index cleanup task when Elasticsearch goes online', async () => { + const mockStatusSubject = new Subject(); + service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + + // ES isn't online yet. + expect(mockSessionIndexInitialize).not.toHaveBeenCalled(); + expect(mockTaskManager.ensureScheduled).not.toHaveBeenCalled(); + + const mockScheduleRetry = jest.fn(); + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(1); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(1); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledWith({ + id: SESSION_INDEX_CLEANUP_TASK_NAME, + taskType: SESSION_INDEX_CLEANUP_TASK_NAME, + scope: ['security'], + schedule: { interval: '3600s' }, + params: {}, + state: {}, + }); + + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(2); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(2); + + expect(mockScheduleRetry).not.toHaveBeenCalled(); + }); + + it('removes old cleanup task if cleanup interval changes', async () => { + const mockStatusSubject = new Subject(); + service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + + mockTaskManager.get.mockResolvedValue({ schedule: { interval: '2000s' } } as any); + + // ES isn't online yet. + expect(mockTaskManager.ensureScheduled).not.toHaveBeenCalled(); + + const mockScheduleRetry = jest.fn(); + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + + expect(mockTaskManager.get).toHaveBeenCalledTimes(1); + expect(mockTaskManager.get).toHaveBeenCalledWith(SESSION_INDEX_CLEANUP_TASK_NAME); + + expect(mockTaskManager.remove).toHaveBeenCalledTimes(1); + expect(mockTaskManager.remove).toHaveBeenCalledWith(SESSION_INDEX_CLEANUP_TASK_NAME); + + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(1); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledWith({ + id: SESSION_INDEX_CLEANUP_TASK_NAME, + taskType: SESSION_INDEX_CLEANUP_TASK_NAME, + scope: ['security'], + schedule: { interval: '3600s' }, + params: {}, + state: {}, + }); + }); + + it('does not remove old cleanup task if cleanup interval does not change', async () => { + const mockStatusSubject = new Subject(); + service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + + mockTaskManager.get.mockResolvedValue({ schedule: { interval: '3600s' } } as any); + + // ES isn't online yet. + expect(mockTaskManager.ensureScheduled).not.toHaveBeenCalled(); + + const mockScheduleRetry = jest.fn(); + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + + expect(mockTaskManager.get).toHaveBeenCalledTimes(1); + expect(mockTaskManager.get).toHaveBeenCalledWith(SESSION_INDEX_CLEANUP_TASK_NAME); + + expect(mockTaskManager.remove).not.toHaveBeenCalled(); + + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(1); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledWith({ + id: SESSION_INDEX_CLEANUP_TASK_NAME, + taskType: SESSION_INDEX_CLEANUP_TASK_NAME, + scope: ['security'], + schedule: { interval: '3600s' }, + params: {}, + state: {}, + }); + }); + + it('schedules retry if index initialization fails', async () => { + const mockStatusSubject = new Subject(); + service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + + mockSessionIndexInitialize.mockRejectedValue(new Error('ugh :/')); + + const mockScheduleRetry = jest.fn(); + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(1); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(1); + expect(mockScheduleRetry).toHaveBeenCalledTimes(1); + + // Still fails. + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(2); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(2); + expect(mockScheduleRetry).toHaveBeenCalledTimes(2); + + // And finally succeeds, retry is not scheduled. + mockSessionIndexInitialize.mockResolvedValue(undefined); + + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(3); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(3); + expect(mockScheduleRetry).toHaveBeenCalledTimes(2); + }); + + it('schedules retry if cleanup task registration fails', async () => { + const mockStatusSubject = new Subject(); + service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + + mockTaskManager.ensureScheduled.mockRejectedValue(new Error('ugh :/')); + + const mockScheduleRetry = jest.fn(); + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(1); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(1); + expect(mockScheduleRetry).toHaveBeenCalledTimes(1); + + // Still fails. + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(2); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(2); + expect(mockScheduleRetry).toHaveBeenCalledTimes(2); + + // And finally succeeds, retry is not scheduled. + mockTaskManager.ensureScheduled.mockResolvedValue(undefined as any); + + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + await nextTick(); + expect(mockSessionIndexInitialize).toHaveBeenCalledTimes(3); + expect(mockTaskManager.ensureScheduled).toHaveBeenCalledTimes(3); + expect(mockScheduleRetry).toHaveBeenCalledTimes(2); + }); + }); + + describe('stop()', () => { + let mockSessionIndexInitialize: jest.SpyInstance; + let mockTaskManager: jest.Mocked; + beforeEach(() => { + mockSessionIndexInitialize = jest.spyOn(SessionIndex.prototype, 'initialize'); + + mockTaskManager = taskManagerMock.createStart(); + mockTaskManager.ensureScheduled.mockResolvedValue(undefined as any); + + const mockCoreSetup = coreMock.createSetup(); + service.setup({ + clusterClient: elasticsearchServiceMock.createLegacyClusterClient(), + http: mockCoreSetup.http, + config: createConfig(ConfigSchema.validate({}), loggingSystemMock.createLogger(), { + isTLSEnabled: false, + }), + kibanaIndexName: '.kibana', + taskManager: taskManagerMock.createSetup(), + }); + }); + + afterEach(() => { + mockSessionIndexInitialize.mockReset(); + }); + + it('properly unsubscribes from status updates', () => { + const mockStatusSubject = new Subject(); + service.start({ online$: mockStatusSubject.asObservable(), taskManager: mockTaskManager }); + + service.stop(); + + const mockScheduleRetry = jest.fn(); + mockStatusSubject.next({ scheduleRetry: mockScheduleRetry }); + + expect(mockSessionIndexInitialize).not.toHaveBeenCalled(); + expect(mockScheduleRetry).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/security/server/session_management/session_management_service.ts b/x-pack/plugins/security/server/session_management/session_management_service.ts new file mode 100644 index 0000000000000..6691b47638e27 --- /dev/null +++ b/x-pack/plugins/security/server/session_management/session_management_service.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable, Subscription } from 'rxjs'; +import { + HttpServiceSetup, + ILegacyClusterClient, + Logger, + SavedObjectsErrorHelpers, +} from '../../../../../src/core/server'; +import { TaskManagerSetupContract, TaskManagerStartContract } from '../../../task_manager/server'; +import { ConfigType } from '../config'; +import { OnlineStatusRetryScheduler } from '../elasticsearch'; +import { SessionCookie } from './session_cookie'; +import { SessionIndex } from './session_index'; +import { Session } from './session'; + +export interface SessionManagementServiceSetupParams { + readonly http: Pick; + readonly config: ConfigType; + readonly clusterClient: ILegacyClusterClient; + readonly kibanaIndexName: string; + readonly taskManager: TaskManagerSetupContract; +} + +export interface SessionManagementServiceStartParams { + readonly online$: Observable; + readonly taskManager: TaskManagerStartContract; +} + +export interface SessionManagementServiceSetup { + readonly session: Session; +} + +/** + * Name of the task that is periodically run and performs session index cleanup. + */ +export const SESSION_INDEX_CLEANUP_TASK_NAME = 'session_cleanup'; + +/** + * Service responsible for the user session management. + */ +export class SessionManagementService { + private statusSubscription?: Subscription; + private sessionIndex!: SessionIndex; + private config!: ConfigType; + + constructor(private readonly logger: Logger) {} + + setup({ + config, + clusterClient, + http, + kibanaIndexName, + taskManager, + }: SessionManagementServiceSetupParams): SessionManagementServiceSetup { + this.config = config; + + const sessionCookie = new SessionCookie({ + config, + createCookieSessionStorageFactory: http.createCookieSessionStorageFactory, + serverBasePath: http.basePath.serverBasePath || '/', + logger: this.logger.get('cookie'), + }); + + this.sessionIndex = new SessionIndex({ + config, + clusterClient, + kibanaIndexName, + logger: this.logger.get('index'), + }); + + // Register task that will perform periodic session index cleanup. + taskManager.registerTaskDefinitions({ + [SESSION_INDEX_CLEANUP_TASK_NAME]: { + title: 'Cleanup expired or invalid user sessions', + type: SESSION_INDEX_CLEANUP_TASK_NAME, + createTaskRunner: () => ({ run: () => this.sessionIndex.cleanUp() }), + }, + }); + + return { + session: new Session({ + logger: this.logger, + sessionCookie, + sessionIndex: this.sessionIndex, + config, + }), + }; + } + + start({ online$, taskManager }: SessionManagementServiceStartParams) { + this.statusSubscription = online$.subscribe(async ({ scheduleRetry }) => { + try { + await Promise.all([this.sessionIndex.initialize(), this.scheduleCleanupTask(taskManager)]); + } catch (err) { + scheduleRetry(); + } + }); + } + + stop() { + if (this.statusSubscription !== undefined) { + this.statusSubscription.unsubscribe(); + this.statusSubscription = undefined; + } + } + + private async scheduleCleanupTask(taskManager: TaskManagerStartContract) { + let currentTask; + try { + currentTask = await taskManager.get(SESSION_INDEX_CLEANUP_TASK_NAME); + } catch (err) { + if (!SavedObjectsErrorHelpers.isNotFoundError(err)) { + this.logger.error(`Failed to retrieve session index cleanup task: ${err.message}`); + throw err; + } + + this.logger.debug('Session index cleanup task is not scheduled yet.'); + } + + // Check if currently scheduled task is scheduled with the correct interval. + const cleanupInterval = `${this.config.session.cleanupInterval.asSeconds()}s`; + if (currentTask && currentTask.schedule?.interval !== cleanupInterval) { + this.logger.debug( + 'Session index cleanup interval has changed, the cleanup task will be rescheduled.' + ); + + try { + await taskManager.remove(SESSION_INDEX_CLEANUP_TASK_NAME); + } catch (err) { + // We may have multiple instances of Kibana that are removing old task definition at the + // same time. If we get 404 here then task was removed by another instance, it's fine. + if (!SavedObjectsErrorHelpers.isNotFoundError(err)) { + this.logger.error(`Failed to remove old session index cleanup task: ${err.message}`); + throw err; + } + } + } + + try { + await taskManager.ensureScheduled({ + id: SESSION_INDEX_CLEANUP_TASK_NAME, + taskType: SESSION_INDEX_CLEANUP_TASK_NAME, + scope: ['security'], + schedule: { interval: cleanupInterval }, + params: {}, + state: {}, + }); + } catch (err) { + this.logger.error(`Failed to register session index cleanup task: ${err.message}`); + throw err; + } + + this.logger.debug('Successfully scheduled session index cleanup task.'); + } +} diff --git a/x-pack/plugins/security_solution/cypress/integration/search_bar.spec.ts b/x-pack/plugins/security_solution/cypress/integration/search_bar.spec.ts index 9104f494e3e6b..15ca92714d4a3 100644 --- a/x-pack/plugins/security_solution/cypress/integration/search_bar.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/search_bar.spec.ts @@ -12,7 +12,7 @@ import { hostIpFilter } from '../objects/filter'; import { HOSTS_URL } from '../urls/navigation'; import { waitForAllHostsToBeLoaded } from '../tasks/hosts/all_hosts'; -describe.skip('SearchBar', () => { +describe('SearchBar', () => { before(() => { loginAndWaitForPage(HOSTS_URL); waitForAllHostsToBeLoaded(); @@ -21,6 +21,10 @@ describe.skip('SearchBar', () => { it('adds correctly a filter to the global search bar', () => { openAddFilterPopover(); fillAddFilterForm(hostIpFilter); - cy.get(GLOBAL_SEARCH_BAR_FILTER_ITEM(hostIpFilter)).should('be.visible'); + + cy.get(GLOBAL_SEARCH_BAR_FILTER_ITEM).should( + 'have.text', + `${hostIpFilter.key}: ${hostIpFilter.value}` + ); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/search_bar.ts b/x-pack/plugins/security_solution/cypress/screens/search_bar.ts index 35864749a4065..07e9de137826c 100644 --- a/x-pack/plugins/security_solution/cypress/screens/search_bar.ts +++ b/x-pack/plugins/security_solution/cypress/screens/search_bar.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SearchBarFilter } from '../objects/filter'; - export const GLOBAL_SEARCH_BAR_ADD_FILTER = '[data-test-subj="globalDatePicker"] [data-test-subj="addFilter"]'; @@ -28,5 +26,4 @@ export const ADD_FILTER_FORM_FILTER_VALUE_INPUT = '[data-test-subj="filterParams export const ADD_FILTER_FORM_SAVE_BUTTON = '[data-test-subj="saveFilter"]'; -export const GLOBAL_SEARCH_BAR_FILTER_ITEM = ({ key, value }: SearchBarFilter) => - `[data-test-subj="filter filter-enabled filter-key-${key} filter-value-${value} filter-unpinned"]`; +export const GLOBAL_SEARCH_BAR_FILTER_ITEM = '#popoverFor_filter0'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/hosts/all_hosts.ts b/x-pack/plugins/security_solution/cypress/tasks/hosts/all_hosts.ts index f9f902c3de8c7..27d17f966d8fc 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/hosts/all_hosts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/hosts/all_hosts.ts @@ -36,5 +36,5 @@ export const openFirstHostDetails = () => { }; export const waitForAllHostsToBeLoaded = () => { - cy.get(ALL_HOSTS_TABLE).should('exist'); + cy.get(ALL_HOSTS_TABLE).should('be.visible'); }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/login.ts b/x-pack/plugins/security_solution/cypress/tasks/login.ts index 4479c8d9d1fbe..f46d2c65c565f 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/login.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/login.ts @@ -78,8 +78,13 @@ const loginViaEnvironmentCredentials = () => { // programmatically authenticate without interacting with the Kibana login page cy.request({ body: { - username: Cypress.env(ELASTICSEARCH_USERNAME), - password: Cypress.env(ELASTICSEARCH_PASSWORD), + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { + username: Cypress.env(ELASTICSEARCH_USERNAME), + password: Cypress.env(ELASTICSEARCH_PASSWORD), + }, }, headers: { 'kbn-xsrf': 'cypress-creds-via-env' }, method: 'POST', @@ -104,8 +109,13 @@ const loginViaConfig = () => { // programmatically authenticate without interacting with the Kibana login page cy.request({ body: { - username: config.elasticsearch.username, - password: config.elasticsearch.password, + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { + username: config.elasticsearch.username, + password: config.elasticsearch.password, + }, }, headers: { 'kbn-xsrf': 'cypress-creds-via-config' }, method: 'POST', diff --git a/x-pack/plugins/security_solution/cypress/tasks/search_bar.ts b/x-pack/plugins/security_solution/cypress/tasks/search_bar.ts index a32c38a97fce5..4b194d1233f25 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/search_bar.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/search_bar.ts @@ -19,7 +19,8 @@ import { export const openAddFilterPopover = () => { cy.get(GLOBAL_SEARCH_BAR_SUBMIT_BUTTON).should('be.enabled'); - cy.get(GLOBAL_SEARCH_BAR_ADD_FILTER).click({ force: true }); + cy.get(GLOBAL_SEARCH_BAR_ADD_FILTER).should('be.visible'); + cy.get(GLOBAL_SEARCH_BAR_ADD_FILTER).click(); }; export const fillAddFilterForm = ({ key, value }: SearchBarFilter) => { @@ -32,4 +33,5 @@ export const fillAddFilterForm = ({ key, value }: SearchBarFilter) => { cy.get(ADD_FILTER_FORM_OPERATOR_OPTION_IS).click(); cy.get(ADD_FILTER_FORM_FILTER_VALUE_INPUT).type(value); cy.get(ADD_FILTER_FORM_SAVE_BUTTON).click(); + cy.get(ADD_FILTER_FORM_SAVE_BUTTON).should('not.exist'); }; diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap deleted file mode 100644 index bed5ac6950a2b..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap +++ /dev/null @@ -1,802 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PageView component should display body header custom element 1`] = ` -.c0.endpoint--isListView { - padding: 0 24px; -} - -.c0.endpoint--isListView .endpoint-header { - padding: 24px; - margin-bottom: 0; -} - -.c0.endpoint--isListView .endpoint-page-content { - border-left: none; - border-right: none; -} - -.c0.endpoint--isDetailsView .endpoint-page-content { - padding: 0; - border: none; - background: none; -} - -.c0 .endpoint-navTabs { - margin-left: 12px; -} - -.c0 .endpoint-header-leftSection { - overflow: hidden; -} - - - body header -

- } - viewType="list" -> - - -
- -
- - -
- -
- -
-

- body header -

-
-
-
-
- -
- body content -
-
-
-
-
-
-
-
-
-
-
-`; - -exports[`PageView component should display body header wrapped in EuiTitle 1`] = ` -.c0.endpoint--isListView { - padding: 0 24px; -} - -.c0.endpoint--isListView .endpoint-header { - padding: 24px; - margin-bottom: 0; -} - -.c0.endpoint--isListView .endpoint-page-content { - border-left: none; - border-right: none; -} - -.c0.endpoint--isDetailsView .endpoint-page-content { - padding: 0; - border: none; - background: none; -} - -.c0 .endpoint-navTabs { - margin-left: 12px; -} - -.c0 .endpoint-header-leftSection { - overflow: hidden; -} - - - - -
- -
- - -
- -
- -
- - -

- body header -

-
-
-
-
-
-
- -
- body content -
-
-
-
-
-
-
-
-
-
-
-`; - -exports[`PageView component should display header left and right 1`] = ` -.c0.endpoint--isListView { - padding: 0 24px; -} - -.c0.endpoint--isListView .endpoint-header { - padding: 24px; - margin-bottom: 0; -} - -.c0.endpoint--isListView .endpoint-page-content { - border-left: none; - border-right: none; -} - -.c0.endpoint--isDetailsView .endpoint-page-content { - padding: 0; - border: none; - background: none; -} - -.c0 .endpoint-navTabs { - margin-left: 12px; -} - -.c0 .endpoint-header-leftSection { - overflow: hidden; -} - - - - -
- -
- -
- -
- - -

- page title -

-
-
-
-
- -
- right side actions -
-
-
-
- - -
- -
- body content -
-
-
-
-
-
-
-
-
-
-
-`; - -exports[`PageView component should display only body if not header props used 1`] = ` -.c0.endpoint--isListView { - padding: 0 24px; -} - -.c0.endpoint--isListView .endpoint-header { - padding: 24px; - margin-bottom: 0; -} - -.c0.endpoint--isListView .endpoint-page-content { - border-left: none; - border-right: none; -} - -.c0.endpoint--isDetailsView .endpoint-page-content { - padding: 0; - border: none; - background: none; -} - -.c0 .endpoint-navTabs { - margin-left: 12px; -} - -.c0 .endpoint-header-leftSection { - overflow: hidden; -} - - - - -
- -
- - -
- -
- body content -
-
-
-
-
-
-
-
-
-
-
-`; - -exports[`PageView component should display only header left 1`] = ` -.c0.endpoint--isListView { - padding: 0 24px; -} - -.c0.endpoint--isListView .endpoint-header { - padding: 24px; - margin-bottom: 0; -} - -.c0.endpoint--isListView .endpoint-page-content { - border-left: none; - border-right: none; -} - -.c0.endpoint--isDetailsView .endpoint-page-content { - padding: 0; - border: none; - background: none; -} - -.c0 .endpoint-navTabs { - margin-left: 12px; -} - -.c0 .endpoint-header-leftSection { - overflow: hidden; -} - - - - -
- -
- -
- -
- - -

- page title -

-
-
-
-
-
-
- - -
- -
- body content -
-
-
-
-
-
-
-
-
-
-
-`; - -exports[`PageView component should display only header right but include an empty left side 1`] = ` -.c0.endpoint--isListView { - padding: 0 24px; -} - -.c0.endpoint--isListView .endpoint-header { - padding: 24px; - margin-bottom: 0; -} - -.c0.endpoint--isListView .endpoint-page-content { - border-left: none; - border-right: none; -} - -.c0.endpoint--isDetailsView .endpoint-page-content { - padding: 0; - border: none; - background: none; -} - -.c0 .endpoint-navTabs { - margin-left: 12px; -} - -.c0 .endpoint-header-leftSection { - overflow: hidden; -} - - - - -
- -
- -
- -
- - -
- right side actions -
-
-
- - - -
- -
- body content -
-
-
-
-
-
-
-
-
-
-
-`; - -exports[`PageView component should pass through EuiPage props 1`] = ` -.c0.endpoint--isListView { - padding: 0 24px; -} - -.c0.endpoint--isListView .endpoint-header { - padding: 24px; - margin-bottom: 0; -} - -.c0.endpoint--isListView .endpoint-page-content { - border-left: none; - border-right: none; -} - -.c0.endpoint--isDetailsView .endpoint-page-content { - padding: 0; - border: none; - background: none; -} - -.c0 .endpoint-navTabs { - margin-left: 12px; -} - -.c0 .endpoint-header-leftSection { - overflow: hidden; -} - - - - -
- -
- - -
- -
- body content -
-
-
-
-
-
-
-
-
-
-
-`; - -exports[`PageView component should use custom element for header left and not wrap in EuiTitle 1`] = ` -.c0.endpoint--isListView { - padding: 0 24px; -} - -.c0.endpoint--isListView .endpoint-header { - padding: 24px; - margin-bottom: 0; -} - -.c0.endpoint--isListView .endpoint-page-content { - border-left: none; - border-right: none; -} - -.c0.endpoint--isDetailsView .endpoint-page-content { - padding: 0; - border: none; - background: none; -} - -.c0 .endpoint-navTabs { - margin-left: 12px; -} - -.c0 .endpoint-header-leftSection { - overflow: hidden; -} - - - title here -

- } - viewType="list" -> - - -
- -
- -
- -
-

- title here -

-
-
-
-
- - -
- -
- body content -
-
-
-
-
-
-
-
-
-
-
-`; diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.test.tsx deleted file mode 100644 index 2c14f66b64865..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { mount } from 'enzyme'; -import { PageView } from './page_view'; -import { EuiThemeProvider } from '../../../../../../legacy/common/eui_styled_components'; - -describe('PageView component', () => { - const render = (ui: Parameters[0]) => - mount(ui, { wrappingComponent: EuiThemeProvider }); - - it('should display only body if not header props used', () => { - expect(render({'body content'})).toMatchSnapshot(); - }); - it('should display header left and right', () => { - expect( - render( - - {'body content'} - - ) - ).toMatchSnapshot(); - }); - it('should display only header left', () => { - expect( - render( - - {'body content'} - - ) - ).toMatchSnapshot(); - }); - it('should display only header right but include an empty left side', () => { - expect( - render( - - {'body content'} - - ) - ).toMatchSnapshot(); - }); - it(`should use custom element for header left and not wrap in EuiTitle`, () => { - expect( - render( - {'title here'}

}> - {'body content'} -
- ) - ).toMatchSnapshot(); - }); - it('should display body header wrapped in EuiTitle', () => { - expect( - render( - - {'body content'} - - ) - ).toMatchSnapshot(); - }); - it('should display body header custom element', () => { - expect( - render( - {'body header'}

}> - {'body content'} -
- ) - ).toMatchSnapshot(); - }); - it('should pass through EuiPage props', () => { - expect( - render( - - {'body content'} - - ) - ).toMatchSnapshot(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.tsx deleted file mode 100644 index d4753b3a64e24..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - EuiPage, - EuiPageBody, - EuiPageContent, - EuiPageContentBody, - EuiPageContentHeader, - EuiPageContentHeaderSection, - EuiPageHeader, - EuiPageHeaderSection, - EuiPageProps, - EuiTab, - EuiTabs, - EuiTitle, - EuiTitleProps, -} from '@elastic/eui'; -import React, { memo, MouseEventHandler, ReactNode, useMemo } from 'react'; -import styled from 'styled-components'; -import { EuiTabProps } from '@elastic/eui/src/components/tabs/tab'; - -const StyledEuiPage = styled(EuiPage)` - &.endpoint--isListView { - padding: 0 ${(props) => props.theme.eui.euiSizeL}; - - .endpoint-header { - padding: ${(props) => props.theme.eui.euiSizeL}; - margin-bottom: 0; - } - .endpoint-page-content { - border-left: none; - border-right: none; - } - } - &.endpoint--isDetailsView { - .endpoint-page-content { - padding: 0; - border: none; - background: none; - } - } - .endpoint-navTabs { - margin-left: ${(props) => props.theme.eui.euiSizeM}; - } - .endpoint-header-leftSection { - overflow: hidden; - } -`; - -const isStringOrNumber = /(string|number)/; - -/** - * The `PageView` component used to render `headerLeft` when it is set as a `string` - * Can be used when wanting to customize the `headerLeft` value but still use the standard - * title component - */ -export const PageViewHeaderTitle = memo & { children: ReactNode }>( - ({ children, size = 'l', ...otherProps }) => { - return ( - -

{children}

-
- ); - } -); - -PageViewHeaderTitle.displayName = 'PageViewHeaderTitle'; - -/** - * The `PageView` component used to render `bodyHeader` when it is set as a `string` - * Can be used when wanting to customize the `bodyHeader` value but still use the standard - * title component - */ -export const PageViewBodyHeaderTitle = memo<{ children: ReactNode }>( - ({ children, ...otherProps }) => { - return ( - -

{children}

-
- ); - } -); -PageViewBodyHeaderTitle.displayName = 'PageViewBodyHeaderTitle'; - -export type PageViewProps = EuiPageProps & { - /** - * The type of view - */ - viewType: 'list' | 'details'; - /** - * content to be placed on the left side of the header. If a `string` is used, then it will - * be wrapped with `

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

` - */ - bodyHeader?: ReactNode; - /** - * The list of tab navigation items - */ - tabs?: Array< - EuiTabProps & { - name: ReactNode; - id: string; - href?: string; - onClick?: MouseEventHandler; - } - >; - children?: ReactNode; -}; - -/** - * Page View layout for use in Endpoint - */ -export const PageView = memo( - ({ viewType, children, headerLeft, headerRight, bodyHeader, tabs, ...otherProps }) => { - const tabComponents = useMemo(() => { - if (!tabs) { - return []; - } - return tabs.map(({ name, id, ...otherEuiTabProps }) => ( - - {name} - - )); - }, [tabs]); - - return ( - - - {(headerLeft || headerRight) && ( - - - {isStringOrNumber.test(typeof headerLeft) ? ( - {headerLeft} - ) : ( - headerLeft - )} - - {headerRight && ( - - {headerRight} - - )} - - )} - {tabComponents.length > 0 && ( - {tabComponents} - )} - - {bodyHeader && ( - - - {isStringOrNumber.test(typeof bodyHeader) ? ( - {bodyHeader} - ) : ( - bodyHeader - )} - - - )} - {children} - - - - ); - } -); - -PageView.displayName = 'PageView'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_comments.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_comments.tsx index 22d14ec6bedb1..85ff3278ba45d 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_comments.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_comments.tsx @@ -112,7 +112,7 @@ export const AddExceptionComments = memo(function AddExceptionComments({ { + const ruleName = 'test rule'; + let defaultEndpointItems: jest.SpyInstance>; + let ExceptionBuilderComponent: jest.SpyInstance>; + beforeEach(() => { + defaultEndpointItems = jest.spyOn(helpers, 'defaultEndpointExceptionItems'); + ExceptionBuilderComponent = jest + .spyOn(builder, 'ExceptionBuilderComponent') + .mockReturnValue(<>); + + const kibanaMock = createUseKibanaMock()(); + useKibanaMock.mockImplementation(() => ({ + ...kibanaMock, + })); + (useAddOrUpdateException as jest.Mock).mockImplementation(() => [ + { isLoading: false }, + jest.fn(), + ]); + (useFetchOrCreateRuleExceptionList as jest.Mock).mockImplementation(() => [ + false, + getExceptionListSchemaMock(), + ]); + (useSignalIndex as jest.Mock).mockImplementation(() => ({ + loading: false, + signalIndexName: 'mock-siem-signals-index', + })); + (useFetchIndexPatterns as jest.Mock).mockImplementation(() => [ + { + isLoading: false, + indexPatterns: stubIndexPattern, + }, + ]); + (useCurrentUser as jest.Mock).mockReturnValue({ username: 'test-username' }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when the modal is loading', () => { + let wrapper: ReactWrapper; + beforeEach(() => { + // Mocks one of the hooks as loading + (useFetchIndexPatterns as jest.Mock).mockImplementation(() => [ + { + isLoading: true, + indexPatterns: stubIndexPattern, + }, + ]); + wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + }); + it('should show the loading spinner', () => { + expect(wrapper.find('[data-test-subj="loadingAddExceptionModal"]').exists()).toBeTruthy(); + }); + }); + + describe('when there is no alert data passed to an endpoint list exception', () => { + let wrapper: ReactWrapper; + beforeEach(() => { + wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + const callProps = ExceptionBuilderComponent.mock.calls[0][0]; + act(() => callProps.onChange({ exceptionItems: [] })); + }); + it('has the add exception button disabled', () => { + expect( + wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() + ).toBeDisabled(); + }); + it('should render the exception builder', () => { + expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); + }); + it('should not render the close on add exception checkbox', () => { + expect( + wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() + ).toBeFalsy(); + }); + it('should contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="add-exception-endpoint-text"]').exists()).toBeTruthy(); + }); + }); + + describe('when there is alert data passed to an endpoint list exception', () => { + let wrapper: ReactWrapper; + beforeEach(() => { + const alertDataMock: { ecsData: Ecs; nonEcsData: TimelineNonEcsData[] } = { + ecsData: { _id: 'test-id' }, + nonEcsData: [{ field: 'file.path', value: ['test/path'] }], + }; + wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + const callProps = ExceptionBuilderComponent.mock.calls[0][0]; + act(() => callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] })); + }); + it('has the add exception button enabled', () => { + expect( + wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() + ).not.toBeDisabled(); + }); + it('should render the exception builder', () => { + expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); + }); + it('should prepopulate endpoint items', () => { + expect(defaultEndpointItems).toHaveBeenCalled(); + }); + it('should render the close on add exception checkbox', () => { + expect( + wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() + ).toBeTruthy(); + }); + it('should have the bulk close checkbox disabled', () => { + expect( + wrapper + .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') + .getDOMNode() + ).toBeDisabled(); + }); + it('should contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="add-exception-endpoint-text"]').exists()).toBeTruthy(); + }); + }); + + describe('when there is alert data passed to a detection list exception', () => { + let wrapper: ReactWrapper; + beforeEach(() => { + const alertDataMock: { ecsData: Ecs; nonEcsData: TimelineNonEcsData[] } = { + ecsData: { _id: 'test-id' }, + nonEcsData: [{ field: 'file.path', value: ['test/path'] }], + }; + wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + const callProps = ExceptionBuilderComponent.mock.calls[0][0]; + act(() => callProps.onChange({ exceptionItems: [getExceptionListItemSchemaMock()] })); + }); + it('has the add exception button enabled', () => { + expect( + wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() + ).not.toBeDisabled(); + }); + it('should render the exception builder', () => { + expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); + }); + it('should not prepopulate endpoint items', () => { + expect(defaultEndpointItems).not.toHaveBeenCalled(); + }); + it('should render the close on add exception checkbox', () => { + expect( + wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() + ).toBeTruthy(); + }); + it('should have the bulk close checkbox disabled', () => { + expect( + wrapper + .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') + .getDOMNode() + ).toBeDisabled(); + }); + }); + + describe('when there is bulk-closeable alert data passed to an endpoint list exception', () => { + let wrapper: ReactWrapper; + let callProps: { + onChange: (props: { exceptionItems: ExceptionListItemSchema[] }) => void; + exceptionListItems: ExceptionListItemSchema[]; + }; + beforeEach(() => { + // Mocks the index patterns to contain the pre-populated endpoint fields so that the exception qualifies as bulk closable + (useFetchIndexPatterns as jest.Mock).mockImplementation(() => [ + { + isLoading: false, + indexPatterns: { + ...stubIndexPattern, + fields: [ + { name: 'file.path.text', type: 'string' }, + { name: 'subject_name', type: 'string' }, + { name: 'trusted', type: 'string' }, + { name: 'file.hash.sha256', type: 'string' }, + { name: 'event.code', type: 'string' }, + ], + }, + }, + ]); + const alertDataMock: { ecsData: Ecs; nonEcsData: TimelineNonEcsData[] } = { + ecsData: { _id: 'test-id' }, + nonEcsData: [{ field: 'file.path', value: ['test/path'] }], + }; + wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + callProps = ExceptionBuilderComponent.mock.calls[0][0]; + act(() => callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] })); + }); + it('has the add exception button enabled', () => { + expect( + wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode() + ).not.toBeDisabled(); + }); + it('should render the exception builder', () => { + expect(wrapper.find('[data-test-subj="alert-exception-builder"]').exists()).toBeTruthy(); + }); + it('should prepopulate endpoint items', () => { + expect(defaultEndpointItems).toHaveBeenCalled(); + }); + it('should render the close on add exception checkbox', () => { + expect( + wrapper.find('[data-test-subj="close-alert-on-add-add-exception-checkbox"]').exists() + ).toBeTruthy(); + }); + it('should contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="add-exception-endpoint-text"]').exists()).toBeTruthy(); + }); + it('should have the bulk close checkbox enabled', () => { + expect( + wrapper + .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') + .getDOMNode() + ).not.toBeDisabled(); + }); + describe('when a "is in list" entry is added', () => { + it('should have the bulk close checkbox disabled', () => { + act(() => + callProps.onChange({ + exceptionItems: [ + ...callProps.exceptionListItems, + { + ...getExceptionListItemSchemaMock(), + entries: [ + { field: 'event.code', operator: 'included', type: 'list' }, + ] as EntriesArray, + }, + ], + }) + ); + + expect( + wrapper + .find('input[data-test-subj="bulk-close-alert-on-add-add-exception-checkbox"]') + .getDOMNode() + ).toBeDisabled(); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index 7526c52d16fde..03051ead357c9 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -196,7 +196,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ setShouldDisableBulkClose( entryHasListType(exceptionItemsToAdd) || entryHasNonEcsType(exceptionItemsToAdd, signalIndexPatterns) || - exceptionItemsToAdd.length === 0 + exceptionItemsToAdd.every((item) => item.entries.length === 0) ); } }, [ @@ -344,6 +344,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ {alertData !== undefined && alertStatus !== 'closed' && ( - + {i18n.ENDPOINT_QUARANTINE_TEXT} @@ -380,6 +382,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ {i18n.CANCEL} { + const ruleName = 'test rule'; + + let ExceptionBuilderComponent: jest.SpyInstance>; + + beforeEach(() => { + ExceptionBuilderComponent = jest + .spyOn(builder, 'ExceptionBuilderComponent') + .mockReturnValue(<>); + + const kibanaMock = createUseKibanaMock()(); + useKibanaMock.mockImplementation(() => ({ + ...kibanaMock, + })); + (useSignalIndex as jest.Mock).mockReturnValue({ + loading: false, + signalIndexName: 'test-signal', + }); + (useAddOrUpdateException as jest.Mock).mockImplementation(() => [ + { isLoading: false }, + jest.fn(), + ]); + (useFetchIndexPatterns as jest.Mock).mockImplementation(() => [ + { + isLoading: false, + indexPatterns: stubIndexPatternWithFields, + }, + ]); + (useCurrentUser as jest.Mock).mockReturnValue({ username: 'test-username' }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('when the modal is loading', () => { + let wrapper: ReactWrapper; + beforeEach(() => { + (useFetchIndexPatterns as jest.Mock).mockImplementation(() => [ + { + isLoading: true, + indexPatterns: stubIndexPattern, + }, + ]); + wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + }); + it('renders the loading spinner', () => { + expect(wrapper.find('[data-test-subj="loadingEditExceptionModal"]').exists()).toBeTruthy(); + }); + }); + + describe('when an endpoint exception with exception data is passed', () => { + describe('when exception entry fields are included in the index pattern', () => { + let wrapper: ReactWrapper; + beforeEach(() => { + const exceptionItemMock = { + ...getExceptionListItemSchemaMock(), + entries: [ + { field: 'response', operator: 'included', type: 'match', value: '3' }, + ] as EntriesArray, + }; + wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + const callProps = ExceptionBuilderComponent.mock.calls[0][0]; + act(() => callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] })); + }); + it('has the edit exception button enabled', () => { + expect( + wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() + ).not.toBeDisabled(); + }); + it('should have the bulk close checkbox enabled', () => { + expect( + wrapper + .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') + .getDOMNode() + ).not.toBeDisabled(); + }); + it('renders the exceptions builder', () => { + expect( + wrapper.find('[data-test-subj="edit-exception-modal-builder"]').exists() + ).toBeTruthy(); + }); + it('should contain the endpoint specific documentation text', () => { + expect( + wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists() + ).toBeTruthy(); + }); + }); + + describe("when exception entry fields aren't included in the index pattern", () => { + let wrapper: ReactWrapper; + beforeEach(() => { + wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + const callProps = ExceptionBuilderComponent.mock.calls[0][0]; + act(() => callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] })); + }); + it('has the edit exception button enabled', () => { + expect( + wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() + ).not.toBeDisabled(); + }); + it('should have the bulk close checkbox disabled', () => { + expect( + wrapper + .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') + .getDOMNode() + ).toBeDisabled(); + }); + it('renders the exceptions builder', () => { + expect( + wrapper.find('[data-test-subj="edit-exception-modal-builder"]').exists() + ).toBeTruthy(); + }); + it('should contain the endpoint specific documentation text', () => { + expect( + wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists() + ).toBeTruthy(); + }); + }); + }); + + describe('when an detection exception with entries is passed', () => { + let wrapper: ReactWrapper; + beforeEach(() => { + wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + const callProps = ExceptionBuilderComponent.mock.calls[0][0]; + act(() => callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] })); + }); + it('has the edit exception button enabled', () => { + expect( + wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() + ).not.toBeDisabled(); + }); + it('renders the exceptions builder', () => { + expect(wrapper.find('[data-test-subj="edit-exception-modal-builder"]').exists()).toBeTruthy(); + }); + it('should not contain the endpoint specific documentation text', () => { + expect(wrapper.find('[data-test-subj="edit-exception-endpoint-text"]').exists()).toBeFalsy(); + }); + it('should have the bulk close checkbox disabled', () => { + expect( + wrapper + .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') + .getDOMNode() + ).toBeDisabled(); + }); + }); + + describe('when an exception with no entries is passed', () => { + let wrapper: ReactWrapper; + beforeEach(() => { + const exceptionItemMock = { ...getExceptionListItemSchemaMock(), entries: [] }; + wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + const callProps = ExceptionBuilderComponent.mock.calls[0][0]; + act(() => callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] })); + }); + it('has the edit exception button disabled', () => { + expect( + wrapper.find('button[data-test-subj="edit-exception-confirm-button"]').getDOMNode() + ).toBeDisabled(); + }); + it('renders the exceptions builder', () => { + expect(wrapper.find('[data-test-subj="edit-exception-modal-builder"]').exists()).toBeTruthy(); + }); + it('should have the bulk close checkbox disabled', () => { + expect( + wrapper + .find('input[data-test-subj="close-alert-on-add-edit-exception-checkbox"]') + .getDOMNode() + ).toBeDisabled(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index e1352ac38dc49..a2c8531def2ba 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -137,7 +137,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ setShouldDisableBulkClose( entryHasListType(exceptionItemsToAdd) || entryHasNonEcsType(exceptionItemsToAdd, signalIndexPatterns) || - exceptionItemsToAdd.length === 0 + exceptionItemsToAdd.every((item) => item.entries.length === 0) ); } }, [ @@ -259,7 +259,8 @@ export const EditExceptionModal = memo(function EditExceptionModal({ - + {i18n.ENDPOINT_QUARANTINE_TEXT} @@ -292,6 +293,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ {i18n.CANCEL} { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); const refetch = jest.fn(); const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); @@ -59,9 +56,9 @@ describe('Inspect Button', () => { }); test('Eui Empty Button', () => { const wrapper = mount( - + - + ); expect(wrapper.find('button[data-test-subj="inspect-empty-button"]').first().exists()).toBe( true @@ -70,9 +67,9 @@ describe('Inspect Button', () => { test('it does NOT render the Eui Empty Button when timeline is timeline and compact is true', () => { const wrapper = mount( - + - + ); expect(wrapper.find('button[data-test-subj="inspect-empty-button"]').first().exists()).toBe( false @@ -81,9 +78,9 @@ describe('Inspect Button', () => { test('Eui Icon Button', () => { const wrapper = mount( - + - + ); expect(wrapper.find('button[data-test-subj="inspect-icon-button"]').first().exists()).toBe( true @@ -92,9 +89,9 @@ describe('Inspect Button', () => { test('renders the Icon Button when inputId does NOT equal global, but compact is true', () => { const wrapper = mount( - + - + ); expect(wrapper.find('button[data-test-subj="inspect-icon-button"]').first().exists()).toBe( true @@ -103,18 +100,18 @@ describe('Inspect Button', () => { test('Eui Empty Button disabled', () => { const wrapper = mount( - + - + ); expect(wrapper.find('.euiButtonIcon').get(0).props.disabled).toBe(true); }); test('Eui Icon Button disabled', () => { const wrapper = mount( - + - + ); expect(wrapper.find('.euiButtonIcon').get(0).props.disabled).toBe(true); }); @@ -122,11 +119,11 @@ describe('Inspect Button', () => { describe('InspectButtonContainer', () => { test('it renders a transparent inspect button by default', async () => { const wrapper = mount( - + - + ); expect(wrapper.find(`InspectButtonContainer`)).toHaveStyleRule('opacity', '0', { @@ -136,11 +133,11 @@ describe('Inspect Button', () => { test('it renders an opaque inspect button when it has mouse focus', async () => { const wrapper = mount( - + - + ); expect(wrapper.find(`InspectButtonContainer`)).toHaveStyleRule('opacity', '1', { @@ -169,11 +166,9 @@ describe('Inspect Button', () => { }); test('Open Inspect Modal', () => { const wrapper = mount( - - - - - + + + ); wrapper.find('button[data-test-subj="inspect-icon-button"]').first().simulate('click'); @@ -187,11 +182,9 @@ describe('Inspect Button', () => { test('Close Inspect Modal', () => { const wrapper = mount( - - - - - + + + ); wrapper.find('button[data-test-subj="inspect-icon-button"]').first().simulate('click'); @@ -209,9 +202,9 @@ describe('Inspect Button', () => { test('Do not Open Inspect Modal if it is loading', () => { const wrapper = mount( - + - + ); store.getState().inputs.global.queries[0].loading = true; wrapper.find('button[data-test-subj="inspect-icon-button"]').first().simulate('click'); diff --git a/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx index 373c1f7aaec75..f3136b0a40b3e 100644 --- a/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx @@ -7,14 +7,15 @@ import classNames from 'classnames'; import React, { useEffect } from 'react'; import styled from 'styled-components'; +import { CommonProps } from '@elastic/eui'; import { useFullScreen } from '../../containers/use_full_screen'; import { gutterTimeline } from '../../lib/helpers'; import { AppGlobalStyle } from '../page/index'; const Wrapper = styled.div` - padding: ${({ theme }) => - `${theme.eui.paddingSizes.l} ${gutterTimeline} ${theme.eui.paddingSizes.l} ${theme.eui.paddingSizes.l}`}; + padding: ${(props) => `${props.theme.eui.paddingSizes.l}`}; + &.siemWrapperPage--restrictWidthDefault, &.siemWrapperPage--restrictWidthCustom { box-sizing: content-box; @@ -29,6 +30,10 @@ const Wrapper = styled.div` height: 100%; } + &.siemWrapperPage--withTimeline { + padding-right: ${gutterTimeline}; + } + &.siemWrapperPage--noPadding { padding: 0; } @@ -38,18 +43,20 @@ Wrapper.displayName = 'Wrapper'; interface WrapperPageProps { children: React.ReactNode; - className?: string; restrictWidth?: boolean | number | string; style?: Record; noPadding?: boolean; + noTimeline?: boolean; } -const WrapperPageComponent: React.FC = ({ +const WrapperPageComponent: React.FC = ({ children, className, restrictWidth, style, noPadding, + noTimeline, + ...otherProps }) => { const { globalFullScreen, setGlobalFullScreen } = useFullScreen(); useEffect(() => { @@ -59,6 +66,7 @@ const WrapperPageComponent: React.FC = ({ const classes = classNames(className, { siemWrapperPage: true, 'siemWrapperPage--noPadding': noPadding, + 'siemWrapperPage--withTimeline': !noTimeline, 'siemWrapperPage--fullHeight': globalFullScreen, 'siemWrapperPage--restrictWidthDefault': restrictWidth && typeof restrictWidth === 'boolean' && restrictWidth === true, @@ -73,7 +81,7 @@ const WrapperPageComponent: React.FC = ({ } return ( - + {children} diff --git a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx index 297dc235a2a50..010d2fac18af5 100644 --- a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx @@ -74,23 +74,6 @@ const TestProvidersComponent: React.FC = ({ export const TestProviders = React.memo(TestProvidersComponent); -const TestProviderWithoutDragAndDropComponent: React.FC = ({ - children, - store = createStore( - state, - SUB_PLUGINS_REDUCER, - apolloClientObservable, - kibanaObservable, - storage - ), -}) => ( - - {children} - -); - -export const TestProviderWithoutDragAndDrop = React.memo(TestProviderWithoutDragAndDropComponent); - export const useFormFieldMock = (options?: Partial): FieldHook => { const { form } = useForm(); diff --git a/x-pack/plugins/security_solution/public/management/common/constants.ts b/x-pack/plugins/security_solution/public/management/common/constants.ts index 88396cc24a5e2..39d42114f9939 100644 --- a/x-pack/plugins/security_solution/public/management/common/constants.ts +++ b/x-pack/plugins/security_solution/public/management/common/constants.ts @@ -13,6 +13,7 @@ export const MANAGEMENT_ROUTING_ROOT_PATH = ''; export const MANAGEMENT_ROUTING_ENDPOINTS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${AdministrationSubTab.endpoints})`; export const MANAGEMENT_ROUTING_POLICIES_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${AdministrationSubTab.policies})`; export const MANAGEMENT_ROUTING_POLICY_DETAILS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId`; +export const MANAGEMENT_ROUTING_TRUSTED_APPS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${AdministrationSubTab.trustedApps})`; // --[ STORE ]--------------------------------------------------------------------------- /** The SIEM global store namespace where the management state will be mounted */ diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index ea162422abb6f..c5ced6f3bcf55 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -13,6 +13,7 @@ import { MANAGEMENT_ROUTING_ENDPOINTS_PATH, MANAGEMENT_ROUTING_POLICIES_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, + MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, } from './constants'; import { AdministrationSubTab } from '../types'; import { appendSearch } from '../../common/components/link_to/helpers'; @@ -72,13 +73,21 @@ export const getEndpointDetailsPath = ( })}${appendSearch(`${urlQueryParams ? `${urlQueryParams}${urlSearch}` : urlSearch}`)}`; }; -export const getPoliciesPath = (search?: string) => - `${generatePath(MANAGEMENT_ROUTING_POLICIES_PATH, { +export const getPoliciesPath = (search?: string) => { + return `${generatePath(MANAGEMENT_ROUTING_POLICIES_PATH, { tabName: AdministrationSubTab.policies, })}${appendSearch(search)}`; +}; -export const getPolicyDetailPath = (policyId: string, search?: string) => - `${generatePath(MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, { +export const getPolicyDetailPath = (policyId: string, search?: string) => { + return `${generatePath(MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, { tabName: AdministrationSubTab.policies, policyId, })}${appendSearch(search)}`; +}; + +export const getTrustedAppsListPath = (search?: string) => { + return `${generatePath(MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, { + tabName: AdministrationSubTab.trustedApps, + })}${appendSearch(search)}`; +}; diff --git a/x-pack/plugins/security_solution/public/management/common/translations.ts b/x-pack/plugins/security_solution/public/management/common/translations.ts index 03f6a80ef99a4..d24eb1bd315fa 100644 --- a/x-pack/plugins/security_solution/public/management/common/translations.ts +++ b/x-pack/plugins/security_solution/public/management/common/translations.ts @@ -13,3 +13,11 @@ export const ENDPOINTS_TAB = i18n.translate('xpack.securitySolution.endpointsTab export const POLICIES_TAB = i18n.translate('xpack.securitySolution.policiesTab', { defaultMessage: 'Policies', }); + +export const TRUSTED_APPS_TAB = i18n.translate('xpack.securitySolution.trustedAppsTab', { + defaultMessage: 'Trusted applications', +}); + +export const BETA_BADGE_LABEL = i18n.translate('xpack.securitySolution.administration.list.beta', { + defaultMessage: 'Beta', +}); diff --git a/x-pack/plugins/security_solution/public/management/components/administration_list_page.tsx b/x-pack/plugins/security_solution/public/management/components/administration_list_page.tsx new file mode 100644 index 0000000000000..3df525b4d59d6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/administration_list_page.tsx @@ -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 React, { FC, memo } from 'react'; +import { EuiPanel, EuiSpacer, CommonProps } from '@elastic/eui'; +import { SecurityPageName } from '../../../common/constants'; +import { WrapperPage } from '../../common/components/wrapper_page'; +import { HeaderPage } from '../../common/components/header_page'; +import { SiemNavigation } from '../../common/components/navigation'; +import { SpyRoute } from '../../common/utils/route/spy_routes'; +import { AdministrationSubTab } from '../types'; +import { ENDPOINTS_TAB, TRUSTED_APPS_TAB, BETA_BADGE_LABEL } from '../common/translations'; +import { getEndpointListPath, getTrustedAppsListPath } from '../common/routing'; + +interface AdministrationListPageProps { + beta: boolean; + title: React.ReactNode; + subtitle: React.ReactNode; + actions?: React.ReactNode; +} + +export const AdministrationListPage: FC = memo( + ({ beta, title, subtitle, actions, children, ...otherProps }) => { + const badgeOptions = !beta ? undefined : { beta: true, text: BETA_BADGE_LABEL }; + + return ( + + + {actions} + + + + + + + {children} + + + + ); + } +); + +AdministrationListPage.displayName = 'AdministrationListPage'; diff --git a/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx b/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx deleted file mode 100644 index 54d9131209d0d..0000000000000 --- a/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { memo } from 'react'; -import { EuiErrorBoundary } from '@elastic/eui'; -import { PageView, PageViewProps } from '../../common/components/endpoint/page_view'; - -export const ManagementPageView = memo>((options) => { - return ( - - - - ); -}); - -ManagementPageView.displayName = 'ManagementPageView'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 09df6d6ece042..fe06bcc8131f8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import * as reactTestingLibrary from '@testing-library/react'; import { EndpointList } from './index'; +import '../../../../common/mock/match_media.ts'; import { mockEndpointDetailsApiResult, mockEndpointResultList, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index a923d49012d70..611e69391ab06 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -10,15 +10,10 @@ import { EuiBasicTable, EuiBasicTableColumn, EuiText, - EuiTitle, - EuiSpacer, EuiLink, EuiHealth, EuiToolTip, EuiSelectableProps, - EuiBetaBadge, - EuiFlexGroup, - EuiFlexItem, } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; @@ -36,8 +31,6 @@ import { import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; import { CreateStructuredSelector } from '../../../../common/store'; import { Immutable, HostInfo } from '../../../../../common/endpoint/types'; -import { SpyRoute } from '../../../../common/utils/route/spy_routes'; -import { ManagementPageView } from '../../../components/management_page_view'; import { PolicyEmptyState, HostsEmptyState } from '../../../components/management_empty_state'; import { FormattedDate } from '../../../../common/components/formatted_date'; import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; @@ -50,6 +43,7 @@ import { getEndpointListPath, getEndpointDetailsPath } from '../../../common/rou import { useFormatUrl } from '../../../../common/components/link_to'; import { EndpointAction } from '../store/action'; import { EndpointPolicyLink } from './components/endpoint_policy_link'; +import { AdministrationListPage } from '../../../components/administration_list_page'; const EndpointListNavLink = memo<{ name: string; @@ -375,40 +369,20 @@ export const EndpointList = () => { ]); return ( - - - - -

- -

-
-
- - - -
- - -

- -

-
- + beta={true} + title={ + + } + subtitle={ + } > {hasSelectedEndpoint && } @@ -425,7 +399,6 @@ export const EndpointList = () => { )} {renderTableOrEmptyState} - -
+ ); }; diff --git a/x-pack/plugins/security_solution/public/management/pages/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/index.test.tsx index 9e496ce6c0b50..c04d3b1ec1a90 100644 --- a/x-pack/plugins/security_solution/public/management/pages/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/index.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { ManagementContainer } from './index'; +import '../../common/mock/match_media.ts'; import { AppContextTestRender, createAppRootMockRenderer } from '../../common/mock/endpoint'; import { useIngestEnabledCheck } from '../../common/hooks/endpoint/ingest_enabled'; @@ -22,15 +23,13 @@ describe('when in the Admistration tab', () => { it('should display the No Permissions view when Ingest is OFF', async () => { (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: false }); - const renderResult = render(); - const noIngestPermissions = await renderResult.findByTestId('noIngestPermissions'); - expect(noIngestPermissions).not.toBeNull(); + + expect(await render().findByTestId('noIngestPermissions')).not.toBeNull(); }); it('should display the Management view when Ingest is ON', async () => { (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: true }); - const renderResult = render(); - const endpointPage = await renderResult.findByTestId('endpointPage'); - expect(endpointPage).not.toBeNull(); + + expect(await render().findByTestId('endpointPage')).not.toBeNull(); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/index.tsx b/x-pack/plugins/security_solution/public/management/pages/index.tsx index c20a3dd31d6a4..959753cba7bd7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/index.tsx @@ -11,55 +11,50 @@ import { useHistory, Route, Switch } from 'react-router-dom'; import { ChromeBreadcrumb } from 'kibana/public'; import { EuiText, EuiEmptyPrompt } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { PolicyContainer } from './policy'; import { MANAGEMENT_ROUTING_ENDPOINTS_PATH, MANAGEMENT_ROUTING_POLICIES_PATH, MANAGEMENT_ROUTING_ROOT_PATH, + MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, } from '../common/constants'; import { NotFoundPage } from '../../app/404'; import { EndpointsContainer } from './endpoint_hosts'; +import { PolicyContainer } from './policy'; +import { TrustedAppsContainer } from './trusted_apps'; import { getEndpointListPath } from '../common/routing'; import { APP_ID, SecurityPageName } from '../../../common/constants'; import { GetUrlForApp } from '../../common/components/navigation/types'; import { AdministrationRouteSpyState } from '../../common/utils/route/types'; import { ADMINISTRATION } from '../../app/home/translations'; import { AdministrationSubTab } from '../types'; -import { ENDPOINTS_TAB, POLICIES_TAB } from '../common/translations'; +import { ENDPOINTS_TAB, POLICIES_TAB, TRUSTED_APPS_TAB } from '../common/translations'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { useIngestEnabledCheck } from '../../common/hooks/endpoint/ingest_enabled'; -const TabNameMappedToI18nKey: Record = { +const TabNameMappedToI18nKey: Record = { [AdministrationSubTab.endpoints]: ENDPOINTS_TAB, [AdministrationSubTab.policies]: POLICIES_TAB, + [AdministrationSubTab.trustedApps]: TRUSTED_APPS_TAB, }; -export const getBreadcrumbs = ( +export function getBreadcrumbs( params: AdministrationRouteSpyState, search: string[], getUrlForApp: GetUrlForApp -): ChromeBreadcrumb[] => { - let breadcrumb = [ +): ChromeBreadcrumb[] { + return [ { text: ADMINISTRATION, href: getUrlForApp(`${APP_ID}:${SecurityPageName.administration}`, { path: !isEmpty(search[0]) ? search[0] : '', }), }, - ]; - - const tabName = params?.tabName; - if (!tabName) return breadcrumb; - - breadcrumb = [ - ...breadcrumb, - { + ...(params?.tabName ? [params?.tabName] : []).map((tabName) => ({ text: TabNameMappedToI18nKey[tabName], href: '', - }, + })), ]; - return breadcrumb; -}; +} const NoPermissions = memo(() => { return ( @@ -104,6 +99,7 @@ export const ManagementContainer = memo(() => { + { it('should display back to list button and policy title', () => { policyView.update(); - const pageHeaderLeft = policyView.find( - 'EuiPageHeaderSection[data-test-subj="pageViewHeaderLeft"]' - ); - const backToListButton = pageHeaderLeft.find('EuiButtonEmpty'); - expect(backToListButton.prop('iconType')).toBe('arrowLeft'); - expect(backToListButton.prop('href')).toBe(endpointListPath); - expect(backToListButton.text()).toBe('Back to endpoint hosts'); + const backToListLink = policyView.find('LinkIcon[dataTestSubj="policyDetailsBackLink"]'); + expect(backToListLink.prop('iconType')).toBe('arrowLeft'); + expect(backToListLink.prop('href')).toBe(endpointListPath); + expect(backToListLink.text()).toBe('Back to endpoint hosts'); - const pageTitle = pageHeaderLeft.find('[data-test-subj="pageViewHeaderLeftTitle"]'); + const pageTitle = policyView.find('h1[data-test-subj="header-page-title"]'); expect(pageTitle).toHaveLength(1); expect(pageTitle.text()).toEqual(policyPackageConfig.name); }); it('should navigate to list if back to link is clicked', async () => { policyView.update(); - const backToListButton = policyView.find( - 'EuiPageHeaderSection[data-test-subj="pageViewHeaderLeft"] EuiButtonEmpty' - ); + + const backToListLink = policyView.find('a[data-test-subj="policyDetailsBackLink"]'); expect(history.location.pathname).toEqual(policyDetailsPathUrl); - backToListButton.simulate('click', { button: 0 }); + backToListLink.simulate('click', { button: 0 }); expect(history.location.pathname).toEqual(endpointListPath); }); it('should display agent stats', async () => { await asyncActions; policyView.update(); - const headerRight = policyView.find( - 'EuiPageHeaderSection[data-test-subj="pageViewHeaderRight"]' - ); - const agentsSummary = headerRight.find('EuiFlexGroup[data-test-subj="policyAgentsSummary"]'); + + const agentsSummary = policyView.find('EuiFlexGroup[data-test-subj="policyAgentsSummary"]'); expect(agentsSummary).toHaveLength(1); expect(agentsSummary.text()).toBe('Endpoints5Online3Offline1Error1'); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx index e54e684d788c0..636c0e5e6a0c3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx @@ -38,9 +38,6 @@ import { WindowsEvents, MacEvents, LinuxEvents } from './policy_forms/events'; import { MalwareProtections } from './policy_forms/protections/malware'; import { useToasts } from '../../../../common/lib/kibana'; import { AppAction } from '../../../../common/store/actions'; -import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; -import { PageViewHeaderTitle } from '../../../../common/components/endpoint/page_view'; -import { ManagementPageView } from '../../../components/management_page_view'; import { SpyRoute } from '../../../../common/utils/route/spy_routes'; import { SecurityPageName } from '../../../../app/types'; import { getEndpointListPath } from '../../../common/routing'; @@ -48,6 +45,8 @@ import { useFormatUrl } from '../../../../common/components/link_to'; import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; import { MANAGEMENT_APP_ID } from '../../../common/constants'; import { PolicyDetailsRouteState } from '../../../../../common/endpoint/types'; +import { WrapperPage } from '../../../../common/components/wrapper_page'; +import { HeaderPage } from '../../../../common/components/header_page'; export const PolicyDetails = React.memo(() => { const dispatch = useDispatch<(action: AppAction) => void>(); @@ -109,8 +108,6 @@ export const PolicyDetails = React.memo(() => { } }, [navigateToApp, toasts, policyName, policyUpdateStatus, routeState]); - const handleBackToListOnClick = useNavigateByRouterEventHandler(hostListRouterPath); - const navigateToAppArguments = useMemo((): Parameters => { return routeState?.onCancelNavigateTo ?? [MANAGEMENT_APP_ID, { path: hostListRouterPath }]; }, [hostListRouterPath, routeState?.onCancelNavigateTo]); @@ -142,7 +139,7 @@ export const PolicyDetails = React.memo(() => { // Else, if we have an error, then show error on the page. if (!policyItem) { return ( - + {isPolicyLoading ? ( ) : policyApiError ? ( @@ -151,28 +148,10 @@ export const PolicyDetails = React.memo(() => { ) : null} - + ); } - const headerLeftContent = ( -
- {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} - - - - {policyItem.name} -
- ); - const headerRightContent = ( @@ -222,12 +201,21 @@ export const PolicyDetails = React.memo(() => { onConfirm={handleSaveConfirmation} /> )} - + + + {headerRightContent} + +

{ />

+ +

{ />

+ -
+ + ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx index 97eaceff91e9c..b0139bf3ecb21 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { PolicyList } from './index'; +import '../../../../common/mock/match_media.ts'; import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { setPolicyListApiMockImplementation } from '../store/policy_list/test_mock_utils'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx index 39b77d259add1..b97ea958fd1c2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx @@ -8,7 +8,6 @@ import React, { useCallback, useEffect, useMemo, CSSProperties, useState } from import { EuiBasicTable, EuiText, - EuiTitle, EuiSpacer, EuiFlexGroup, EuiFlexItem, @@ -23,7 +22,6 @@ import { EuiConfirmModal, EuiCallOut, EuiButton, - EuiBetaBadge, EuiHorizontalRule, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -41,9 +39,7 @@ import { useKibana } from '../../../../../../../../src/plugins/kibana_react/publ import { Immutable, PolicyData } from '../../../../../common/endpoint/types'; import { useNavigateByRouterEventHandler } from '../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; import { LinkToApp } from '../../../../common/components/endpoint/link_to_app'; -import { ManagementPageView } from '../../../components/management_page_view'; import { PolicyEmptyState } from '../../../components/management_empty_state'; -import { SpyRoute } from '../../../../common/utils/route/spy_routes'; import { FormattedDateAndTime } from '../../../../common/components/endpoint/formatted_date_time'; import { SecurityPageName } from '../../../../app/types'; import { useFormatUrl } from '../../../../common/components/link_to'; @@ -51,6 +47,7 @@ import { getPolicyDetailPath, getPoliciesPath } from '../../../common/routing'; import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; import { CreatePackageConfigRouteState } from '../../../../../../ingest_manager/public'; import { MANAGEMENT_APP_ID } from '../../../common/constants'; +import { AdministrationListPage } from '../../../components/administration_list_page'; interface TableChangeCallbackArguments { page: { index: number; size: number }; @@ -405,42 +402,22 @@ export const PolicyList = React.memo(() => { }} /> )} - - - - -

- -

-
-
- - - -
- - -

- -

-
- + beta={true} + title={ + + } + subtitle={ + } - headerRight={ + actions={ { )} {bodyContent} - -
+ ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/index.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/index.tsx new file mode 100644 index 0000000000000..ddc16cc448c8a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/index.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Switch, Route } from 'react-router-dom'; +import React from 'react'; +import { TrustedAppsPage } from './view'; +import { MANAGEMENT_ROUTING_TRUSTED_APPS_PATH } from '../../common/constants'; +import { NotFoundPage } from '../../../app/404'; + +export function TrustedAppsContainer() { + return ( + + + + + ); +} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap new file mode 100644 index 0000000000000..6f074f3809036 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TrustedAppsPage rendering 1`] = ` + + } + title={ + + } +/> +`; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/index.ts new file mode 100644 index 0000000000000..af6f7e2863dfb --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under 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 './trusted_apps_page'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/routes.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx similarity index 51% rename from x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/routes.tsx rename to x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index 727d2715f0974..cc7dbd42d7a7d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/routes.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -3,16 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import { shallow } from 'enzyme'; import React from 'react'; -import { Route, Switch } from 'react-router-dom'; -import { EndpointList } from './view'; +import { TrustedAppsPage } from './trusted_apps_page'; -export const EndpointHostsRoutes: React.FC = () => ( - - - - - -); +describe('TrustedAppsPage', () => { + test('rendering', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx new file mode 100644 index 0000000000000..7045fa49ffad3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AdministrationListPage } from '../../../components/administration_list_page'; + +export function TrustedAppsPage() { + return ( + + } + subtitle={ + + } + /> + ); +} diff --git a/x-pack/plugins/security_solution/public/management/types.ts b/x-pack/plugins/security_solution/public/management/types.ts index c35c9277b4488..21214241d1981 100644 --- a/x-pack/plugins/security_solution/public/management/types.ts +++ b/x-pack/plugins/security_solution/public/management/types.ts @@ -27,6 +27,7 @@ export type ManagementState = CombinedState<{ export enum AdministrationSubTab { endpoints = 'endpoints', policies = 'policy', + trustedApps = 'trusted_apps', } /** diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx index 43a03bb771501..47e92320c4c20 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx @@ -221,7 +221,14 @@ export class Simulator { } /** - * This manually runs the animation frames tied to a configurable timestamp in the future + * Lines that connect the nodes in the graph + */ + public edgeLines(): ReactWrapper { + return this.domNodes('[data-test-subj="resolver:graph:edgeline"]'); + } + + /** + * This manually runs the animation frames tied to a configurable timestamp in the future. */ public runAnimationFramesTimeFromNow(time: number = 0) { this.sideEffectSimulator.controls.time = time; diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 30634e722050f..97d97700b11ae 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -221,7 +221,7 @@ export type Vector2 = readonly [number, number]; */ export interface AABB { /** - * Vector who's `x` component is the _left_ side of the `AABB` and who's `y` component is the _bottom_ side of the `AABB`. + * Vector whose `x` component represents the minimum side of the box and whose 'y' component represents the maximum side of the box. **/ readonly minimum: Vector2; /** diff --git a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx index 3265ee8bcfca0..7a2301b7bb515 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx @@ -10,6 +10,7 @@ import { Simulator } from '../test_utilities/simulator'; import '../test_utilities/extend_jest'; import { noAncestorsTwoChildrenWithRelatedEventsOnOrigin } from '../data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin'; import { urlSearch } from '../test_utilities/url_search'; +import { Vector2, AABB } from '../types'; let simulator: Simulator; let databaseDocumentID: string; @@ -136,6 +137,11 @@ describe('Resolver, when analyzing a tree that has two related events for the or }); describe('when it has loaded', () => { + let originBounds: AABB; + let firstChildBounds: AABB; + let secondChildBounds: AABB; + let edgeStartingCoordinates: Vector2[]; + let edgeTerminalCoordinates: Vector2[]; beforeEach(async () => { await expect( simulator.map(() => ({ @@ -150,6 +156,31 @@ describe('Resolver, when analyzing a tree that has two related events for the or graphErrorElements: 0, originNodeButton: 1, }); + + originBounds = computedNodeBoundaries(entityIDs.origin); + firstChildBounds = computedNodeBoundaries(entityIDs.firstChild); + secondChildBounds = computedNodeBoundaries(entityIDs.secondChild); + edgeStartingCoordinates = computedEdgeStartingCoordinates(); + edgeTerminalCoordinates = computedEdgeTerminalCoordinates(); + }); + + it('should have one and only one outgoing edge from the origin node', () => { + // This winnows edges to the one(s) that "start" under the origin node + const edgesThatStartUnderneathOrigin = edgeStartingCoordinates.filter( + coordinateBoundaryFilter(originBounds) + ); + expect(edgesThatStartUnderneathOrigin).toHaveLength(1); + }); + it('leaf nodes should each have one and only one incoming edge', () => { + const edgesThatTerminateUnderneathFirstChild = edgeTerminalCoordinates.filter( + coordinateBoundaryFilter(firstChildBounds) + ); + expect(edgesThatTerminateUnderneathFirstChild).toHaveLength(1); + + const edgesThatTerminateUnderneathSecondChild = edgeTerminalCoordinates.filter( + coordinateBoundaryFilter(secondChildBounds) + ); + expect(edgesThatTerminateUnderneathSecondChild).toHaveLength(1); }); it('should render a related events button', async () => { @@ -195,3 +226,68 @@ describe('Resolver, when analyzing a tree that has two related events for the or }); }); }); + +/** + * Get the integer in a CSS px unit string + * @param px a string with `px` preceded by numbers + */ +function pxNum(px: string): number { + return parseInt(px.match(/\d+/)![0], 10); +} + +/** + * Get computed boundaries for process node elements + */ +function computedNodeBoundaries(entityID: string): AABB { + const { left, top, width, height } = getComputedStyle( + simulator.processNodeElements({ entityID }).getDOMNode() + ); + return { + minimum: [pxNum(left), pxNum(top)], + maximum: [pxNum(left) + pxNum(width), pxNum(top) + pxNum(height)], + }; +} + +/** + * Coordinates for where the edgelines "start" + */ +function computedEdgeStartingCoordinates(): Vector2[] { + return simulator.edgeLines().map((edge) => { + const { left, top } = getComputedStyle(edge.getDOMNode()); + return [pxNum(left), pxNum(top)]; + }); +} + +/** + * Coordinates for where edgelines "end" (after application of transform) + */ +function computedEdgeTerminalCoordinates(): Vector2[] { + return simulator.edgeLines().map((edge) => { + const { left, top, width, transform } = getComputedStyle(edge.getDOMNode()); + /** + * Without the transform in the rotation, edgelines lay flat across the x-axis. + * Plotting the x/y of the line's terminal point here takes the rotation into account. + * This could cause tests to break if/when certain adjustments are made to the view that might + * regress the alignment of nodes and edges. + */ + const edgeLineRotationInRadians = parseFloat(transform.match(/rotateZ\((-?\d+\.?\d+)/i)![1]); + const rotateDownTo = Math.sin(edgeLineRotationInRadians) * pxNum(width); + const rotateLeftTo = Math.cos(edgeLineRotationInRadians) * pxNum(width); + return [pxNum(left) + rotateLeftTo, pxNum(top) + rotateDownTo]; + }); +} + +/** + * + * @param bounds Get a function that filters x/y of edges to those contained in a certain bounding box + */ +function coordinateBoundaryFilter(bounds: AABB) { + return (coords: Vector2) => { + return ( + coords[0] >= bounds.minimum[0] && + coords[0] <= bounds.maximum[0] && + coords[1] >= bounds.minimum[1] && + coords[1] <= bounds.maximum[1] + ); + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx b/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx index 061dfce64b4e4..fcc363a1560d5 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx @@ -131,6 +131,7 @@ const EdgeLineComponent = React.memo( style={style} resolverEdgeColor={colorMap.resolverEdge} magFactorX={magFactorX} + data-test-subj="resolver:graph:edgeline" > {elapsedTime && ( { getAllTimeline: originalModule.getAllTimeline, }; }); -jest.mock('../use_timeline_types', () => { - return { - useTimelineTypes: jest.fn().mockReturnValue({ - timelineType: 'default', - timelineTabs:
, - timelineFilters:
, - }), - }; -}); +jest.mock('../use_timeline_types', () => ({ + useTimelineTypes: jest.fn().mockReturnValue({ + timelineType: 'default', + timelineTabs:
, + timelineFilters:
, + }), +})); -jest.mock('../use_timeline_status', () => { - return { - useTimelineStatus: jest.fn(), - }; -}); +jest.mock('../use_timeline_status', () => ({ + useTimelineStatus: jest.fn(), +})); // mock for EuiSelectable's virtualization -jest.mock('react-virtualized-auto-sizer', () => { - return ({ +jest.mock( + 'react-virtualized-auto-sizer', + () => ({ children, }: { children: (dimensions: { width: number; height: number }) => ReactElement; - }) => children({ width: 100, height: 500 }); -}); + }) => children({ width: 100, height: 500 }) +); -// Failing: See https://github.com/elastic/kibana/issues/74814 -describe.skip('OpenTimelineModal', () => { - const theme = () => ({ eui: euiDarkVars, darkMode: true }); +describe('OpenTimelineModal', () => { const mockInstallPrepackagedTimelines = jest.fn(); beforeEach(() => { ((useGetAllTimeline as unknown) as jest.Mock).mockReturnValue({ @@ -84,45 +74,29 @@ describe.skip('OpenTimelineModal', () => { test('it renders the expected modal', async () => { const wrapper = mount( - - - - - - - + + + + + ); - await waitFor( - () => { - wrapper.update(); + wrapper.update(); - expect(wrapper.find('div[data-test-subj="open-timeline-modal"].euiModal').length).toEqual( - 1 - ); - }, - { timeout: 10000 } - ); - }, 20000); + expect(wrapper.find('div[data-test-subj="open-timeline-modal"].euiModal').length).toEqual(1); + }); test('it installs elastic prebuilt templates', async () => { const wrapper = mount( - - - - - - - + + + + + ); - await waitFor( - () => { - wrapper.update(); + wrapper.update(); - expect(mockInstallPrepackagedTimelines).toHaveBeenCalled(); - }, - { timeout: 10000 } - ); - }, 20000); + expect(mockInstallPrepackagedTimelines).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx index af2bd53df77db..ab07b4e756476 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx @@ -5,7 +5,7 @@ */ import { EuiModalBody, EuiModalHeader } from '@elastic/eui'; -import React, { memo, useMemo } from 'react'; +import React, { Fragment, memo, useMemo } from 'react'; import styled from 'styled-components'; import { OpenTimelineProps, ActionTimelineToShow } from '../types'; @@ -61,10 +61,10 @@ export const OpenTimelineModalBody = memo( const SearchRowContent = useMemo( () => ( - <> + {!!timelineFilter && timelineFilter} {!!templateTimelineFilter && templateTimelineFilter} - + ), [timelineFilter, templateTimelineFilter] ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_button.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_button.test.tsx index a3f180ce84c58..ea587aeca2061 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_button.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_button.test.tsx @@ -12,7 +12,7 @@ import { ThemeProvider } from 'styled-components'; // we don't have the types for waitFor just yet, so using "as waitFor" until when we do import { wait as waitFor } from '@testing-library/react'; -import { TestProviderWithoutDragAndDrop } from '../../../../common/mock/test_providers'; +import { TestProviders } from '../../../../common/mock/test_providers'; import { mockOpenTimelineQueryResults } from '../../../../common/mock/timeline_results'; import * as i18n from '../translations'; @@ -23,11 +23,11 @@ describe('OpenTimelineModalButton', () => { test('it renders the expected button text', async () => { const wrapper = mount( - + - + ); await waitFor(() => { @@ -44,11 +44,11 @@ describe('OpenTimelineModalButton', () => { const onClick = jest.fn(); const wrapper = mount( - + - + ); diff --git a/x-pack/plugins/task_manager/server/README.md b/x-pack/plugins/task_manager/server/README.md index c3d45be5d8f22..fd2409a7db0a5 100644 --- a/x-pack/plugins/task_manager/server/README.md +++ b/x-pack/plugins/task_manager/server/README.md @@ -41,6 +41,7 @@ The task_manager can be configured via `taskManager` config options (e.g. `taskM - `max_attempts` - The maximum number of times a task will be attempted before being abandoned as failed - `poll_interval` - How often the background worker should check the task_manager index for more work +- `max_poll_inactivity_cycles` - How many poll intervals is work allowed to block polling for before it's timed out. This does not include task execution, as task execution does not block the polling, but rather includes work needed to manage Task Manager's state. - `index` - The name of the index that the task_manager - `max_workers` - The maximum number of tasks a Kibana will run concurrently (defaults to 10) - `credentials` - Encrypted user credentials. All tasks will run in the security context of this user. See [this issue](https://github.com/elastic/dev/issues/1045) for a discussion on task scheduler security. diff --git a/x-pack/plugins/task_manager/server/buffered_task_store.test.ts b/x-pack/plugins/task_manager/server/buffered_task_store.test.ts index 8e18405c79ed2..8ddb9f81c2a8f 100644 --- a/x-pack/plugins/task_manager/server/buffered_task_store.test.ts +++ b/x-pack/plugins/task_manager/server/buffered_task_store.test.ts @@ -58,6 +58,39 @@ describe('Buffered Task Store', () => { ); expect(await results[2]).toMatchObject(tasks[2]); }); + + test('handles multiple items with the same id', async () => { + const taskStore = taskStoreMock.create({ maxAttempts: 10 }); + const bufferedStore = new BufferedTaskStore(taskStore, {}); + + const duplicateIdTask = mockTask(); + const tasks = [ + duplicateIdTask, + mockTask(), + mockTask(), + { ...mockTask(), id: duplicateIdTask.id }, + ]; + + taskStore.bulkUpdate.mockResolvedValueOnce([ + asOk(tasks[0]), + asErr({ entity: tasks[1], error: new Error('Oh no, something went terribly wrong') }), + asOk(tasks[2]), + asOk(tasks[3]), + ]); + + const results = [ + bufferedStore.update(tasks[0]), + bufferedStore.update(tasks[1]), + bufferedStore.update(tasks[2]), + bufferedStore.update(tasks[3]), + ]; + expect(await results[0]).toMatchObject(tasks[0]); + expect(results[1]).rejects.toMatchInlineSnapshot( + `[Error: Oh no, something went terribly wrong]` + ); + expect(await results[2]).toMatchObject(tasks[2]); + expect(await results[3]).toMatchObject(tasks[3]); + }); }); }); diff --git a/x-pack/plugins/task_manager/server/config.test.ts b/x-pack/plugins/task_manager/server/config.test.ts index 8e877f696a2fc..d5bbbe65582f1 100644 --- a/x-pack/plugins/task_manager/server/config.test.ts +++ b/x-pack/plugins/task_manager/server/config.test.ts @@ -13,6 +13,7 @@ describe('config validation', () => { "enabled": true, "index": ".kibana_task_manager", "max_attempts": 3, + "max_poll_inactivity_cycles": 10, "max_workers": 10, "poll_interval": 3000, "request_capacity": 1000, diff --git a/x-pack/plugins/task_manager/server/config.ts b/x-pack/plugins/task_manager/server/config.ts index e3af12eca8a49..aa78cf3baa96d 100644 --- a/x-pack/plugins/task_manager/server/config.ts +++ b/x-pack/plugins/task_manager/server/config.ts @@ -8,6 +8,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; export const DEFAULT_MAX_WORKERS = 10; export const DEFAULT_POLL_INTERVAL = 3000; +export const DEFAULT_MAX_POLL_INACTIVITY_CYCLES = 10; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), @@ -21,6 +22,11 @@ export const configSchema = schema.object({ defaultValue: DEFAULT_POLL_INTERVAL, min: 100, }), + /* How many poll interval cycles can work take before it's timed out. */ + max_poll_inactivity_cycles: schema.number({ + defaultValue: DEFAULT_MAX_POLL_INACTIVITY_CYCLES, + min: 1, + }), /* How many requests can Task Manager buffer before it rejects new requests. */ request_capacity: schema.number({ // a nice round contrived number, feel free to change as we learn how it behaves diff --git a/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts b/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts index f32a755515a95..25abd92b32a26 100644 --- a/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts +++ b/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts @@ -6,6 +6,7 @@ import { createBuffer, Entity, OperationError, BulkOperation } from './bulk_operation_buffer'; import { mapErr, asOk, asErr, Ok, Err } from './result_type'; +import { mockLogger } from '../test_utils'; interface TaskInstance extends Entity { attempts: number; @@ -227,5 +228,38 @@ describe('Bulk Operation Buffer', () => { done(); }); }); + + test('logs unknown bulk operation results', async (done) => { + const bulkUpdate: jest.Mocked> = jest.fn( + ([task1, task2, task3]) => { + return Promise.resolve([ + incrementAttempts(task1), + errorAttempts(createTask()), + incrementAttempts(createTask()), + ]); + } + ); + + const logger = mockLogger(); + + const bufferedUpdate = createBuffer(bulkUpdate, { logger }); + + const task1 = createTask(); + const task2 = createTask(); + const task3 = createTask(); + + return Promise.all([ + expect(bufferedUpdate(task1)).resolves.toMatchObject(incrementAttempts(task1)), + expect(bufferedUpdate(task2)).rejects.toMatchObject( + asErr(new Error(`Unhandled buffered operation for entity: ${task2.id}`)) + ), + expect(bufferedUpdate(task3)).rejects.toMatchObject( + asErr(new Error(`Unhandled buffered operation for entity: ${task3.id}`)) + ), + ]).then(() => { + expect(logger.warn).toHaveBeenCalledTimes(2); + done(); + }); + }); }); }); diff --git a/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.ts b/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.ts index c8e5b837fa36c..57a14c2f8a56b 100644 --- a/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.ts +++ b/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.ts @@ -4,14 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { keyBy, map } from 'lodash'; +import { map } from 'lodash'; import { Subject, race, from } from 'rxjs'; import { bufferWhen, filter, bufferCount, flatMap, mapTo, first } from 'rxjs/operators'; import { either, Result, asOk, asErr, Ok, Err } from './result_type'; +import { Logger } from '../types'; export interface BufferOptions { bufferMaxDuration?: number; bufferMaxOperations?: number; + logger?: Logger; } export interface Entity { @@ -41,14 +43,14 @@ const FLUSH = true; export function createBuffer( bulkOperation: BulkOperation, - { bufferMaxDuration = 0, bufferMaxOperations = Number.MAX_VALUE }: BufferOptions = {} + { bufferMaxDuration = 0, bufferMaxOperations = Number.MAX_VALUE, logger }: BufferOptions = {} ): Operation { const flushBuffer = new Subject(); const storeUpdateBuffer = new Subject<{ entity: Input; onSuccess: (entity: Ok) => void; - onFailure: (error: Err) => void; + onFailure: (error: Err) => void; }>(); storeUpdateBuffer @@ -56,24 +58,61 @@ export function createBuffer flushBuffer), filter((tasks) => tasks.length > 0) ) - .subscribe((entities) => { - const entityById = keyBy(entities, ({ entity: { id } }) => id); - bulkOperation(map(entities, 'entity')) + .subscribe((bufferedEntities) => { + bulkOperation(map(bufferedEntities, 'entity')) .then((results) => { results.forEach((result) => either( result, (entity) => { - entityById[entity.id].onSuccess(asOk(entity)); + either( + pullFirstWhere(bufferedEntities, ({ entity: { id } }) => id === entity.id), + ({ onSuccess }) => { + onSuccess(asOk(entity)); + }, + () => { + if (logger) { + logger.warn( + `Unhandled successful Bulk Operation result: ${ + entity?.id ? entity.id : entity + }` + ); + } + } + ); }, ({ entity, error }: OperationError) => { - entityById[entity.id].onFailure(asErr(error)); + either( + pullFirstWhere(bufferedEntities, ({ entity: { id } }) => id === entity.id), + ({ onFailure }) => { + onFailure(asErr(error)); + }, + () => { + if (logger) { + logger.warn( + `Unhandled failed Bulk Operation result: ${entity?.id ? entity.id : entity}` + ); + } + } + ); } ) ); + + // if any `bufferedEntities` remain in the array then there was no result we could map to them in the bulkOperation + // call their failure handler to avoid hanging the promise returned to the call site + bufferedEntities.forEach((unhandledBufferedEntity) => { + unhandledBufferedEntity.onFailure( + asErr( + new Error( + `Unhandled buffered operation for entity: ${unhandledBufferedEntity.entity.id}` + ) + ) + ); + }); }) .catch((ex) => { - entities.forEach(({ onFailure }) => onFailure(asErr(ex))); + bufferedEntities.forEach(({ onFailure }) => onFailure(asErr(ex))); }); }); @@ -120,3 +159,10 @@ function resolveIn(ms: number) { setTimeout(resolve, ms); }); } + +function pullFirstWhere(collection: T[], predicate: (entity: T) => boolean): Result { + const indexOfFirstEntity = collection.findIndex(predicate); + return indexOfFirstEntity >= 0 + ? asOk(collection.splice(indexOfFirstEntity, 1)[0]) + : asErr(undefined); +} diff --git a/x-pack/plugins/task_manager/server/lib/timeout_promise_after.test.ts b/x-pack/plugins/task_manager/server/lib/timeout_promise_after.test.ts new file mode 100644 index 0000000000000..3e88269671dcc --- /dev/null +++ b/x-pack/plugins/task_manager/server/lib/timeout_promise_after.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { timeoutPromiseAfter } from './timeout_promise_after'; + +const delay = (ms: number, result: unknown) => + new Promise((resolve) => setTimeout(() => resolve(result), ms)); + +const delayRejection = (ms: number, result: unknown) => + new Promise((resolve, reject) => setTimeout(() => reject(result), ms)); + +describe('Promise Timeout', () => { + test('resolves when wrapped promise resolves', async () => { + return expect( + timeoutPromiseAfter(delay(100, 'OK'), 1000, () => 'TIMEOUT ERR') + ).resolves.toMatchInlineSnapshot(`"OK"`); + }); + + test('reject when wrapped promise rejects', async () => { + return expect( + timeoutPromiseAfter(delayRejection(100, 'ERR'), 1000, () => 'TIMEOUT ERR') + ).rejects.toMatchInlineSnapshot(`"ERR"`); + }); + + test('reject it the timeout elapses', async () => { + return expect( + timeoutPromiseAfter(delay(1000, 'OK'), 100, () => 'TIMEOUT ERR') + ).rejects.toMatchInlineSnapshot(`"TIMEOUT ERR"`); + }); +}); diff --git a/x-pack/plugins/task_manager/server/lib/timeout_promise_after.ts b/x-pack/plugins/task_manager/server/lib/timeout_promise_after.ts new file mode 100644 index 0000000000000..2f99bde26ca41 --- /dev/null +++ b/x-pack/plugins/task_manager/server/lib/timeout_promise_after.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. + */ + +export function timeoutPromiseAfter( + future: Promise, + ms: number, + onTimeout: () => G +): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => reject(onTimeout()), ms); + future.then(resolve).catch(reject); + }); +} diff --git a/x-pack/plugins/task_manager/server/task_manager.test.ts b/x-pack/plugins/task_manager/server/task_manager.test.ts index 7035971ad6061..cf7f9e2a7cff3 100644 --- a/x-pack/plugins/task_manager/server/task_manager.test.ts +++ b/x-pack/plugins/task_manager/server/task_manager.test.ts @@ -40,6 +40,7 @@ describe('TaskManager', () => { index: 'foo', max_attempts: 9, poll_interval: 6000000, + max_poll_inactivity_cycles: 10, request_capacity: 1000, }; const taskManagerOpts = { diff --git a/x-pack/plugins/task_manager/server/task_manager.ts b/x-pack/plugins/task_manager/server/task_manager.ts index 7165fd28678c1..2c812f0da516d 100644 --- a/x-pack/plugins/task_manager/server/task_manager.ts +++ b/x-pack/plugins/task_manager/server/task_manager.ts @@ -146,6 +146,7 @@ export class TaskManager { this.bufferedStore = new BufferedTaskStore(this.store, { bufferMaxOperations: opts.config.max_workers, + logger: this.logger, }); this.pool = new TaskPool({ @@ -159,6 +160,11 @@ export class TaskManager { getCapacity: () => this.pool.availableWorkers, pollRequests$: this.claimRequests$, work: this.pollForWork, + // Time out the `work` phase if it takes longer than a certain number of polling cycles + // The `work` phase includes the prework needed *before* executing a task + // (such as polling for new work, marking tasks as running etc.) but does not + // include the time of actually running the task + workTimeout: opts.config.poll_interval * opts.config.max_poll_inactivity_cycles, }); } @@ -283,7 +289,7 @@ export class TaskManager { */ public async schedule( taskInstance: TaskInstanceWithDeprecatedFields, - options?: object + options?: Record ): Promise { await this.waitUntilStarted(); const { taskInstance: modifiedTask } = await this.middleware.beforeSave({ @@ -318,7 +324,7 @@ export class TaskManager { */ public async ensureScheduled( taskInstance: TaskInstanceWithId, - options?: object + options?: Record ): Promise { try { return await this.schedule(taskInstance, options); diff --git a/x-pack/plugins/task_manager/server/task_poller.test.ts b/x-pack/plugins/task_manager/server/task_poller.test.ts index 4b0ecef7ff917..98e6d0f9388a4 100644 --- a/x-pack/plugins/task_manager/server/task_poller.test.ts +++ b/x-pack/plugins/task_manager/server/task_poller.test.ts @@ -9,7 +9,7 @@ import { Subject } from 'rxjs'; import { Option, none, some } from 'fp-ts/lib/Option'; import { createTaskPoller, PollingError, PollingErrorType } from './task_poller'; import { fakeSchedulers } from 'rxjs-marbles/jest'; -import { sleep, resolvable } from './test_utils'; +import { sleep, resolvable, Resolvable } from './test_utils'; import { asOk, asErr } from './lib/result_type'; describe('TaskPoller', () => { @@ -243,6 +243,7 @@ describe('TaskPoller', () => { }, getCapacity: () => 5, pollRequests$, + workTimeout: pollInterval * 5, }).subscribe(handler); pollRequests$.next(some('one')); @@ -272,6 +273,68 @@ describe('TaskPoller', () => { }) ); + test( + 'work times out whe nit exceeds a predefined amount of time', + fakeSchedulers(async (advance) => { + const pollInterval = 100; + const workTimeout = pollInterval * 2; + const bufferCapacity = 2; + + const handler = jest.fn(); + + type ResolvableTupple = [string, PromiseLike & Resolvable]; + const pollRequests$ = new Subject>(); + createTaskPoller<[string, Resolvable], string[]>({ + pollInterval, + bufferCapacity, + work: async (...resolvables) => { + await Promise.all(resolvables.map(([, future]) => future)); + return resolvables.map(([name]) => name); + }, + getCapacity: () => 5, + pollRequests$, + workTimeout, + }).subscribe(handler); + + const one: ResolvableTupple = ['one', resolvable()]; + pollRequests$.next(some(one)); + + // split these into two payloads + advance(pollInterval); + + const two: ResolvableTupple = ['two', resolvable()]; + const three: ResolvableTupple = ['three', resolvable()]; + pollRequests$.next(some(two)); + pollRequests$.next(some(three)); + + advance(workTimeout); + await sleep(workTimeout); + + // one resolves too late! + one[1].resolve(); + + expect(handler).toHaveBeenCalledWith( + asErr( + new PollingError( + 'Failed to poll for work: Error: work has timed out', + PollingErrorType.WorkError, + none + ) + ) + ); + expect(handler.mock.calls[0][0].error.type).toEqual(PollingErrorType.WorkError); + + // two and three in time + two[1].resolve(); + three[1].resolve(); + + advance(pollInterval); + await sleep(pollInterval); + + expect(handler).toHaveBeenCalledWith(asOk(['two', 'three'])); + }) + ); + test( 'returns an error when polling for work fails', fakeSchedulers(async (advance) => { diff --git a/x-pack/plugins/task_manager/server/task_poller.ts b/x-pack/plugins/task_manager/server/task_poller.ts index 3e1a04a366b0e..88511f42f96fb 100644 --- a/x-pack/plugins/task_manager/server/task_poller.ts +++ b/x-pack/plugins/task_manager/server/task_poller.ts @@ -25,6 +25,7 @@ import { asErr, promiseResult, } from './lib/result_type'; +import { timeoutPromiseAfter } from './lib/timeout_promise_after'; type WorkFn = (...params: T[]) => Promise; @@ -34,6 +35,7 @@ interface Opts { getCapacity: () => number; pollRequests$: Observable>; work: WorkFn; + workTimeout?: number; } /** @@ -55,6 +57,7 @@ export function createTaskPoller({ pollRequests$, bufferCapacity, work, + workTimeout, }: Opts): Observable>> { const hasCapacity = () => getCapacity() > 0; @@ -89,11 +92,15 @@ export function createTaskPoller({ concatMap(async (set: Set) => { closeSleepPerf(); return mapResult>>( - await promiseResult(work(...pullFromSet(set, getCapacity()))), + await promiseResult( + timeoutPromiseAfter( + work(...pullFromSet(set, getCapacity())), + workTimeout ?? pollInterval, + () => new Error(`work has timed out`) + ) + ), (workResult) => asOk(workResult), - (err: Error) => { - return asPollingError(err, PollingErrorType.WorkError); - } + (err: Error) => asPollingError(err, PollingErrorType.WorkError) ); }), tap(openSleepPerf), @@ -129,6 +136,7 @@ function pushOptionalIntoSet( export enum PollingErrorType { WorkError, + WorkTimeout, RequestCapacityReached, } diff --git a/x-pack/plugins/task_manager/server/test_utils/index.ts b/x-pack/plugins/task_manager/server/test_utils/index.ts index 3f000a9564ba3..6f43a60ff42d2 100644 --- a/x-pack/plugins/task_manager/server/test_utils/index.ts +++ b/x-pack/plugins/task_manager/server/test_utils/index.ts @@ -23,7 +23,7 @@ export function mockLogger() { }; } -interface Resolvable { +export interface Resolvable { resolve: () => void; } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8c92e7359b2f7..c958215a2677e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -1407,12 +1407,9 @@ "discover.fieldChooser.detailViews.filterOutValueButtonAriaLabel": "{field}を除外:\"{value}\"", "discover.fieldChooser.detailViews.filterValueButtonAriaLabel": "{field}を除外:\"{value}\"", "discover.fieldChooser.detailViews.recordsText": "記録", - "discover.fieldChooser.detailViews.topValuesInRecordsDescription": "次の記録のトップ5の値", "discover.fieldChooser.detailViews.visualizeLinkText": "可視化", "discover.fieldChooser.discoverField.addButtonAriaLabel": "{field}を表に追加", - "discover.fieldChooser.discoverField.addButtonLabel": "追加", "discover.fieldChooser.discoverField.removeButtonAriaLabel": "{field}を表から削除", - "discover.fieldChooser.discoverField.removeButtonLabel": "削除", "discover.fieldChooser.discoverField.scriptedFieldsTakeLongExecuteDescription": "スクリプトフィールドは実行に時間がかかる場合があります。", "discover.fieldChooser.fieldCalculator.analysisIsNotAvailableForGeoFieldsErrorMessage": "ジオフィールドは分析できません。", "discover.fieldChooser.fieldCalculator.analysisIsNotAvailableForObjectFieldsErrorMessage": "オブジェクトフィールドは分析できません。", @@ -16251,7 +16248,6 @@ "xpack.securitySolution.endpoint.details.policyResponse.success": "成功", "xpack.securitySolution.endpoint.details.policyResponse.warning": "警告", "xpack.securitySolution.endpoint.details.policyResponse.workflow": "ワークフロー", - "xpack.securitySolution.endpoint.list.beta": "ベータ", "xpack.securitySolution.endpoint.list.loadingPolicies": "ポリシー構成を読み込んでいます…", "xpack.securitySolution.endpoint.list.noEndpointsInstructions": "セキュリティポリシーを作成しました。以下のステップに従い、エージェントでElastic Endpoint Security機能を有効にする必要があります。", "xpack.securitySolution.endpoint.list.noEndpointsPrompt": "エージェントでElastic Endpoint Securityを有効にする", @@ -16310,7 +16306,6 @@ "xpack.securitySolution.endpoint.policyList.actionButtonText": "Endpoint Securityを追加", "xpack.securitySolution.endpoint.policyList.actionMenu": "開く", "xpack.securitySolution.endpoint.policyList.agentConfigAction": "エージェント構成を表示", - "xpack.securitySolution.endpoint.policyList.beta": "ベータ", "xpack.securitySolution.endpoint.policyList.createdAt": "作成日時", "xpack.securitySolution.endpoint.policyList.createdBy": "作成者", "xpack.securitySolution.endpoint.policyList.createNewButton": "新しいポリシーを作成", @@ -19284,7 +19279,6 @@ "xpack.watcher.models.jiraAction.typeName": "Jira", "xpack.watcher.models.jsonWatch.selectMessageText": "生 JSON のカスタムウォッチをセットアップします。", "xpack.watcher.models.jsonWatch.typeName": "高度なウォッチ", - "xpack.watcher.models.loggingAction.actionJsonIndexNamePropertyMissingBadRequestMessage": "json引数には{actionJsonIndexName}プロパティが含まれている必要があります", "xpack.watcher.models.loggingAction.actionJsonLoggingPropertyMissingBadRequestMessage": "json 引数には {actionJsonLogging} プロパティが含まれている必要があります", "xpack.watcher.models.loggingAction.actionJsonLoggingTextPropertyMissingBadRequestMessage": "json 引数には {actionJsonLoggingText} プロパティが含まれている必要があります", "xpack.watcher.models.loggingAction.actionJsonWebhookHostPropertyMissingBadRequestMessage": "json引数には{actionJsonWebhookHost}プロパティが含まれている必要があります", @@ -19550,7 +19544,6 @@ "xpack.watcher.timeUnits.minuteLabel": "{timeValue, plural, one {分} other {分}}", "xpack.watcher.timeUnits.secondLabel": "{timeValue, plural, one {秒} other {秒}}", "xpack.watcher.watchActions.email.emailRecipientIsRequiredValidationMessage": "送信先メールアドレスが必要です。", - "xpack.watcher.watchActions.index.indexIsRequiredValidationMessage": "インデックス名が必要です。", "xpack.watcher.watchActions.jira.issueTypeNameIsRequiredValidationMessage": "Jira問題タイプが必要です。", "xpack.watcher.watchActions.jira.projectKeyIsRequiredValidationMessage": "Jiraプロジェクトキーが必要です。", "xpack.watcher.watchActions.jira.summaryIsRequiredValidationMessage": "Jira概要が必要です。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 5ab70ff7a9d04..a06e5813796f4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1408,12 +1408,9 @@ "discover.fieldChooser.detailViews.filterOutValueButtonAriaLabel": "筛除 {field}:“{value}”", "discover.fieldChooser.detailViews.filterValueButtonAriaLabel": "筛留 {field}:“{value}”", "discover.fieldChooser.detailViews.recordsText": "个记录", - "discover.fieldChooser.detailViews.topValuesInRecordsDescription": "排名前 5 值 - 范围", "discover.fieldChooser.detailViews.visualizeLinkText": "可视化", "discover.fieldChooser.discoverField.addButtonAriaLabel": "将 {field} 添加到表中", - "discover.fieldChooser.discoverField.addButtonLabel": "添加", "discover.fieldChooser.discoverField.removeButtonAriaLabel": "从表中移除 {field}", - "discover.fieldChooser.discoverField.removeButtonLabel": "移除", "discover.fieldChooser.discoverField.scriptedFieldsTakeLongExecuteDescription": "脚本字段执行时间会很长。", "discover.fieldChooser.fieldCalculator.analysisIsNotAvailableForGeoFieldsErrorMessage": "分析不适用于地理字段。", "discover.fieldChooser.fieldCalculator.analysisIsNotAvailableForObjectFieldsErrorMessage": "分析不适用于对象字段。", @@ -16257,7 +16254,6 @@ "xpack.securitySolution.endpoint.details.policyResponse.success": "成功", "xpack.securitySolution.endpoint.details.policyResponse.warning": "警告", "xpack.securitySolution.endpoint.details.policyResponse.workflow": "工作流", - "xpack.securitySolution.endpoint.list.beta": "公测版", "xpack.securitySolution.endpoint.list.loadingPolicies": "正在加载政策配置", "xpack.securitySolution.endpoint.list.noEndpointsInstructions": "您已创建安全策略。现在您需要按照下面的步骤在代理上启用 Elastic Endpoint Security 功能。", "xpack.securitySolution.endpoint.list.noEndpointsPrompt": "在您的代理上启用 Elastic Endpoint Security", @@ -16317,7 +16313,6 @@ "xpack.securitySolution.endpoint.policyList.actionButtonText": "添加 Endpoint Security", "xpack.securitySolution.endpoint.policyList.actionMenu": "打开", "xpack.securitySolution.endpoint.policyList.agentConfigAction": "查看代理配置", - "xpack.securitySolution.endpoint.policyList.beta": "公测版", "xpack.securitySolution.endpoint.policyList.createdAt": "创建日期", "xpack.securitySolution.endpoint.policyList.createdBy": "创建者", "xpack.securitySolution.endpoint.policyList.createNewButton": "创建新策略", @@ -19292,7 +19287,6 @@ "xpack.watcher.models.jiraAction.typeName": "Jira", "xpack.watcher.models.jsonWatch.selectMessageText": "以原始 JSON 格式设置定制监视。", "xpack.watcher.models.jsonWatch.typeName": "高级监视", - "xpack.watcher.models.loggingAction.actionJsonIndexNamePropertyMissingBadRequestMessage": "JSON 参数必须包含 {actionJsonIndexName} 属性", "xpack.watcher.models.loggingAction.actionJsonLoggingPropertyMissingBadRequestMessage": "json 参数必须包含 {actionJsonLogging} 属性", "xpack.watcher.models.loggingAction.actionJsonLoggingTextPropertyMissingBadRequestMessage": "json 参数必须包含 {actionJsonLoggingText} 属性", "xpack.watcher.models.loggingAction.actionJsonWebhookHostPropertyMissingBadRequestMessage": "JSON 参数必须包含 {actionJsonWebhookHost} 属性", @@ -19558,7 +19552,6 @@ "xpack.watcher.timeUnits.minuteLabel": "{timeValue, plural, one {分钟} other {分钟}}", "xpack.watcher.timeUnits.secondLabel": "{timeValue, plural, one {秒} other {秒}}", "xpack.watcher.watchActions.email.emailRecipientIsRequiredValidationMessage": "“收件人”电子邮件地址必填。", - "xpack.watcher.watchActions.index.indexIsRequiredValidationMessage": "索引名称必填。", "xpack.watcher.watchActions.jira.issueTypeNameIsRequiredValidationMessage": "Jira 问题类型必填。", "xpack.watcher.watchActions.jira.projectKeyIsRequiredValidationMessage": "Jira 项目键必填。", "xpack.watcher.watchActions.jira.summaryIsRequiredValidationMessage": "Jira 摘要必填。", diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index b8e765c9ea635..5a0a0c3219d7e 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -1265,7 +1265,7 @@ Then this dependencies will be used to embed Actions form or register your own a ]; export const ComponentWithActionsForm: () => { - const { http, triggers_actions_ui, toastNotifications } = useKibana().services; + const { http, triggers_actions_ui, notifications } = useKibana().services; const actionTypeRegistry = triggers_actions_ui.actionTypeRegistry; const initialAlert = ({ name: 'test', @@ -1307,7 +1307,7 @@ Then this dependencies will be used to embed Actions form or register your own a actionTypeRegistry={actionTypeRegistry} defaultActionMessage={'Alert [{{ctx.metadata.name}}] has exceeded the threshold'} actionTypes={ALOWED_BY_PLUGIN_ACTION_TYPES} - toastNotifications={toastNotifications} + toastNotifications={notifications.toasts} consumer={initialAlert.consumer} /> ); @@ -1409,7 +1409,7 @@ import { ActionsConnectorsContextProvider, ConnectorAddFlyout } from '../../../. const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); // load required dependancied -const { http, triggers_actions_ui, toastNotifications, capabilities, docLinks } = useKibana().services; +const { http, triggers_actions_ui, notifications, application, docLinks } = useKibana().services; const connector = { secrets: {}, @@ -1438,9 +1438,9 @@ const connector = { @@ -1526,7 +1526,7 @@ import { ActionsConnectorsContextProvider, ConnectorEditFlyout } from '../../../ const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); // load required dependancied -const { http, triggers_actions_ui, toastNotifications, capabilities } = useKibana().services; +const { http, triggers_actions_ui, notifications, application } = useKibana().services; // UI control item for open flyout { /** * List of available action factories */ @@ -51,7 +59,22 @@ export interface ActionWizardProps { /** * Context will be passed into ActionFactory's methods */ - context: object; + context: ActionFactoryContext; + + /** + * Trigger selection has changed + * @param triggers + */ + onSelectedTriggersChange: (triggers?: TriggerId[]) => void; + + getTriggerInfo: (triggerId: TriggerId) => Trigger; + + /** + * List of possible triggers in current context + */ + supportedTriggers: TriggerId[]; + + triggerPickerDocsLink?: string; } export const ActionWizard: React.FC = ({ @@ -61,6 +84,10 @@ export const ActionWizard: React.FC = ({ onConfigChange, config, context, + onSelectedTriggersChange, + getTriggerInfo, + supportedTriggers, + triggerPickerDocsLink, }) => { // auto pick action factory if there is only 1 available if ( @@ -71,7 +98,16 @@ export const ActionWizard: React.FC = ({ onActionFactoryChange(actionFactories[0]); } + // auto pick selected trigger if none is picked + if (currentActionFactory && !((context.triggers?.length ?? 0) > 0)) { + const triggers = getTriggersForActionFactory(currentActionFactory, supportedTriggers); + if (triggers.length > 0) { + onSelectedTriggersChange([triggers[0]]); + } + } + if (currentActionFactory && config) { + const allTriggers = getTriggersForActionFactory(currentActionFactory, supportedTriggers); return ( = ({ onConfigChange={(newConfig) => { onConfigChange(newConfig); }} + allTriggers={allTriggers} + getTriggerInfo={getTriggerInfo} + onSelectedTriggersChange={onSelectedTriggersChange} + triggerPickerDocsLink={triggerPickerDocsLink} /> ); } @@ -99,13 +139,84 @@ export const ActionWizard: React.FC = ({ ); }; -interface SelectedActionFactoryProps { +interface TriggerPickerProps { + triggers: TriggerId[]; + selectedTriggers?: TriggerId[]; + getTriggerInfo: (triggerId: TriggerId) => Trigger; + onSelectedTriggersChange: (triggers?: TriggerId[]) => void; + triggerPickerDocsLink?: string; +} + +const TriggerPicker: React.FC = ({ + triggers, + selectedTriggers, + getTriggerInfo, + onSelectedTriggersChange, + triggerPickerDocsLink, +}) => { + const selectedTrigger = selectedTriggers ? selectedTriggers[0] : undefined; + return ( + +
+ {txtTriggerPickerLabel}{' '} + + {txtTriggerPickerHelpText} + +
+ + ), + }} + style={{ maxWidth: `80%` }} + > + {triggers.map((trigger) => ( + + + + {getTriggerInfo(trigger)?.title ?? 'Unknown'} + + {getTriggerInfo(trigger)?.description && ( +
+ + + {getTriggerInfo(trigger)?.description} + + +
+ )} + + } + name={trigger} + value={trigger} + checked={selectedTrigger === trigger} + onChange={() => onSelectedTriggersChange([trigger])} + data-test-subj={`triggerPicker-${trigger}`} + /> + +
+ ))} +
+ ); +}; + +interface SelectedActionFactoryProps< + ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext +> { actionFactory: ActionFactory; config: object; - context: object; + context: ActionFactoryContext; onConfigChange: (config: object) => void; showDeselect: boolean; onDeselect: () => void; + allTriggers: TriggerId[]; + getTriggerInfo: (triggerId: TriggerId) => Trigger; + onSelectedTriggersChange: (triggers?: TriggerId[]) => void; + triggerPickerDocsLink?: string; } export const TEST_SUBJ_SELECTED_ACTION_FACTORY = 'selectedActionFactory'; @@ -117,6 +228,10 @@ const SelectedActionFactory: React.FC = ({ onConfigChange, config, context, + allTriggers, + getTriggerInfo, + onSelectedTriggersChange, + triggerPickerDocsLink, }) => { return (
= ({ )} - + {allTriggers.length > 1 && ( + <> + + + + )} +
= ({ ); }; -interface ActionFactorySelectorProps { +interface ActionFactorySelectorProps< + ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext +> { actionFactories: ActionFactory[]; - context: object; + context: ActionFactoryContext; onActionFactorySelected: (actionFactory: ActionFactory) => void; } @@ -224,3 +353,10 @@ const ActionFactorySelector: React.FC = ({ ); }; + +function getTriggersForActionFactory( + actionFactory: ActionFactory, + allTriggers: TriggerId[] +): TriggerId[] { + return actionFactory.supportedTriggers().filter((trigger) => allTriggers.includes(trigger)); +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/i18n.ts index 3e7e211dc7738..678457f9794f3 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/i18n.ts @@ -12,3 +12,17 @@ export const txtChangeButton = i18n.translate( defaultMessage: 'Change', } ); + +export const txtTriggerPickerLabel = i18n.translate( + 'xpack.uiActionsEnhanced.components.actionWizard.triggerPickerLabel', + { + defaultMessage: 'Pick a trigger:', + } +); + +export const txtTriggerPickerHelpText = i18n.translate( + 'xpack.uiActionsEnhanced.components.actionWizard.helpText', + { + defaultMessage: "What's this?", + } +); diff --git a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx index 2672a086dca73..d48cb13b1a470 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/test_data.tsx @@ -8,9 +8,16 @@ import React, { useState } from 'react'; import { EuiFieldText, EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui'; import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; import { ActionWizard } from './action_wizard'; -import { ActionFactoryDefinition, ActionFactory } from '../../dynamic_actions'; +import { ActionFactory, ActionFactoryDefinition } from '../../dynamic_actions'; import { CollectConfigProps } from '../../../../../../src/plugins/kibana_utils/public'; import { licenseMock } from '../../../../licensing/common/licensing.mock'; +import { + APPLY_FILTER_TRIGGER, + SELECT_RANGE_TRIGGER, + Trigger, + TriggerId, + VALUE_CLICK_TRIGGER, +} from '../../../../../../src/plugins/ui_actions/public'; type ActionBaseConfig = object; @@ -104,6 +111,9 @@ export const dashboardDrilldownActionFactory: ActionFactoryDefinition< execute: async () => alert('Navigate to dashboard!'), enhancements: {}, }), + supportedTriggers(): any[] { + return [APPLY_FILTER_TRIGGER]; + }, }; export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory, () => @@ -161,16 +171,45 @@ export const urlDrilldownActionFactory: ActionFactoryDefinition null as any, + supportedTriggers(): any[] { + return [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER]; + }, }; export const urlFactory = new ActionFactory(urlDrilldownActionFactory, () => licenseMock.createLicense() ); +export const mockSupportedTriggers: TriggerId[] = [ + VALUE_CLICK_TRIGGER, + SELECT_RANGE_TRIGGER, + APPLY_FILTER_TRIGGER, +]; +export const mockGetTriggerInfo = (triggerId: TriggerId): Trigger => { + const titleMap = { + [VALUE_CLICK_TRIGGER]: 'Single click', + [SELECT_RANGE_TRIGGER]: 'Range selection', + [APPLY_FILTER_TRIGGER]: 'Apply filter', + } as Record; + + const descriptionMap = { + [VALUE_CLICK_TRIGGER]: 'A single point clicked on a visualization', + [SELECT_RANGE_TRIGGER]: 'Select a group of values', + [APPLY_FILTER_TRIGGER]: 'Apply filter description...', + } as Record; + + return { + id: triggerId, + title: titleMap[triggerId] ?? 'Unknown', + description: descriptionMap[triggerId] ?? 'Unknown description', + }; +}; + export function Demo({ actionFactories }: { actionFactories: Array> }) { const [state, setState] = useState<{ currentActionFactory?: ActionFactory; config?: ActionBaseConfig; + selectedTriggers?: TriggerId[]; }>({}); function changeActionFactory(newActionFactory?: ActionFactory) { @@ -181,7 +220,9 @@ export function Demo({ actionFactories }: { actionFactories: Array { + setState({ + ...state, + selectedTriggers: triggers, + }); + }} + getTriggerInfo={mockGetTriggerInfo} + supportedTriggers={[VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER, SELECT_RANGE_TRIGGER]} />

@@ -208,8 +257,13 @@ export function Demo({ actionFactories }: { actionFactories: ArrayAction Factory Config: {JSON.stringify(state.config)}
Is config valid:{' '} - {JSON.stringify(state.currentActionFactory?.isConfigValid(state.config!) ?? false)} + {JSON.stringify( + state.currentActionFactory?.isConfigValid(state.config!, { + triggers: state.selectedTriggers ?? [], + }) ?? false + )}
+
Picked trigger: {state.selectedTriggers?.[0]}
); } diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx index 0b0339a625c50..f7284539ab2fe 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx @@ -25,10 +25,25 @@ const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ alert(JSON.stringify(args)); }, } as any, + getTrigger: (triggerId) => ({ + id: triggerId, + }), }); -storiesOf('components/FlyoutManageDrilldowns', module).add('default', () => ( - {}}> - - -)); +storiesOf('components/FlyoutManageDrilldowns', module) + .add('default (3 triggers)', () => ( + {}}> + + + )) + .add('Only filter is supported', () => ( + {}}> + + + )); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx index e98701a05ce89..2412cdd51748c 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx @@ -7,7 +7,12 @@ import React from 'react'; import { cleanup, fireEvent, render, wait } from '@testing-library/react/pure'; import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; -import { dashboardFactory, urlFactory } from '../../../components/action_wizard/test_data'; +import { + dashboardFactory, + mockGetTriggerInfo, + mockSupportedTriggers, + urlFactory, +} from '../../../components/action_wizard/test_data'; import { StubBrowserStorage } from '../../../../../../../src/test_utils/public/stub_browser_storage'; import { Storage } from '../../../../../../../src/plugins/kibana_utils/public'; import { mockDynamicActionManager } from './test_data'; @@ -24,6 +29,7 @@ const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ actionFactories: [dashboardFactory as ActionFactory, urlFactory as ActionFactory], storage: new Storage(new StubBrowserStorage()), toastService: toasts, + getTrigger: mockGetTriggerInfo, }); // https://github.com/elastic/kibana/issues/59469 @@ -31,12 +37,18 @@ afterEach(cleanup); beforeEach(() => { storage.clear(); + mockDynamicActionManager.state.set({ ...mockDynamicActionManager.state.get(), events: [] }); (toasts as jest.Mocked).addSuccess.mockClear(); (toasts as jest.Mocked).addError.mockClear(); }); test('Allows to manage drilldowns', async () => { - const screen = render(); + const screen = render( + + ); // wait for initial render. It is async because resolving compatible action factories is async await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); @@ -103,7 +115,12 @@ test('Allows to manage drilldowns', async () => { }); test('Can delete multiple drilldowns', async () => { - const screen = render(); + const screen = render( + + ); // wait for initial render. It is async because resolving compatible action factories is async await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); @@ -143,6 +160,7 @@ test('Create only mode', async () => { dynamicActionManager={mockDynamicActionManager} viewMode={'create'} onClose={onClose} + supportedTriggers={mockSupportedTriggers} /> ); // wait for initial render. It is async because resolving compatible action factories is async @@ -163,7 +181,11 @@ test('Create only mode', async () => { test('After switching between action factories state is restored', async () => { const screen = render( - + ); // wait for initial render. It is async because resolving compatible action factories is async await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); @@ -200,7 +222,12 @@ test("Error when can't save drilldown changes", async () => { jest.spyOn(mockDynamicActionManager, 'createEvent').mockImplementationOnce(async () => { throw error; }); - const screen = render(); + const screen = render( + + ); // wait for initial render. It is async because resolving compatible action factories is async await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); fireEvent.click(screen.getByText(/Create new/i)); @@ -218,7 +245,12 @@ test("Error when can't save drilldown changes", async () => { }); test('Should show drilldown welcome message. Should be able to dismiss it', async () => { - let screen = render(); + let screen = render( + + ); // wait for initial render. It is async because resolving compatible action factories is async await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); @@ -228,8 +260,63 @@ test('Should show drilldown welcome message. Should be able to dismiss it', asyn expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); cleanup(); - screen = render(); + screen = render( + + ); // wait for initial render. It is async because resolving compatible action factories is async await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); }); + +test('Drilldown type is not shown if no supported trigger', async () => { + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); + expect(screen.queryByText(/Go to Dashboard/i)).not.toBeInTheDocument(); // dashboard action is not visible, because APPLY_FILTER_TRIGGER not supported + expect(screen.getByTestId('selectedActionFactory-Url')).toBeInTheDocument(); +}); + +test('Can pick a trigger', async () => { + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); + + // input drilldown name + const name = 'Test name'; + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: name }, + }); + + // select URL one + fireEvent.click(screen.getByText(/Go to URL/i)); + + // Input url + const URL = 'https://elastic.co'; + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: URL }, + }); + + fireEvent.click(screen.getByTestId('triggerPicker-SELECT_RANGE_TRIGGER').querySelector('input')!); + + const [, createButton] = screen.getAllByText(/Create Drilldown/i); + + expect(createButton).toBeEnabled(); + fireEvent.click(createButton); + await wait(() => expect(toasts.addSuccess).toBeCalled()); + expect(mockDynamicActionManager.state.get().events[0].triggers).toEqual(['SELECT_RANGE_TRIGGER']); +}); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx index 283464b137ff9..9fca785ec9072 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx @@ -4,16 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { ToastsStart } from 'kibana/public'; import useMountedState from 'react-use/lib/useMountedState'; +import intersection from 'lodash/intersection'; import { DrilldownWizardConfig, FlyoutDrilldownWizard } from '../flyout_drilldown_wizard'; import { FlyoutListManageDrilldowns } from '../flyout_list_manage_drilldowns'; import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; -import { - TriggerContextMapping, - APPLY_FILTER_TRIGGER, -} from '../../../../../../../src/plugins/ui_actions/public'; +import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public'; import { useContainerState } from '../../../../../../../src/plugins/kibana_utils/public'; import { DrilldownListItem } from '../list_manage_drilldowns'; import { @@ -27,15 +25,29 @@ import { } from './i18n'; import { ActionFactory, + BaseActionFactoryContext, DynamicActionManager, SerializedAction, SerializedEvent, } from '../../../dynamic_actions'; +import { ExtraActionFactoryContext } from '../types'; -interface ConnectedFlyoutManageDrilldownsProps { +interface ConnectedFlyoutManageDrilldownsProps< + ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext +> { dynamicActionManager: DynamicActionManager; viewMode?: 'create' | 'manage'; onClose?: () => void; + + /** + * List of possible triggers in current context + */ + supportedTriggers: TriggerId[]; + + /** + * Extra action factory context passed into action factories CollectConfig, getIconType, getDisplayName and etc... + */ + extraContext?: ExtraActionFactoryContext; } /** @@ -52,8 +64,10 @@ export function createFlyoutManageDrilldowns({ storage, toastService, docsLink, + getTrigger, }: { actionFactories: ActionFactory[]; + getTrigger: (triggerId: TriggerId) => Trigger; storage: IStorageWrapper; toastService: ToastsStart; docsLink?: string; @@ -66,19 +80,10 @@ export function createFlyoutManageDrilldowns({ return (props: ConnectedFlyoutManageDrilldownsProps) => { const isCreateOnly = props.viewMode === 'create'; - // TODO: https://github.com/elastic/kibana/issues/59569 - const selectedTriggers: Array = React.useMemo( - () => [APPLY_FILTER_TRIGGER], - [] + const factoryContext: BaseActionFactoryContext = useMemo( + () => ({ ...props.extraContext, triggers: props.supportedTriggers }), + [props.extraContext, props.supportedTriggers] ); - - const factoryContext: object = React.useMemo( - () => ({ - triggers: selectedTriggers, - }), - [selectedTriggers] - ); - const actionFactories = useCompatibleActionFactoriesForCurrentContext( allActionFactories, factoryContext @@ -122,6 +127,7 @@ export function createFlyoutManageDrilldowns({ actionFactory: allActionFactoriesById[drilldownToEdit.action.factoryId], actionConfig: drilldownToEdit.action.config as object, name: drilldownToEdit.action.name, + selectedTriggers: (drilldownToEdit.triggers ?? []) as TriggerId[], }; } @@ -130,16 +136,22 @@ export function createFlyoutManageDrilldowns({ */ function mapToDrilldownToDrilldownListItem(drilldown: SerializedEvent): DrilldownListItem { const actionFactory = allActionFactoriesById[drilldown.action.factoryId]; + const drilldownFactoryContext: BaseActionFactoryContext = { + ...props.extraContext, + triggers: drilldown.triggers as TriggerId[], + }; return { id: drilldown.eventId, drilldownName: drilldown.action.name, - actionName: actionFactory?.getDisplayName(factoryContext) ?? drilldown.action.factoryId, - icon: actionFactory?.getIconType(factoryContext), + actionName: + actionFactory?.getDisplayName(drilldownFactoryContext) ?? drilldown.action.factoryId, + icon: actionFactory?.getIconType(drilldownFactoryContext), error: !actionFactory ? invalidDrilldownType(drilldown.action.factoryId) // this shouldn't happen for the end user, but useful during development : !actionFactory.isCompatibleLicence() ? insufficientLicenseLevel : undefined, + triggers: drilldown.triggers.map((trigger) => getTrigger(trigger as TriggerId)), }; } @@ -155,7 +167,7 @@ export function createFlyoutManageDrilldowns({ onClose={props.onClose} mode={route === Routes.Create ? 'create' : 'edit'} onBack={isCreateOnly ? undefined : () => setRoute(Routes.Manage)} - onSubmit={({ actionConfig, actionFactory, name }) => { + onSubmit={({ actionConfig, actionFactory, name, selectedTriggers }) => { if (route === Routes.Create) { createDrilldown( { @@ -192,13 +204,23 @@ export function createFlyoutManageDrilldowns({ setRoute(Routes.Manage); setCurrentEditId(null); }} - actionFactoryContext={factoryContext} + extraActionFactoryContext={props.extraContext} initialDrilldownWizardConfig={resolveInitialDrilldownWizardConfig()} + supportedTriggers={props.supportedTriggers} + getTrigger={getTrigger} /> ); case Routes.Manage: default: + // show trigger column in case if there is more then 1 possible trigger in current context + const showTriggerColumn = + intersection( + props.supportedTriggers, + actionFactories + .map((factory) => factory.supportedTriggers()) + .reduce((res, next) => res.concat(next), []) + ).length > 1; return ( ); } }; } -function useCompatibleActionFactoriesForCurrentContext( - actionFactories: ActionFactory[], - context: Context -) { +function useCompatibleActionFactoriesForCurrentContext< + Context extends BaseActionFactoryContext = BaseActionFactoryContext +>(actionFactories: ActionFactory[], context: Context) { const [compatibleActionFactories, setCompatibleActionFactories] = useState(); useEffect(() => { let canceled = false; @@ -236,13 +258,18 @@ function useCompatibleActionFactoriesForCurrentContext factory.isCompatible(context)) ); if (canceled) return; - setCompatibleActionFactories(actionFactories.filter((_, i) => compatibility[i])); + + const compatibleFactories = actionFactories.filter((_, i) => compatibility[i]); + const triggerSupportedFactories = compatibleFactories.filter((factory) => + factory.supportedTriggers().some((trigger) => context.triggers.includes(trigger)) + ); + setCompatibleActionFactories(triggerSupportedFactories); } updateCompatibleFactoriesForContext(); return () => { canceled = true; }; - }, [context, actionFactories]); + }, [context, actionFactories, context.triggers]); return compatibleActionFactories; } @@ -280,10 +307,7 @@ function useDrilldownsStateManager(actionManager: DynamicActionManager, toastSer } } - async function createDrilldown( - action: SerializedAction, - selectedTriggers: Array - ) { + async function createDrilldown(action: SerializedAction, selectedTriggers: TriggerId[]) { await run(async () => { await actionManager.createEvent(action, selectedTriggers); toastService.addSuccess({ @@ -296,7 +320,7 @@ function useDrilldownsStateManager(actionManager: DynamicActionManager, toastSer async function editDrilldown( drilldownId: string, action: SerializedAction, - selectedTriggers: Array + selectedTriggers: TriggerId[] ) { await run(async () => { await actionManager.updateEvent(drilldownId, action, selectedTriggers); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx index 01e2a457889ca..8f73c2b3b3cc9 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx @@ -10,12 +10,24 @@ import { storiesOf } from '@storybook/react'; import { FlyoutDrilldownWizard } from './index'; import { dashboardFactory, urlFactory } from '../../../components/action_wizard/test_data'; import { ActionFactory } from '../../../dynamic_actions'; +import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public'; + +const otherProps = { + supportedTriggers: [ + 'VALUE_CLICK_TRIGGER', + 'SELECT_RANGE_TRIGGER', + 'FILTER_TRIGGER', + ] as TriggerId[], + onClose: () => {}, + getTrigger: (id: TriggerId) => ({ id } as Trigger), +}; storiesOf('components/FlyoutDrilldownWizard', module) .add('default', () => { return ( ); }) @@ -23,11 +35,11 @@ storiesOf('components/FlyoutDrilldownWizard', module) return ( {}}> {}} drilldownActionFactories={[ urlFactory as ActionFactory, dashboardFactory as ActionFactory, ]} + {...otherProps} /> ); @@ -36,7 +48,6 @@ storiesOf('components/FlyoutDrilldownWizard', module) return ( {}}> {}} drilldownActionFactories={[ urlFactory as ActionFactory, dashboardFactory as ActionFactory, @@ -50,6 +61,7 @@ storiesOf('components/FlyoutDrilldownWizard', module) }, }} mode={'edit'} + {...otherProps} /> ); @@ -58,7 +70,6 @@ storiesOf('components/FlyoutDrilldownWizard', module) return ( {}}> {}} drilldownActionFactories={[dashboardFactory as ActionFactory]} initialDrilldownWizardConfig={{ name: 'My fancy drilldown', @@ -69,6 +80,7 @@ storiesOf('components/FlyoutDrilldownWizard', module) }, }} mode={'edit'} + {...otherProps} /> ); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx index 58cf2501280c7..a908d53bf6ae7 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { EuiButton, EuiSpacer } from '@elastic/eui'; import { FormDrilldownWizard } from '../form_drilldown_wizard'; import { FlyoutFrame } from '../flyout_frame'; @@ -16,15 +16,21 @@ import { txtEditDrilldownTitle, } from './i18n'; import { DrilldownHelloBar } from '../drilldown_hello_bar'; -import { ActionFactory } from '../../../dynamic_actions'; +import { ActionFactory, BaseActionFactoryContext } from '../../../dynamic_actions'; +import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public'; +import { ExtraActionFactoryContext } from '../types'; export interface DrilldownWizardConfig { name: string; actionFactory?: ActionFactory; actionConfig?: ActionConfig; + selectedTriggers?: TriggerId[]; } -export interface FlyoutDrilldownWizardProps { +export interface FlyoutDrilldownWizardProps< + CurrentActionConfig extends object = object, + ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext +> { drilldownActionFactories: ActionFactory[]; onSubmit?: (drilldownWizardConfig: Required) => void; @@ -38,12 +44,20 @@ export interface FlyoutDrilldownWizardProps void; - actionFactoryContext?: object; + extraActionFactoryContext?: ExtraActionFactoryContext; docsLink?: string; + + getTrigger: (triggerId: TriggerId) => Trigger; + + /** + * List of possible triggers in current context + */ + supportedTriggers: TriggerId[]; } function useWizardConfigState( + actionFactoryContext: BaseActionFactoryContext, initialDrilldownWizardConfig?: DrilldownWizardConfig ): [ DrilldownWizardConfig, @@ -51,6 +65,7 @@ function useWizardConfigState( setName: (name: string) => void; setActionConfig: (actionConfig: object) => void; setActionFactory: (actionFactory?: ActionFactory) => void; + setSelectedTriggers: (triggers?: TriggerId[]) => void; } ] { const [wizardConfig, setWizardConfig] = useState( @@ -88,7 +103,10 @@ function useWizardConfigState( setWizardConfig({ ...wizardConfig, actionFactory, - actionConfig: actionConfigCache[actionFactory.id] ?? actionFactory.createConfig(), + actionConfig: + actionConfigCache[actionFactory.id] ?? + actionFactory.createConfig(actionFactoryContext), + selectedTriggers: [], }); } else { if (wizardConfig.actionFactory?.id) { @@ -105,6 +123,12 @@ function useWizardConfigState( }); } }, + setSelectedTriggers: (selectedTriggers: TriggerId[] = []) => { + setWizardConfig({ + ...wizardConfig, + selectedTriggers, + }); + }, }, ]; } @@ -119,21 +143,39 @@ export function FlyoutDrilldownWizard) { - const [wizardConfig, { setActionFactory, setActionConfig, setName }] = useWizardConfigState( + const [ + wizardConfig, + { setActionFactory, setActionConfig, setName, setSelectedTriggers }, + ] = useWizardConfigState( + { ...extraActionFactoryContext, triggers: supportedTriggers }, initialDrilldownWizardConfig ); + const actionFactoryContext: BaseActionFactoryContext = useMemo( + () => ({ + ...extraActionFactoryContext, + triggers: wizardConfig.selectedTriggers ?? [], + }), + [extraActionFactoryContext, wizardConfig.selectedTriggers] + ); + const isActionValid = ( config: DrilldownWizardConfig ): config is Required => { if (!wizardConfig.name) return false; if (!wizardConfig.actionFactory) return false; if (!wizardConfig.actionConfig) return false; + if (!wizardConfig.selectedTriggers || wizardConfig.selectedTriggers.length === 0) return false; - return wizardConfig.actionFactory.isConfigValid(wizardConfig.actionConfig); + return wizardConfig.actionFactory.isConfigValid( + wizardConfig.actionConfig, + actionFactoryContext + ); }; const footer = ( @@ -171,7 +213,11 @@ export function FlyoutDrilldownWizard {mode === 'edit' && ( <> diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx index 1d849b1db0688..97face28a5e4c 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx @@ -19,6 +19,7 @@ export interface FlyoutListManageDrilldownsProps { onDelete?: (drilldownIds: string[]) => void; showWelcomeMessage?: boolean; onWelcomeHideClick?: () => void; + showTriggerColumn?: boolean; } export function FlyoutListManageDrilldowns({ @@ -30,6 +31,7 @@ export function FlyoutListManageDrilldowns({ onEdit, showWelcomeMessage = true, onWelcomeHideClick, + showTriggerColumn, }: FlyoutListManageDrilldownsProps) { return ( ); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx index fe63b0835af9e..9ab893f23b398 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx @@ -7,13 +7,25 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; import { FormDrilldownWizard } from './index'; +import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public'; + +const otherProps = { + supportedTriggers: [ + 'VALUE_CLICK_TRIGGER', + 'SELECT_RANGE_TRIGGER', + 'FILTER_TRIGGER', + ] as TriggerId[], + getTriggerInfo: (id: TriggerId) => ({ id } as Trigger), + onSelectedTriggersChange: () => {}, + actionFactoryContext: { triggers: [] as TriggerId[] }, +}; const DemoEditName: React.FC = () => { const [name, setName] = React.useState(''); return ( <> - {' '} + {' '}
name: {name}
); @@ -21,9 +33,9 @@ const DemoEditName: React.FC = () => { storiesOf('components/FormDrilldownWizard', module) .add('default', () => { - return ; + return ; }) .add('[name=foobar]', () => { - return ; + return ; }) .add('can edit name', () => ); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx index d9c53ae6f737a..0dcca84ede3bf 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx @@ -9,20 +9,32 @@ import { render } from 'react-dom'; import { FormDrilldownWizard } from './form_drilldown_wizard'; import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; import { txtNameOfDrilldown } from './i18n'; +import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public'; afterEach(cleanup); +const otherProps = { + actionFactoryContext: { triggers: [] as TriggerId[] }, + supportedTriggers: [ + 'VALUE_CLICK_TRIGGER', + 'SELECT_RANGE_TRIGGER', + 'FILTER_TRIGGER', + ] as TriggerId[], + getTriggerInfo: (id: TriggerId) => ({ id } as Trigger), + onSelectedTriggersChange: () => {}, +}; + describe('', () => { test('renders without crashing', () => { const div = document.createElement('div'); - render( {}} actionFactoryContext={{}} />, div); + render( {}} {...otherProps} />, div); }); describe('[name=]', () => { test('if name not provided, uses to empty string', () => { const div = document.createElement('div'); - render(, div); + render(, div); const input = div.querySelector('[data-test-subj="drilldownNameInput"]') as HTMLInputElement; @@ -32,13 +44,13 @@ describe('', () => { test('can set initial name input field value', () => { const div = document.createElement('div'); - render(, div); + render(, div); const input = div.querySelector('[data-test-subj="drilldownNameInput"]') as HTMLInputElement; expect(input?.value).toBe('foo'); - render(, div); + render(, div); expect(input?.value).toBe('bar'); }); @@ -46,7 +58,7 @@ describe('', () => { test('fires onNameChange callback on name change', () => { const onNameChange = jest.fn(); const utils = renderTestingLibrary( - + ); const input = utils.getByLabelText(txtNameOfDrilldown); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx index e7e7f72dbf58f..bb3eb89d8f199 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/form_drilldown_wizard/form_drilldown_wizard.tsx @@ -8,25 +8,43 @@ import React from 'react'; import { EuiFieldText, EuiForm, EuiFormRow, EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { txtDrilldownAction, txtNameOfDrilldown, txtUntitledDrilldown } from './i18n'; -import { ActionFactory } from '../../../dynamic_actions'; +import { ActionFactory, BaseActionFactoryContext } from '../../../dynamic_actions'; import { ActionWizard } from '../../../components/action_wizard'; +import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public'; const GET_MORE_ACTIONS_LINK = 'https://www.elastic.co/subscriptions'; const noopFn = () => {}; -export interface FormDrilldownWizardProps { +export interface FormDrilldownWizardProps< + ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext +> { name?: string; onNameChange?: (name: string) => void; currentActionFactory?: ActionFactory; onActionFactoryChange?: (actionFactory?: ActionFactory) => void; - actionFactoryContext: object; + actionFactoryContext: ActionFactoryContext; actionConfig?: object; onActionConfigChange?: (config: object) => void; actionFactories?: ActionFactory[]; + + /** + * Trigger selection has changed + * @param triggers + */ + onSelectedTriggersChange: (triggers?: TriggerId[]) => void; + + getTriggerInfo: (triggerId: TriggerId) => Trigger; + + /** + * List of possible triggers in current context + */ + supportedTriggers: TriggerId[]; + + triggerPickerDocsLink?: string; } export const FormDrilldownWizard: React.FC = ({ @@ -38,6 +56,10 @@ export const FormDrilldownWizard: React.FC = ({ onActionFactoryChange = noopFn, actionFactories = [], actionFactoryContext, + onSelectedTriggersChange, + getTriggerInfo, + supportedTriggers, + triggerPickerDocsLink, }) => { const nameFragment = ( @@ -86,6 +108,10 @@ export const FormDrilldownWizard: React.FC = ({ onActionFactoryChange={(actionFactory) => onActionFactoryChange(actionFactory)} onConfigChange={(config) => onActionConfigChange(config)} context={actionFactoryContext} + onSelectedTriggersChange={onSelectedTriggersChange} + getTriggerInfo={getTriggerInfo} + supportedTriggers={supportedTriggers} + triggerPickerDocsLink={triggerPickerDocsLink} /> ); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx index eafe50bab2016..51df6c8d1c715 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx @@ -11,9 +11,26 @@ import { ListManageDrilldowns } from './list_manage_drilldowns'; storiesOf('components/ListManageDrilldowns', module).add('default', () => ( )); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.tsx index b828c4d7d076d..1c5085087791c 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/list_manage_drilldowns/list_manage_drilldowns.tsx @@ -30,6 +30,12 @@ export interface DrilldownListItem { drilldownName: string; icon?: string; error?: string; + triggers?: Trigger[]; +} + +interface Trigger { + title?: string; + description?: string; } export interface ListManageDrilldownsProps { @@ -38,6 +44,8 @@ export interface ListManageDrilldownsProps { onEdit?: (id: string) => void; onCreate?: () => void; onDelete?: (ids: string[]) => void; + + showTriggerColumn?: boolean; } const noop = () => {}; @@ -49,14 +57,13 @@ export function ListManageDrilldowns({ onEdit = noop, onCreate = noop, onDelete = noop, + showTriggerColumn = true, }: ListManageDrilldownsProps) { const [selectedDrilldowns, setSelectedDrilldowns] = useState([]); const columns: Array> = [ { name: 'Name', - truncateText: true, - width: '50%', 'data-test-subj': 'drilldownListItemName', render: (drilldown: DrilldownListItem) => (
@@ -85,21 +92,38 @@ export function ListManageDrilldowns({ )} - + {drilldown.actionName} ), }, + showTriggerColumn && { + name: 'Trigger', + textOnly: true, + render: (drilldown: DrilldownListItem) => + drilldown.triggers?.map((trigger, idx) => + trigger.description ? ( + + {trigger.title ?? 'unknown'} + + ) : ( + + {trigger.title ?? 'unknown'} + + ) + ), + }, { align: 'right', + width: '64px', render: (drilldown: DrilldownListItem) => ( onEdit(drilldown.id)}> {txtEditDrilldown} ), }, - ]; + ].filter(Boolean) as Array>; return ( <> diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/types.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/types.ts new file mode 100644 index 0000000000000..870b55c24fb58 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/types.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 { BaseActionFactoryContext } from '../../dynamic_actions'; + +/** + * Interface used as piece of ActionFactoryContext that is passed in from drilldown wizard component to action factories + * Omitted values are added inside the wizard and then full {@link BaseActionFactoryContext} passed into action factory methods + */ +export type ExtraActionFactoryContext< + ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext +> = Omit; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts index 756bdf9e672aa..ff455c6ae45b6 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ActionFactoryDefinition } from '../dynamic_actions'; +import { ActionFactoryDefinition, BaseActionFactoryContext } from '../dynamic_actions'; import { LicenseType } from '../../../licensing/public'; +import { TriggerContextMapping, TriggerId } from '../../../../../src/plugins/ui_actions/public'; import { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/public'; /** @@ -21,9 +22,14 @@ import { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/pu * and provided to the `execute` function of the drilldown. This object contains * information about the action user performed. */ + export interface DrilldownDefinition< Config extends object = object, - ExecutionContext extends object = object + SupportedTriggers extends TriggerId = TriggerId, + FactoryContext extends BaseActionFactoryContext = { + triggers: SupportedTriggers[]; + }, + ExecutionContext extends TriggerContextMapping[SupportedTriggers] = TriggerContextMapping[SupportedTriggers] > { /** * Globally unique identifier for this drilldown. @@ -45,7 +51,12 @@ export interface DrilldownDefinition< /** * Function that returns default config for this drilldown. */ - createConfig: ActionFactoryDefinition['createConfig']; + createConfig: ActionFactoryDefinition< + Config, + SupportedTriggers, + FactoryContext, + ExecutionContext + >['createConfig']; /** * `UiComponent` that collections config for this drilldown. You can create @@ -66,13 +77,23 @@ export interface DrilldownDefinition< * export const CollectConfig = uiToReactComponent(ReactCollectConfig); * ``` */ - CollectConfig: ActionFactoryDefinition['CollectConfig']; + CollectConfig: ActionFactoryDefinition< + Config, + SupportedTriggers, + FactoryContext, + ExecutionContext + >['CollectConfig']; /** * A validator function for the config object. Should always return a boolean * given any input. */ - isConfigValid: ActionFactoryDefinition['isConfigValid']; + isConfigValid: ActionFactoryDefinition< + Config, + SupportedTriggers, + FactoryContext, + ExecutionContext + >['isConfigValid']; /** * Name of EUI icon to display when showing this drilldown to user. @@ -85,6 +106,15 @@ export interface DrilldownDefinition< */ getDisplayName: () => string; + /** + * isCompatible during execution + * Could be used to prevent drilldown from execution + */ + isCompatible?( + config: Config, + context: ExecutionContext | ActionExecutionContext + ): Promise; + /** * Implements the "navigation" action of the drilldown. This happens when * user clicks something in the UI that executes a trigger to which this @@ -106,4 +136,10 @@ export interface DrilldownDefinition< config: Config, context: ExecutionContext | ActionExecutionContext ): Promise; + + /** + * List of triggers supported by this drilldown type + * This is used in trigger picker when configuring drilldown + */ + supportedTriggers(): SupportedTriggers[]; } diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.test.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.test.ts index 918c6422546f4..a07fed8486438 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.test.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.test.ts @@ -19,12 +19,13 @@ const def: ActionFactoryDefinition = { getDisplayName: () => name, enhancements: {}, }), + supportedTriggers: () => [], }; describe('License & ActionFactory', () => { test('no license requirements', async () => { const factory = new ActionFactory(def, () => licensingMock.createLicense()); - expect(await factory.isCompatible({})).toBe(true); + expect(await factory.isCompatible({ triggers: [] })).toBe(true); expect(factory.isCompatibleLicence()).toBe(true); }); @@ -32,7 +33,15 @@ describe('License & ActionFactory', () => { const factory = new ActionFactory({ ...def, minimalLicense: 'gold' }, () => licensingMock.createLicense() ); - expect(await factory.isCompatible({})).toBe(true); + expect(await factory.isCompatible({ triggers: [] })).toBe(true); + expect(factory.isCompatibleLicence()).toBe(false); + }); + + test('licence has expired', async () => { + const factory = new ActionFactory({ ...def, minimalLicense: 'gold' }, () => + licensingMock.createLicense({ license: { type: 'gold', status: 'expired' } }) + ); + expect(await factory.isCompatible({ triggers: [] })).toBe(true); expect(factory.isCompatibleLicence()).toBe(false); }); @@ -40,7 +49,7 @@ describe('License & ActionFactory', () => { const factory = new ActionFactory({ ...def, minimalLicense: 'gold' }, () => licensingMock.createLicense({ license: { type: 'gold' } }) ); - expect(await factory.isCompatible({})).toBe(true); + expect(await factory.isCompatible({ triggers: [] })).toBe(true); expect(factory.isCompatibleLicence()).toBe(true); }); }); diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts index 95b7941b48ed3..35e06ab036fc9 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory.ts @@ -5,20 +5,32 @@ */ import { uiToReactComponent } from '../../../../../src/plugins/kibana_react/public'; -import { UiActionsPresentable as Presentable } from '../../../../../src/plugins/ui_actions/public'; +import { + TriggerContextMapping, + TriggerId, + UiActionsPresentable as Presentable, +} from '../../../../../src/plugins/ui_actions/public'; import { ActionFactoryDefinition } from './action_factory_definition'; import { Configurable } from '../../../../../src/plugins/kibana_utils/public'; -import { SerializedAction } from './types'; +import { BaseActionFactoryContext, SerializedAction } from './types'; import { ILicense } from '../../../licensing/public'; import { UiActionsActionDefinition as ActionDefinition } from '../../../../../src/plugins/ui_actions/public'; export class ActionFactory< Config extends object = object, - FactoryContext extends object = object, - ActionContext extends object = object + SupportedTriggers extends TriggerId = TriggerId, + FactoryContext extends BaseActionFactoryContext = { + triggers: SupportedTriggers[]; + }, + ActionContext extends TriggerContextMapping[SupportedTriggers] = TriggerContextMapping[SupportedTriggers] > implements Omit, 'getHref'>, Configurable { constructor( - protected readonly def: ActionFactoryDefinition, + protected readonly def: ActionFactoryDefinition< + Config, + SupportedTriggers, + FactoryContext, + ActionContext + >, protected readonly getLicence: () => ILicense ) {} @@ -58,7 +70,8 @@ export class ActionFactory< */ public isCompatibleLicence() { if (!this.minimalLicense) return true; - return this.getLicence().hasAtLeast(this.minimalLicense); + const licence = this.getLicence(); + return licence.isAvailable && licence.isActive && licence.hasAtLeast(this.minimalLicense); } public create( @@ -74,4 +87,8 @@ export class ActionFactory< }, }; } + + public supportedTriggers(): SupportedTriggers[] { + return this.def.supportedTriggers(); + } } diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts index d63f69ba5ab72..0acd3ea3e51a7 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/action_factory_definition.ts @@ -5,9 +5,11 @@ */ import { Configurable } from '../../../../../src/plugins/kibana_utils/public'; -import { SerializedAction } from './types'; +import { BaseActionFactoryContext, SerializedAction } from './types'; import { LicenseType } from '../../../licensing/public'; import { + TriggerContextMapping, + TriggerId, UiActionsActionDefinition as ActionDefinition, UiActionsPresentable as Presentable, } from '../../../../../src/plugins/ui_actions/public'; @@ -17,8 +19,11 @@ import { */ export interface ActionFactoryDefinition< Config extends object = object, - FactoryContext extends object = object, - ActionContext extends object = object + SupportedTriggers extends TriggerId = TriggerId, + FactoryContext extends BaseActionFactoryContext = { + triggers: SupportedTriggers[]; + }, + ActionContext extends TriggerContextMapping[SupportedTriggers] = TriggerContextMapping[SupportedTriggers] > extends Partial, 'getHref'>>, Configurable { @@ -42,4 +47,6 @@ export interface ActionFactoryDefinition< create( serializedAction: Omit, 'factoryId'> ): ActionDefinition; + + supportedTriggers(): SupportedTriggers[]; } diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.test.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.test.ts index 930f88ff08775..0b0cd39e35e25 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.test.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.test.ts @@ -24,6 +24,9 @@ const actionFactoryDefinition1: ActionFactoryDefinition = { execute: async () => {}, getDisplayName: () => name, }), + supportedTriggers() { + return ['VALUE_CLICK_TRIGGER']; + }, }; const actionFactoryDefinition2: ActionFactoryDefinition = { @@ -36,6 +39,9 @@ const actionFactoryDefinition2: ActionFactoryDefinition = { execute: async () => {}, getDisplayName: () => name, }), + supportedTriggers() { + return ['VALUE_CLICK_TRIGGER']; + }, }; const event1: SerializedEvent = { @@ -417,6 +423,21 @@ describe('DynamicActionManager', () => { expect(actions.size).toBe(0); }); + + test('throws when trigger is unknown', async () => { + const { manager, uiActions } = setup([]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + await expect(manager.createEvent(action, ['SELECT_RANGE_TRIGGER'])).rejects; + }); }); }); diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.ts index 4afefe3006a43..6ca388281ad76 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/dynamic_action_manager.ts @@ -84,7 +84,17 @@ export class DynamicActionManager { return actionDefinition.isCompatible(context); }, }); - for (const trigger of triggers) uiActions.attachAction(trigger as any, actionId); + + const supportedTriggers = factory.supportedTriggers(); + for (const trigger of triggers) { + if (!supportedTriggers.includes(trigger as any)) + throw new Error( + `Can't attach [action=${actionId}] to [trigger=${trigger}]. Supported triggers for this action: ${supportedTriggers.join( + ',' + )}` + ); + uiActions.attachAction(trigger as any, actionId); + } } protected killAction({ eventId, triggers }: SerializedEvent) { diff --git a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/types.ts b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/types.ts index fb513e892d413..d00db0d9acb7a 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/types.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/dynamic_actions/types.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { TriggerId } from '../../../../../src/plugins/ui_actions/public'; + export interface SerializedAction { readonly factoryId: string; readonly name: string; @@ -18,3 +20,10 @@ export interface SerializedEvent { triggers: string[]; action: SerializedAction; } + +/** + * Action factory context passed into ActionFactories' CollectConfig, getDisplayName, getIconType + */ +export interface BaseActionFactoryContext { + triggers: SupportedTriggers[]; +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/index.ts b/x-pack/plugins/ui_actions_enhanced/public/index.ts index a3cfddb31d663..a255bc28f5c68 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/index.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/index.ts @@ -28,6 +28,7 @@ export { DynamicActionManagerParams as UiActionsEnhancedDynamicActionManagerParams, DynamicActionManagerState as UiActionsEnhancedDynamicActionManagerState, MemoryActionStorage as UiActionsEnhancedMemoryActionStorage, + BaseActionFactoryContext as UiActionsEnhancedBaseActionFactoryContext, } from './dynamic_actions'; export { DrilldownDefinition as UiActionsEnhancedDrilldownDefinition } from './drilldowns'; diff --git a/x-pack/plugins/ui_actions_enhanced/public/plugin.ts b/x-pack/plugins/ui_actions_enhanced/public/plugin.ts index a625ea2e2118b..5069b485b198d 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/plugin.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/plugin.ts @@ -13,7 +13,11 @@ import { } from '../../../../src/core/public'; import { createReactOverlays } from '../../../../src/plugins/kibana_react/public'; import { UI_SETTINGS } from '../../../../src/plugins/data/public'; -import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; +import { + TriggerId, + UiActionsSetup, + UiActionsStart, +} from '../../../../src/plugins/ui_actions/public'; import { CONTEXT_MENU_TRIGGER, PANEL_BADGE_TRIGGER, @@ -116,6 +120,7 @@ export class AdvancedUiActionsPublicPlugin ...this.enhancements, FlyoutManageDrilldowns: createFlyoutManageDrilldowns({ actionFactories: this.enhancements.getActionFactories(), + getTrigger: (triggerId: TriggerId) => uiActions.getTrigger(triggerId), storage: new Storage(window?.localStorage), toastService: core.notifications.toasts, docsLink: core.docLinks.links.dashboard.drilldowns, diff --git a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.test.ts b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.test.ts index 4f2ddcf7e0491..08823833b9af2 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.test.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.test.ts @@ -18,6 +18,9 @@ describe('UiActionsService', () => { createConfig: () => ({}), isConfigValid: () => true, create: () => ({} as any), + supportedTriggers() { + return ['VALUE_CLICK_TRIGGER']; + }, }; const factoryDefinition2: ActionFactoryDefinition = { id: 'test-factory-2', @@ -25,6 +28,9 @@ describe('UiActionsService', () => { createConfig: () => ({}), isConfigValid: () => true, create: () => ({} as any), + supportedTriggers() { + return ['VALUE_CLICK_TRIGGER']; + }, }; test('.getActionFactories() returns empty array if no action factories registered', () => { @@ -69,5 +75,18 @@ describe('UiActionsService', () => { 'Action factory [actionFactoryId = UNKNOWN_ID] does not exist.' ); }); + + test('isCompatible from definition is used on registered factory', async () => { + const service = new UiActionsServiceEnhancements({ getLicenseInfo }); + + service.registerActionFactory({ + ...factoryDefinition1, + isCompatible: () => Promise.resolve(false), + }); + + await expect( + service.getActionFactory(factoryDefinition1.id).isCompatible({ triggers: [] }) + ).resolves.toBe(false); + }); }); }); diff --git a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts index bd05659d59e9d..9575329514835 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/services/ui_actions_service_enhancements.ts @@ -5,9 +5,14 @@ */ import { ActionFactoryRegistry } from '../types'; -import { ActionFactory, ActionFactoryDefinition } from '../dynamic_actions'; +import { + ActionFactory, + ActionFactoryDefinition, + BaseActionFactoryContext, +} from '../dynamic_actions'; import { DrilldownDefinition } from '../drilldowns'; import { ILicense } from '../../../licensing/common/types'; +import { TriggerContextMapping, TriggerId } from '../../../../../src/plugins/ui_actions/public'; export interface UiActionsServiceEnhancementsParams { readonly actionFactories?: ActionFactoryRegistry; @@ -29,19 +34,24 @@ export class UiActionsServiceEnhancements { */ public readonly registerActionFactory = < Config extends object = object, - FactoryContext extends object = object, - ActionContext extends object = object + SupportedTriggers extends TriggerId = TriggerId, + FactoryContext extends BaseActionFactoryContext = { + triggers: SupportedTriggers[]; + }, + ActionContext extends TriggerContextMapping[SupportedTriggers] = TriggerContextMapping[SupportedTriggers] >( - definition: ActionFactoryDefinition + definition: ActionFactoryDefinition ) => { if (this.actionFactories.has(definition.id)) { throw new Error(`ActionFactory [actionFactory.id = ${definition.id}] already registered.`); } - const actionFactory = new ActionFactory( - definition, - this.getLicenseInfo - ); + const actionFactory = new ActionFactory< + Config, + SupportedTriggers, + FactoryContext, + ActionContext + >(definition, this.getLicenseInfo); this.actionFactories.set(actionFactory.id, actionFactory as ActionFactory); }; @@ -68,7 +78,11 @@ export class UiActionsServiceEnhancements { */ public readonly registerDrilldown = < Config extends object = object, - ExecutionContext extends object = object + SupportedTriggers extends TriggerId = TriggerId, + FactoryContext extends BaseActionFactoryContext = { + triggers: SupportedTriggers[]; + }, + ExecutionContext extends TriggerContextMapping[SupportedTriggers] = TriggerContextMapping[SupportedTriggers] >({ id: factoryId, order, @@ -80,8 +94,15 @@ export class UiActionsServiceEnhancements { execute, getHref, minimalLicense, - }: DrilldownDefinition): void => { - const actionFactory: ActionFactoryDefinition = { + supportedTriggers, + isCompatible, + }: DrilldownDefinition): void => { + const actionFactory: ActionFactoryDefinition< + Config, + SupportedTriggers, + FactoryContext, + ExecutionContext + > = { id: factoryId, minimalLicense, order, @@ -89,6 +110,7 @@ export class UiActionsServiceEnhancements { createConfig, isConfigValid, getDisplayName, + supportedTriggers, getIconType: () => euiIcon, isCompatible: async () => true, create: (serializedAction) => ({ @@ -98,8 +120,11 @@ export class UiActionsServiceEnhancements { getDisplayName: () => serializedAction.name, execute: async (context) => await execute(serializedAction.config, context), getHref: getHref ? async (context) => getHref(serializedAction.config, context) : undefined, + isCompatible: isCompatible + ? async (context) => isCompatible(serializedAction.config, context) + : undefined, }), - } as ActionFactoryDefinition; + } as ActionFactoryDefinition; this.registerActionFactory(actionFactory); }; diff --git a/x-pack/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx b/x-pack/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx index 3513606dbfe30..5c6cf22065d95 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx +++ b/x-pack/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx @@ -290,24 +290,16 @@ describe(' create route', () => { }); test('should simulate an index action', async () => { - const { form, find, actions, exists } = testBed; - - const INDEX = 'my_index'; + const { form, actions, exists } = testBed; actions.clickAddActionButton(); actions.clickActionLink('index'); expect(exists('watchActionAccordion')).toBe(true); - // First, provide invalid field and verify + // Verify an empty index is allowed form.setInputValue('indexInput', ''); - expect(form.getErrorsMessages()).toContain('Index name is required.'); - expect(find('simulateActionButton').props().disabled).toEqual(true); - - // Next, provide valid field and verify - form.setInputValue('indexInput', INDEX); - await act(async () => { actions.clickSimulateButton(); await nextTick(); @@ -327,7 +319,7 @@ describe(' create route', () => { id: 'index_1', type: 'index', index: { - index: INDEX, + index: '', }, }, ], diff --git a/x-pack/plugins/watcher/common/models/action/index_action.js b/x-pack/plugins/watcher/common/models/action/index_action.js index 3db4e4f9b0dab..e21505ea98c8c 100644 --- a/x-pack/plugins/watcher/common/models/action/index_action.js +++ b/x-pack/plugins/watcher/common/models/action/index_action.js @@ -80,21 +80,6 @@ export class IndexAction extends BaseAction { }); } - if (json.index && !json.index.index) { - errors.push({ - code: ERROR_CODES.ERR_PROP_MISSING, - message: i18n.translate( - 'xpack.watcher.models.loggingAction.actionJsonIndexNamePropertyMissingBadRequestMessage', - { - defaultMessage: 'JSON argument must contain an {actionJsonIndexName} property', - values: { - actionJsonIndexName: 'actionJson.index.index', - }, - } - ), - }); - } - return { errors: errors.length ? errors : null }; } } diff --git a/x-pack/plugins/watcher/public/application/models/action/index_action.js b/x-pack/plugins/watcher/public/application/models/action/index_action.js index 537a13fd855ca..6f9823e981e5b 100644 --- a/x-pack/plugins/watcher/public/application/models/action/index_action.js +++ b/x-pack/plugins/watcher/public/application/models/action/index_action.js @@ -19,13 +19,6 @@ export class IndexAction extends BaseAction { const errors = { index: [], }; - if (!this.index) { - errors.index.push( - i18n.translate('xpack.watcher.watchActions.index.indexIsRequiredValidationMessage', { - defaultMessage: 'Index name is required.', - }) - ); - } return errors; } diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index eeff81d492d26..205ff500a36ec 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -9,7 +9,9 @@ const alwaysImportedTests = [ require.resolve('../test/security_solution_endpoint/config.ts'), require.resolve('../test/functional_with_es_ssl/config.ts'), require.resolve('../test/functional/config_security_basic.ts'), - require.resolve('../test/functional/config_security_trial.ts'), + require.resolve('../test/security_functional/login_selector.config.ts'), + require.resolve('../test/security_functional/oidc.config.ts'), + require.resolve('../test/security_functional/saml.config.ts'), require.resolve('../test/functional_embedded/config.ts'), require.resolve('../test/functional_enterprise_search/without_host_configured.config.ts'), ]; @@ -28,6 +30,8 @@ const onlyNotInCoverageTests = [ require.resolve('../test/kerberos_api_integration/config.ts'), require.resolve('../test/kerberos_api_integration/anonymous_access.config.ts'), require.resolve('../test/saml_api_integration/config.ts'), + require.resolve('../test/security_api_integration/session_idle.config.ts'), + require.resolve('../test/security_api_integration/session_lifespan.config.ts'), require.resolve('../test/token_api_integration/config.js'), require.resolve('../test/oidc_api_integration/config.ts'), require.resolve('../test/oidc_api_integration/implicit_flow.config.ts'), diff --git a/x-pack/test/api_integration/apis/beats/enroll_beat.js b/x-pack/test/api_integration/apis/beats/enroll_beat.js index 305322e766e3c..18e0b250a91b5 100644 --- a/x-pack/test/api_integration/apis/beats/enroll_beat.js +++ b/x-pack/test/api_integration/apis/beats/enroll_beat.js @@ -97,8 +97,9 @@ export default function ({ getService }) { .expect(400); expect(apiResponse).to.eql({ - success: false, - error: { code: 400, message: 'Invalid enrollment token' }, + statusCode: 400, + error: 'Bad Request', + message: 'Invalid enrollment token', }); }); @@ -128,8 +129,9 @@ export default function ({ getService }) { .expect(400); expect(apiResponse).to.eql({ - success: false, - error: { code: 400, message: 'Expired enrollment token' }, + statusCode: 400, + error: 'Bad Request', + message: 'Expired enrollment token', }); }); diff --git a/x-pack/test/api_integration/apis/beats/update_beat.js b/x-pack/test/api_integration/apis/beats/update_beat.js index dff9c2a0ec072..dc4afe88353e1 100644 --- a/x-pack/test/api_integration/apis/beats/update_beat.js +++ b/x-pack/test/api_integration/apis/beats/update_beat.js @@ -92,7 +92,7 @@ export default function ({ getService }) { .send(beat) .expect(401); - expect(body.error.message).to.be('Invalid access token'); + expect(body.message).to.be('Invalid access token'); const beatInEs = await es.get({ index: ES_INDEX_NAME, @@ -115,7 +115,7 @@ export default function ({ getService }) { .send(beat) .expect(404); - expect(body.error.message).to.be('Beat not found'); + expect(body.message).to.be('Beat not found'); }); }); } diff --git a/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts b/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts index bf35a6283aae5..0d65b0e66c54d 100644 --- a/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts +++ b/x-pack/test/api_integration/apis/lists/create_exception_list_item.ts @@ -40,9 +40,11 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send(badItem) .expect(400); - expect(body.message).to.eql( - 'cannot add exception item with entry of type "list" to endpoint exception list' - ); + expect(body.message).to.eql([ + 'Invalid value "list" supplied to "type"', + 'Invalid value "undefined" supplied to "value"', + 'Invalid value "undefined" supplied to "entries"', + ]); }); it('should return a 400 if endpoint exception entry has disallowed field', async () => { diff --git a/x-pack/test/api_integration/apis/security/basic_login.js b/x-pack/test/api_integration/apis/security/basic_login.js index 284330cf0fc9d..70eddc9aee4d8 100644 --- a/x-pack/test/api_integration/apis/security/basic_login.js +++ b/x-pack/test/api_integration/apis/security/basic_login.js @@ -39,19 +39,34 @@ export default function ({ getService }) { await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: wrongUsername, password: wrongPassword }) + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: wrongUsername, password: wrongPassword }, + }) .expect(401); await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: validUsername, password: wrongPassword }) + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: validUsername, password: wrongPassword }, + }) .expect(401); await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: wrongUsername, password: validPassword }) + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: wrongUsername, password: validPassword }, + }) .expect(401); }); @@ -59,8 +74,13 @@ export default function ({ getService }) { const loginResponse = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: validUsername, password: validPassword }) - .expect(204); + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: validUsername, password: validPassword }, + }) + .expect(200); const cookies = loginResponse.headers['set-cookie']; expect(cookies).to.have.length(1); @@ -134,8 +154,13 @@ export default function ({ getService }) { const loginResponse = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: validUsername, password: validPassword }) - .expect(204); + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: validUsername, password: validPassword }, + }) + .expect(200); sessionCookie = request.cookie(loginResponse.headers['set-cookie'][0]); }); diff --git a/x-pack/test/api_integration/apis/security/change_password.ts b/x-pack/test/api_integration/apis/security/change_password.ts index 217c239596690..15b04cb7069a4 100644 --- a/x-pack/test/api_integration/apis/security/change_password.ts +++ b/x-pack/test/api_integration/apis/security/change_password.ts @@ -22,8 +22,13 @@ export default function ({ getService }: FtrProviderContext) { const loginResponse = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: mockUserName, password: mockUserPassword }) - .expect(204); + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: mockUserName, password: mockUserPassword }, + }) + .expect(200); sessionCookie = cookie(loginResponse.headers['set-cookie'][0])!; }); @@ -44,22 +49,37 @@ export default function ({ getService }: FtrProviderContext) { await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: mockUserName, password: wrongPassword }) + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: mockUserName, password: wrongPassword }, + }) .expect(401); // Let's check that we can't login with the password we were supposed to set. await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: mockUserName, password: newPassword }) + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: mockUserName, password: newPassword }, + }) .expect(401); // And can login with the current password. await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: mockUserName, password: mockUserPassword }) - .expect(204); + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: mockUserName, password: mockUserPassword }, + }) + .expect(200); }); it('should allow password change if current password is correct', async () => { @@ -74,21 +94,26 @@ export default function ({ getService }: FtrProviderContext) { const newSessionCookie = cookie(passwordChangeResponse.headers['set-cookie'][0])!; - // Let's check that previous cookie isn't valid anymore. + // Old cookie is still valid (since it's still the same user and cookie doesn't store password). await supertest .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) - .expect(401); + .expect(200); - // And that we can't login with the old password. + // But we can't login with the old password. await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: mockUserName, password: mockUserPassword }) + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: mockUserName, password: mockUserPassword }, + }) .expect(401); - // But new cookie should be valid. + // New cookie should be valid. await supertest .get('/internal/security/me') .set('kbn-xsrf', 'xxx') @@ -99,8 +124,13 @@ export default function ({ getService }: FtrProviderContext) { await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: mockUserName, password: newPassword }) - .expect(204); + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: mockUserName, password: newPassword }, + }) + .expect(200); }); }); } diff --git a/x-pack/test/api_integration/apis/security/index.js b/x-pack/test/api_integration/apis/security/index.js index 91552a3b873a8..19eddb311b451 100644 --- a/x-pack/test/api_integration/apis/security/index.js +++ b/x-pack/test/api_integration/apis/security/index.js @@ -18,6 +18,5 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./index_fields')); loadTestFile(require.resolve('./roles')); loadTestFile(require.resolve('./privileges')); - loadTestFile(require.resolve('./session')); }); } diff --git a/x-pack/test/api_integration/apis/security/security_basic.ts b/x-pack/test/api_integration/apis/security/security_basic.ts index 3bae51d3b86d8..191523e969717 100644 --- a/x-pack/test/api_integration/apis/security/security_basic.ts +++ b/x-pack/test/api_integration/apis/security/security_basic.ts @@ -20,6 +20,5 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./index_fields')); loadTestFile(require.resolve('./roles')); loadTestFile(require.resolve('./privileges_basic')); - loadTestFile(require.resolve('./session')); }); } diff --git a/x-pack/test/api_integration/services/legacy_es.js b/x-pack/test/api_integration/services/legacy_es.js index 0ea061365aca2..04b991151034a 100644 --- a/x-pack/test/api_integration/services/legacy_es.js +++ b/x-pack/test/api_integration/services/legacy_es.js @@ -8,7 +8,7 @@ import { format as formatUrl } from 'url'; import * as legacyElasticsearch from 'elasticsearch'; -import { elasticsearchClientPlugin as securityEsClientPlugin } from '../../../plugins/security/server/elasticsearch_client_plugin'; +import { elasticsearchClientPlugin as securityEsClientPlugin } from '../../../plugins/security/server/elasticsearch/elasticsearch_client_plugin'; import { elasticsearchJsPlugin as indexManagementEsClientPlugin } from '../../../plugins/index_management/server/client/elasticsearch'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { DEFAULT_API_VERSION } from '../../../../src/core/server/elasticsearch/elasticsearch_config'; diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 77b9aa1e25edd..8c4321d77acf4 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -11,6 +11,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['visualize', 'lens']); const find = getService('find'); const listingTable = getService('listingTable'); + const testSubjects = getService('testSubjects'); describe('lens smokescreen tests', () => { it('should allow editing saved visualizations', async () => { @@ -109,6 +110,54 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await PageObjects.lens.getLayerCount()).to.eql(2); }); + it('should switch from a multi-layer stacked bar to donut chart using suggestions', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'geo.dest', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'avg', + field: 'bytes', + }); + + await PageObjects.lens.createLayer(); + + await PageObjects.lens.configureDimension( + { + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'geo.src', + }, + 1 + ); + + await PageObjects.lens.configureDimension( + { + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'avg', + field: 'bytes', + }, + 1 + ); + await PageObjects.lens.save('twolayerchart'); + await testSubjects.click('lnsSuggestion-asDonut > lnsSuggestion'); + + expect(await PageObjects.lens.getLayerCount()).to.eql(1); + expect(await PageObjects.lens.getDimensionTriggerText('lnsPie_sliceByDimensionPanel')).to.eql( + 'Top values of geo.dest' + ); + expect(await PageObjects.lens.getDimensionTriggerText('lnsPie_sizeByDimensionPanel')).to.eql( + 'Average of bytes' + ); + }); + it('should allow transition from line chart to donut chart and to bar chart', async () => { await PageObjects.visualize.gotoVisualizationLandingPage(); await listingTable.searchForItemWithName('lnsXYvis'); diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index bed0e3a159e23..acba3fa472b1a 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -89,10 +89,14 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont * @param opts.dimension - the selector of the dimension being changed * @param opts.operation - the desired operation ID for the dimension * @param opts.field - the desired field for the dimension + * @param layerIndex - the index of the layer */ - async configureDimension(opts: { dimension: string; operation: string; field: string }) { + async configureDimension( + opts: { dimension: string; operation: string; field: string }, + layerIndex = 0 + ) { await retry.try(async () => { - await testSubjects.click(opts.dimension); + await testSubjects.click(`lns-layerPanel-${layerIndex} > ${opts.dimension}`); await testSubjects.exists(`lns-indexPatternDimension-${opts.operation}`); }); diff --git a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts index 8d2e575fad313..38a8697e05252 100644 --- a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts +++ b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts @@ -58,8 +58,13 @@ export default function ({ getService }: FtrProviderContext) { const response = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username, password }) - .expect(204); + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); const cookies = response.headers['set-cookie']; expect(cookies).to.have.length(1); diff --git a/x-pack/test/login_selector_api_integration/apis/login_selector.ts b/x-pack/test/login_selector_api_integration/apis/login_selector.ts index 54b37fe52cc56..439e553b17a86 100644 --- a/x-pack/test/login_selector_api_integration/apis/login_selector.ts +++ b/x-pack/test/login_selector_api_integration/apis/login_selector.ts @@ -76,7 +76,7 @@ export default function ({ getService }: FtrProviderContext) { it('should allow access to login selector with intermediate authentication cookie', async () => { const handshakeResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .set('kbn-xsrf', 'xxx') .send({ providerType: 'saml', providerName: 'saml1', currentURL: 'https://kibana.com/' }) @@ -176,19 +176,24 @@ export default function ({ getService }: FtrProviderContext) { }); it('should be able to log in via IdP initiated login even if session with other provider type exists', async () => { - const basicAuthenticationResponse = await supertest - .post('/internal/security/login') - .ca(CA_CERT) - .set('kbn-xsrf', 'xxx') - .send({ username: validUsername, password: validPassword }) - .expect(204); + for (const providerName of ['saml1', 'saml2']) { + const basicAuthenticationResponse = await supertest + .post('/internal/security/login') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic1', + currentURL: '/', + params: { username: validUsername, password: validPassword }, + }) + .expect(200); - const basicSessionCookie = request.cookie( - basicAuthenticationResponse.headers['set-cookie'][0] - )!; - await checkSessionCookie(basicSessionCookie, 'elastic', 'basic1'); + const basicSessionCookie = request.cookie( + basicAuthenticationResponse.headers['set-cookie'][0] + )!; + await checkSessionCookie(basicSessionCookie, 'elastic', 'basic1'); - for (const providerName of ['saml1', 'saml2']) { const authenticationResponse = await supertest .post('/api/security/saml/callback') .ca(CA_CERT) @@ -200,8 +205,9 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(302); - // It should be `/overwritten_session` instead of `/` once it's generalized. - expect(authenticationResponse.headers.location).to.be('/'); + expect(authenticationResponse.headers.location).to.be( + '/security/overwritten_session?next=%2F' + ); const cookies = authenticationResponse.headers['set-cookie']; expect(cookies).to.have.length(1); @@ -235,8 +241,9 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(302); - // It should be `/overwritten_session` instead of `/` once it's generalized. - expect(saml2AuthenticationResponse.headers.location).to.be('/'); + expect(saml2AuthenticationResponse.headers.location).to.be( + '/security/overwritten_session?next=%2F' + ); const saml2SessionCookie = request.cookie( saml2AuthenticationResponse.headers['set-cookie'][0] @@ -271,9 +278,9 @@ export default function ({ getService }: FtrProviderContext) { .send({ RelayState: '/app/kibana#/dashboards' }) .expect(302); - // It should be `/overwritten_session` with `?next='/app/kibana#/dashboards'` instead of just - // `'/app/kibana#/dashboards'` once it's generalized. - expect(saml2AuthenticationResponse.headers.location).to.be('/app/kibana#/dashboards'); + expect(saml2AuthenticationResponse.headers.location).to.be( + '/security/overwritten_session?next=%2Fapp%2Fkibana%23%2Fdashboards' + ); const saml2SessionCookie = request.cookie( saml2AuthenticationResponse.headers['set-cookie'][0] @@ -288,7 +295,7 @@ export default function ({ getService }: FtrProviderContext) { it('should fail for IdP initiated login if intermediate session with other SAML provider exists', async () => { // First start authentication flow with `saml1`. const saml1HandshakeResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .set('kbn-xsrf', 'xxx') .send({ @@ -320,7 +327,7 @@ export default function ({ getService }: FtrProviderContext) { it('should be able to log in via SP initiated login with any configured realm', async () => { for (const providerName of ['saml1', 'saml2']) { const handshakeResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .set('kbn-xsrf', 'xxx') .send({ @@ -366,7 +373,7 @@ export default function ({ getService }: FtrProviderContext) { it('should be able to log in via SP initiated login even if intermediate session with other SAML provider exists', async () => { // First start authentication flow with `saml1`. const saml1HandshakeResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .set('kbn-xsrf', 'xxx') .send({ @@ -386,7 +393,7 @@ export default function ({ getService }: FtrProviderContext) { // And now try to login with `saml2`. const saml2HandshakeResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .set('kbn-xsrf', 'xxx') .set('Cookie', saml1HandshakeCookie.cookieString()) @@ -428,7 +435,7 @@ export default function ({ getService }: FtrProviderContext) { describe('Kerberos', () => { it('should be able to log in from Login Selector', async () => { const spnegoResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .set('kbn-xsrf', 'xxx') .send({ @@ -442,7 +449,7 @@ export default function ({ getService }: FtrProviderContext) { expect(spnegoResponse.headers['www-authenticate']).to.be('Negotiate'); const authenticationResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .set('kbn-xsrf', 'xxx') .set('Authorization', `Negotiate ${getSPNEGOToken()}`) @@ -470,7 +477,7 @@ export default function ({ getService }: FtrProviderContext) { it('should be able to log in from Login Selector even if client provides certificate and PKI is enabled', async () => { const spnegoResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .pfx(CLIENT_CERT) .set('kbn-xsrf', 'xxx') @@ -485,7 +492,7 @@ export default function ({ getService }: FtrProviderContext) { expect(spnegoResponse.headers['www-authenticate']).to.be('Negotiate'); const authenticationResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .pfx(CLIENT_CERT) .set('kbn-xsrf', 'xxx') @@ -547,7 +554,7 @@ export default function ({ getService }: FtrProviderContext) { it('should be able to log in via SP initiated login', async () => { const handshakeResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .set('kbn-xsrf', 'xxx') .send({ @@ -612,7 +619,7 @@ export default function ({ getService }: FtrProviderContext) { it('should be able to log in from Login Selector', async () => { const authenticationResponse = await supertest - .post('/internal/security/login_with') + .post('/internal/security/login') .ca(CA_CERT) .pfx(CLIENT_CERT) .set('kbn-xsrf', 'xxx') diff --git a/x-pack/test/oidc_api_integration/apis/authorization_code_flow/index.js b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/index.ts similarity index 73% rename from x-pack/test/oidc_api_integration/apis/authorization_code_flow/index.js rename to x-pack/test/oidc_api_integration/apis/authorization_code_flow/index.ts index 0ef60bb929826..0acae074f129f 100644 --- a/x-pack/test/oidc_api_integration/apis/authorization_code_flow/index.js +++ b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/index.ts @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export default function ({ loadTestFile }) { +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { describe('apis', function () { this.tags('ciGroup6'); loadTestFile(require.resolve('./oidc_auth')); diff --git a/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.js b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.ts similarity index 73% rename from x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.js rename to x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.ts index f91eb492afe24..18dfdcffef363 100644 --- a/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.js +++ b/x-pack/test/oidc_api_integration/apis/authorization_code_flow/oidc_auth.ts @@ -5,41 +5,85 @@ */ import expect from '@kbn/expect'; -import request from 'request'; +import request, { Cookie } from 'request'; import url from 'url'; -import { getStateAndNonce } from '../../fixtures/oidc_tools'; import { delay } from 'bluebird'; +import { getStateAndNonce } from '../../fixtures/oidc_tools'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService }) { +export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); + const config = getService('config'); describe('OpenID Connect authentication', () => { it('should reject API requests if client is not authenticated', async () => { await supertest.get('/internal/security/me').set('kbn-xsrf', 'xxx').expect(401); }); + it('does not prevent basic login', async () => { + const [username, password] = config.get('servers.elasticsearch.auth').split(':'); + const response = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const { body: user } = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', request.cookie(cookies[0])!.cookieString()) + .expect(200); + + expect(user.username).to.eql(username); + expect(user.authentication_realm).to.eql({ name: 'reserved', type: 'reserved' }); + expect(user.authentication_provider).to.eql('basic'); + }); + describe('initiating handshake', () => { - it('should properly set cookie, return all parameters and redirect user', async () => { + it('should redirect user to a page that would capture URL fragment', async () => { const handshakeResponse = await supertest .get('/abc/xyz/handshake?one=two three') .expect(302); + expect(handshakeResponse.headers['set-cookie']).to.be(undefined); + expect(handshakeResponse.headers.location).to.be( + '/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc' + ); + }); + + it('should properly set cookie, return all parameters and redirect user', async () => { + const handshakeResponse = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); + const cookies = handshakeResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - const handshakeCookie = request.cookie(cookies[0]); + const handshakeCookie = request.cookie(cookies[0])!; expect(handshakeCookie.key).to.be('sid'); expect(handshakeCookie.value).to.not.be.empty(); expect(handshakeCookie.path).to.be('/'); expect(handshakeCookie.httpOnly).to.be(true); - const redirectURL = url.parse( - handshakeResponse.headers.location, - true /* parseQueryString */ - ); - expect(redirectURL.href.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`)).to.be( - true - ); + const redirectURL = url.parse(handshakeResponse.body.location, true /* parseQueryString */); + expect( + redirectURL.href!.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`) + ).to.be(true); expect(redirectURL.query.scope).to.not.be.empty(); expect(redirectURL.query.response_type).to.not.be.empty(); expect(redirectURL.query.client_id).to.not.be.empty(); @@ -57,7 +101,7 @@ export default function ({ getService }) { const cookies = handshakeResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - const handshakeCookie = request.cookie(cookies[0]); + const handshakeCookie = request.cookie(cookies[0])!; expect(handshakeCookie.key).to.be('sid'); expect(handshakeCookie.value).to.not.be.empty(); expect(handshakeCookie.path).to.be('/'); @@ -67,9 +111,9 @@ export default function ({ getService }) { handshakeResponse.headers.location, true /* parseQueryString */ ); - expect(redirectURL.href.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`)).to.be( - true - ); + expect( + redirectURL.href!.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`) + ).to.be(true); expect(redirectURL.query.scope).to.not.be.empty(); expect(redirectURL.query.response_type).to.not.be.empty(); expect(redirectURL.query.client_id).to.not.be.empty(); @@ -80,10 +124,17 @@ export default function ({ getService }) { it('should not allow access to the API with the handshake cookie', async () => { const handshakeResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); - const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; await supertest .get('/internal/security/me') .set('kbn-xsrf', 'xxx') @@ -102,16 +153,23 @@ export default function ({ getService }) { }); describe('finishing handshake', () => { - let stateAndNonce; - let handshakeCookie; + let stateAndNonce: { state: string; nonce: string }; + let handshakeCookie: Cookie; beforeEach(async () => { const handshakeResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); - handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); - stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + stateAndNonce = getStateAndNonce(handshakeResponse.body.location); // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens await supertest .post('/api/oidc_provider/setup') @@ -144,13 +202,13 @@ export default function ({ getService }) { // User should be redirected to the URL that initiated handshake. expect(oidcAuthenticationResponse.headers.location).to.be( - '/abc/xyz/handshake?one=two%20three' + '/abc/xyz/handshake?one=two%20three#/workpad' ); const cookies = oidcAuthenticationResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - const sessionCookie = request.cookie(cookies[0]); + const sessionCookie = request.cookie(cookies[0])!; expect(sessionCookie.key).to.be('sid'); expect(sessionCookie.value).to.not.be.empty(); expect(sessionCookie.path).to.be('/'); @@ -182,7 +240,7 @@ export default function ({ getService }) { const handshakeResponse = await supertest .get('/api/security/oidc/initiate_login?iss=https://test-op.elastic.co') .expect(302); - const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens @@ -200,7 +258,7 @@ export default function ({ getService }) { const cookies = oidcAuthenticationResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - const sessionCookie = request.cookie(cookies[0]); + const sessionCookie = request.cookie(cookies[0])!; expect(sessionCookie.key).to.be('sid'); expect(sessionCookie.value).to.not.be.empty(); expect(sessionCookie.path).to.be('/'); @@ -228,14 +286,23 @@ export default function ({ getService }) { }); describe('API access with active session', () => { - let stateAndNonce; - let sessionCookie; + let stateAndNonce: { state: string; nonce: string }; + let sessionCookie: Cookie; beforeEach(async () => { - const handshakeResponse = await supertest.get('/abc/xyz').expect(302); + const handshakeResponse = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); - sessionCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); - stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + sessionCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + stateAndNonce = getStateAndNonce(handshakeResponse.body.location); // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens await supertest .post('/api/oidc_provider/setup') @@ -249,7 +316,7 @@ export default function ({ getService }) { .set('Cookie', sessionCookie.cookieString()) .expect(302); - sessionCookie = request.cookie(oidcAuthenticationResponse.headers['set-cookie'][0]); + sessionCookie = request.cookie(oidcAuthenticationResponse.headers['set-cookie'][0])!; }); it('should extend cookie on every successful non-system API call', async () => { @@ -260,7 +327,7 @@ export default function ({ getService }) { .expect(200); expect(apiResponseOne.headers['set-cookie']).to.not.be(undefined); - const sessionCookieOne = request.cookie(apiResponseOne.headers['set-cookie'][0]); + const sessionCookieOne = request.cookie(apiResponseOne.headers['set-cookie'][0])!; expect(sessionCookieOne.value).to.not.be.empty(); expect(sessionCookieOne.value).to.not.equal(sessionCookie.value); @@ -272,7 +339,7 @@ export default function ({ getService }) { .expect(200); expect(apiResponseTwo.headers['set-cookie']).to.not.be(undefined); - const sessionCookieTwo = request.cookie(apiResponseTwo.headers['set-cookie'][0]); + const sessionCookieTwo = request.cookie(apiResponseTwo.headers['set-cookie'][0])!; expect(sessionCookieTwo.value).to.not.be.empty(); expect(sessionCookieTwo.value).to.not.equal(sessionCookieOne.value); @@ -302,15 +369,22 @@ export default function ({ getService }) { }); describe('logging out', () => { - let sessionCookie; + let sessionCookie: Cookie; beforeEach(async () => { const handshakeResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); - const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); - const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + const stateAndNonce = getStateAndNonce(handshakeResponse.body.location); // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens await supertest .post('/api/oidc_provider/setup') @@ -327,7 +401,7 @@ export default function ({ getService }) { const cookies = oidcAuthenticationResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - sessionCookie = request.cookie(cookies[0]); + sessionCookie = request.cookie(cookies[0])!; }); it('should redirect to home page if session cookie is not provided', async () => { @@ -346,7 +420,7 @@ export default function ({ getService }) { const cookies = logoutResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - const logoutCookie = request.cookie(cookies[0]); + const logoutCookie = request.cookie(cookies[0])!; expect(logoutCookie.key).to.be('sid'); expect(logoutCookie.value).to.be.empty(); expect(logoutCookie.path).to.be('/'); @@ -355,23 +429,16 @@ export default function ({ getService }) { const redirectURL = url.parse(logoutResponse.headers.location, true /* parseQueryString */); expect( - redirectURL.href.startsWith(`https://test-op.elastic.co/oauth2/v1/endsession`) + redirectURL.href!.startsWith(`https://test-op.elastic.co/oauth2/v1/endsession`) ).to.be(true); expect(redirectURL.query.id_token_hint).to.not.be.empty(); - // Tokens that were stored in the previous cookie should be invalidated as well and old - // session cookie should not allow API access. - const apiResponse = await supertest + // Session should be invalidated and old session cookie should not allow API access. + await supertest .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) - .expect(400); - - expect(apiResponse.body).to.eql({ - error: 'Bad Request', - message: 'Both access and refresh tokens are expired.', - statusCode: 400, - }); + .expect(401); }); it('should reject AJAX requests', async () => { @@ -391,15 +458,22 @@ export default function ({ getService }) { }); describe('API access with expired access token.', () => { - let sessionCookie; + let sessionCookie: Cookie; beforeEach(async () => { const handshakeResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); - const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); - const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + const stateAndNonce = getStateAndNonce(handshakeResponse.body.location); // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens await supertest .post('/api/oidc_provider/setup') @@ -416,10 +490,10 @@ export default function ({ getService }) { const cookies = oidcAuthenticationResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - sessionCookie = request.cookie(cookies[0]); + sessionCookie = request.cookie(cookies[0])!; }); - const expectNewSessionCookie = (cookie) => { + const expectNewSessionCookie = (cookie: Cookie) => { expect(cookie.key).to.be('sid'); expect(cookie.value).to.not.be.empty(); expect(cookie.path).to.be('/'); @@ -445,7 +519,7 @@ export default function ({ getService }) { const firstResponseCookies = firstResponse.headers['set-cookie']; expect(firstResponseCookies).to.have.length(1); - const firstNewCookie = request.cookie(firstResponseCookies[0]); + const firstNewCookie = request.cookie(firstResponseCookies[0])!; expectNewSessionCookie(firstNewCookie); // Request with old cookie should reuse the same refresh token if within 60 seconds. @@ -459,7 +533,7 @@ export default function ({ getService }) { const secondResponseCookies = secondResponse.headers['set-cookie']; expect(secondResponseCookies).to.have.length(1); - const secondNewCookie = request.cookie(secondResponseCookies[0]); + const secondNewCookie = request.cookie(secondResponseCookies[0])!; expectNewSessionCookie(secondNewCookie); expect(firstNewCookie.value).not.to.eql(secondNewCookie.value); @@ -481,15 +555,22 @@ export default function ({ getService }) { }); describe('API access with missing access token document.', () => { - let sessionCookie; + let sessionCookie: Cookie; beforeEach(async () => { const handshakeResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); - const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0]); - const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + const stateAndNonce = getStateAndNonce(handshakeResponse.body.location); // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens await supertest .post('/api/oidc_provider/setup') @@ -506,7 +587,7 @@ export default function ({ getService }) { const cookies = oidcAuthenticationResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - sessionCookie = request.cookie(cookies[0]); + sessionCookie = request.cookie(cookies[0])!; }); it('should properly set cookie and start new OIDC handshake', async function () { @@ -521,26 +602,30 @@ export default function ({ getService }) { expect(esResponse).to.have.property('deleted').greaterThan(0); const handshakeResponse = await supertest - .get('/abc/xyz/handshake?one=two three') + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) - .expect(302); + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); const cookies = handshakeResponse.headers['set-cookie']; expect(cookies).to.have.length(1); - const handshakeCookie = request.cookie(cookies[0]); + const handshakeCookie = request.cookie(cookies[0])!; expect(handshakeCookie.key).to.be('sid'); expect(handshakeCookie.value).to.not.be.empty(); expect(handshakeCookie.path).to.be('/'); expect(handshakeCookie.httpOnly).to.be(true); - const redirectURL = url.parse( - handshakeResponse.headers.location, - true /* parseQueryString */ - ); - expect(redirectURL.href.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`)).to.be( - true - ); + const redirectURL = url.parse(handshakeResponse.body.location, true /* parseQueryString */); + expect( + redirectURL.href!.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`) + ).to.be(true); expect(redirectURL.query.scope).to.not.be.empty(); expect(redirectURL.query.response_type).to.not.be.empty(); expect(redirectURL.query.client_id).to.not.be.empty(); diff --git a/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts b/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts index fbfb4df7fac63..bea2f996141d5 100644 --- a/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts +++ b/x-pack/test/oidc_api_integration/apis/implicit_flow/oidc_auth.ts @@ -22,11 +22,18 @@ export default function ({ getService }: FtrProviderContext) { beforeEach(async () => { const handshakeResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad', + }) + .expect(200); handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; - stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + stateAndNonce = getStateAndNonce(handshakeResponse.body.location); }); it('should return an HTML page that will parse URL fragment', async () => { @@ -117,7 +124,7 @@ export default function ({ getService }: FtrProviderContext) { // User should be redirected to the URL that initiated handshake. expect(oidcAuthenticationResponse.headers.location).to.be( - '/abc/xyz/handshake?one=two%20three' + '/abc/xyz/handshake?one=two%20three#/workpad' ); const cookies = oidcAuthenticationResponse.headers['set-cookie']; diff --git a/x-pack/test/oidc_api_integration/config.ts b/x-pack/test/oidc_api_integration/config.ts index 7a0d786e20130..08aa0a6d9c0dd 100644 --- a/x-pack/test/oidc_api_integration/config.ts +++ b/x-pack/test/oidc_api_integration/config.ts @@ -49,7 +49,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { serverArgs: [ ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), `--plugin-path=${plugin}`, - '--xpack.security.authc.providers=["oidc"]', + `--xpack.security.authc.providers=${JSON.stringify(['oidc', 'basic'])}`, '--xpack.security.authc.oidc.realm="oidc1"', ], }, diff --git a/x-pack/test/oidc_api_integration/fixtures/oidc_provider/server/init_routes.ts b/x-pack/test/oidc_api_integration/fixtures/oidc_provider/server/init_routes.ts index 385cbfc1ec099..73f92139806e3 100644 --- a/x-pack/test/oidc_api_integration/fixtures/oidc_provider/server/init_routes.ts +++ b/x-pack/test/oidc_api_integration/fixtures/oidc_provider/server/init_routes.ts @@ -4,11 +4,48 @@ * you may not use this file except in compliance with the Elastic License. */ +import { schema } from '@kbn/config-schema'; import { IRouter } from '../../../../../../src/core/server'; import { createTokens } from '../../oidc_tools'; export function initRoutes(router: IRouter) { let nonce = ''; + router.get( + { + path: '/oidc_provider/authorize', + validate: { + query: schema.object( + { redirect_uri: schema.string(), state: schema.string(), nonce: schema.string() }, + { unknowns: 'ignore' } + ), + }, + options: { authRequired: false }, + }, + async (context, request, response) => { + nonce = request.query.nonce; + + return response.redirected({ + headers: { + location: `${request.query.redirect_uri}?code=code1&state=${request.query.state}`, + }, + }); + } + ); + + router.get( + { + path: '/oidc_provider/endsession', + validate: { + query: schema.object({ post_logout_redirect_uri: schema.string() }, { unknowns: 'ignore' }), + }, + options: { authRequired: false }, + }, + async (context, request, response) => { + return response.redirected({ + headers: { location: request.query.post_logout_redirect_uri || '/' }, + }); + } + ); router.post( { diff --git a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts index 33e608d0b18f1..664fdb9fba67a 100644 --- a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts +++ b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts @@ -70,8 +70,13 @@ export default function ({ getService }: FtrProviderContext) { .ca(CA_CERT) .pfx(UNTRUSTED_CLIENT_CERT) .set('kbn-xsrf', 'xxx') - .send({ username, password }) - .expect(204); + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); const cookies = response.headers['set-cookie']; expect(cookies).to.have.length(1); @@ -147,6 +152,7 @@ export default function ({ getService }: FtrProviderContext) { .get('/internal/security/me') .ca(CA_CERT) .pfx(SECOND_CLIENT_CERT) + .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) .expect(200, { username: 'second_client', diff --git a/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts b/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts index 726115958d027..4b5b372c92641 100644 --- a/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts +++ b/x-pack/test/plugin_functional/test_suites/global_search/global_search_providers.ts @@ -40,7 +40,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(results.length).to.be(1); expect(results[0].type).to.be('index-pattern'); expect(results[0].title).to.be('logstash-*'); - expect(results[0].score).to.be.greaterThan(1); + expect(results[0].score).to.be.greaterThan(0.9); }); it('can search for visualizations', async () => { @@ -70,5 +70,12 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(results.map((r) => r.title)).to.contain('dashboard with map'); expect(results.map((r) => r.title)).to.contain('Amazing Dashboard'); }); + + it('can search by prefix', async () => { + const results = await findResultsWithAPI('Amaz'); + expect(results.length).to.be(1); + expect(results[0].type).to.be('dashboard'); + expect(results[0].title).to.be('Amazing Dashboard'); + }); }); } diff --git a/x-pack/test/saml_api_integration/apis/security/saml_login.ts b/x-pack/test/saml_api_integration/apis/security/saml_login.ts index ab33ecc1eb87a..d78f4da63ab5b 100644 --- a/x-pack/test/saml_api_integration/apis/security/saml_login.ts +++ b/x-pack/test/saml_api_integration/apis/security/saml_login.ts @@ -9,7 +9,6 @@ import url from 'url'; import { delay } from 'bluebird'; import expect from '@kbn/expect'; import request, { Cookie } from 'request'; -import { JSDOM } from 'jsdom'; import { getLogoutRequest, getSAMLRequestId, getSAMLResponse } from '../../fixtures/saml_tools'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -72,8 +71,13 @@ export default function ({ getService }: FtrProviderContext) { const response = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username, password }) - .expect(204); + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); const cookies = response.headers['set-cookie']; expect(cookies).to.have.length(1); @@ -89,88 +93,28 @@ export default function ({ getService }: FtrProviderContext) { expect(user.authentication_provider).to.eql('basic'); }); - describe('capture URL fragment', () => { + describe('initiating handshake', () => { it('should redirect user to a page that would capture URL fragment', async () => { const handshakeResponse = await supertest .get('/abc/xyz/handshake?one=two three') .expect(302); - // The cookie should capture current path. - const cookies = handshakeResponse.headers['set-cookie']; - expect(cookies).to.have.length(1); - - const handshakeCookie = request.cookie(cookies[0])!; - expect(handshakeCookie.key).to.be('sid'); - expect(handshakeCookie.value).to.not.be.empty(); - expect(handshakeCookie.path).to.be('/'); - expect(handshakeCookie.httpOnly).to.be(true); - + expect(handshakeResponse.headers['set-cookie']).to.be(undefined); expect(handshakeResponse.headers.location).to.be( - '/internal/security/saml/capture-url-fragment' + '/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml' ); }); - it('should return an HTML page that will extract URL fragment', async () => { - const response = await supertest - .get('/internal/security/saml/capture-url-fragment') - .expect(200); - - const kibanaBaseURL = url.format({ ...config.get('servers.kibana'), auth: false }); - const dom = new JSDOM(response.text, { - url: kibanaBaseURL, - runScripts: 'dangerously', - resources: 'usable', - beforeParse(window) { - // JSDOM doesn't support changing of `window.location` and throws an exception if script - // tries to do that and we have to workaround this behaviour. We also need to wait until our - // script is loaded and executed, __isScriptExecuted__ is used exactly for that. - (window as Record).__isScriptExecuted__ = new Promise((resolve) => { - Object.defineProperty(window, 'location', { - value: { - hash: '#/workpad', - href: `${kibanaBaseURL}/internal/security/saml/capture-url-fragment#/workpad`, - replace(newLocation: string) { - this.href = newLocation; - resolve(); - }, - }, - }); - }); - }, - }); - - await (dom.window as Record).__isScriptExecuted__; - - // Check that proxy page is returned with proper headers. - expect(response.headers['content-type']).to.be('text/html; charset=utf-8'); - expect(response.headers['cache-control']).to.be( - 'private, no-cache, no-store, must-revalidate' - ); - expect(response.headers['content-security-policy']).to.be( - `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'` - ); - - // Check that script that forwards URL fragment worked correctly. - expect(dom.window.location.href).to.be( - '/internal/security/saml/start?redirectURLFragment=%23%2Fworkpad' - ); - }); - }); - - describe('initiating handshake', () => { - const initiateHandshakeURL = `/internal/security/saml/start?redirectURLFragment=%23%2Fworkpad`; - - let captureURLCookie: Cookie; - beforeEach(async () => { - const response = await supertest.get('/abc/xyz/handshake?one=two three').expect(302); - captureURLCookie = request.cookie(response.headers['set-cookie'][0])!; - }); - it('should properly set cookie and redirect user to IdP', async () => { const handshakeResponse = await supertest - .get(initiateHandshakeURL) - .set('Cookie', captureURLCookie.cookieString()) - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml', + currentURL: `https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad`, + }) + .expect(200); const cookies = handshakeResponse.headers['set-cookie']; expect(cookies).to.have.length(1); @@ -181,19 +125,21 @@ export default function ({ getService }: FtrProviderContext) { expect(handshakeCookie.path).to.be('/'); expect(handshakeCookie.httpOnly).to.be(true); - const redirectURL = url.parse( - handshakeResponse.headers.location, - true /* parseQueryString */ - ); + const redirectURL = url.parse(handshakeResponse.body.location, true /* parseQueryString */); expect(redirectURL.href!.startsWith(`https://elastic.co/sso/saml`)).to.be(true); expect(redirectURL.query.SAMLRequest).to.not.be.empty(); }); - it('should not allow access to the API', async () => { + it('should not allow access to the API with the handshake cookie', async () => { const handshakeResponse = await supertest - .get(initiateHandshakeURL) - .set('Cookie', captureURLCookie.cookieString()) - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml', + currentURL: `https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad`, + }) + .expect(200); const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; await supertest @@ -218,18 +164,19 @@ export default function ({ getService }: FtrProviderContext) { let samlRequestId: string; beforeEach(async () => { - const captureURLResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); - const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; - const handshakeResponse = await supertest - .get(`/internal/security/saml/start?redirectURLFragment=%23%2Fworkpad`) - .set('Cookie', captureURLCookie.cookieString()) - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad', + }) + .expect(200); handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; - samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location); + samlRequestId = await getSAMLRequestId(handshakeResponse.body.location); }); it('should fail if SAML response is not complemented with handshake cookie', async () => { @@ -356,20 +303,19 @@ export default function ({ getService }: FtrProviderContext) { let idpSessionIndex: string; beforeEach(async () => { - const captureURLResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); - const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; - const handshakeResponse = await supertest - .get( - `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` - ) - .set('Cookie', captureURLCookie.cookieString()) - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad', + }) + .expect(200); const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; - const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location); + const samlRequestId = await getSAMLRequestId(handshakeResponse.body.location); idpSessionIndex = String(randomness.naturalNumber()); const samlAuthenticationResponse = await supertest @@ -407,19 +353,12 @@ export default function ({ getService }: FtrProviderContext) { expect(redirectURL.href!.startsWith(`https://elastic.co/slo/saml`)).to.be(true); expect(redirectURL.query.SAMLRequest).to.not.be.empty(); - // Tokens that were stored in the previous cookie should be invalidated as well and old - // session cookie should not allow API access. - const apiResponse = await supertest + // Session should be invalidated and old session cookie should not allow API access. + await supertest .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) - .expect(400); - - expect(apiResponse.body).to.eql({ - error: 'Bad Request', - message: 'Both access and refresh tokens are expired.', - statusCode: 400, - }); + .expect(401); }); it('should redirect to home page if session cookie is not provided', async () => { @@ -465,19 +404,12 @@ export default function ({ getService }: FtrProviderContext) { expect(redirectURL.href!.startsWith(`https://elastic.co/slo/saml`)).to.be(true); expect(redirectURL.query.SAMLResponse).to.not.be.empty(); - // Tokens that were stored in the previous cookie should be invalidated as well and old session - // cookie should not allow API access. - const apiResponse = await supertest + // Session should be invalidated and old session cookie should not allow API access. + await supertest .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', sessionCookie.cookieString()) - .expect(400); - - expect(apiResponse.body).to.eql({ - error: 'Bad Request', - message: 'Both access and refresh tokens are expired.', - statusCode: 400, - }); + .expect(401); }); it('should invalidate access token on IdP initiated logout even if there is no Kibana session', async () => { @@ -515,20 +447,19 @@ export default function ({ getService }: FtrProviderContext) { beforeEach(async function () { this.timeout(40000); - const captureURLResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); - const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; - const handshakeResponse = await supertest - .get( - `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` - ) - .set('Cookie', captureURLCookie.cookieString()) - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad', + }) + .expect(200); const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; - const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location); + const samlRequestId = await getSAMLRequestId(handshakeResponse.body.location); const samlAuthenticationResponse = await supertest .post('/api/security/saml/callback') @@ -616,20 +547,19 @@ export default function ({ getService }: FtrProviderContext) { let sessionCookie: Cookie; beforeEach(async () => { - const captureURLResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); - const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; - const handshakeResponse = await supertest - .get( - `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` - ) - .set('Cookie', captureURLCookie.cookieString()) - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml', + currentURL: + 'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad', + }) + .expect(200); const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; - const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location); + const samlRequestId = await getSAMLRequestId(handshakeResponse.body.location); const samlAuthenticationResponse = await supertest .post('/api/security/saml/callback') @@ -651,7 +581,7 @@ export default function ({ getService }: FtrProviderContext) { expect(esResponse).to.have.property('deleted').greaterThan(0); }); - it('should properly set cookie and start new SAML handshake', async () => { + it('should redirect user to a page that would capture URL fragment', async () => { const handshakeResponse = await supertest .get('/abc/xyz/handshake?one=two three') .set('Cookie', sessionCookie.cookieString()) @@ -662,15 +592,42 @@ export default function ({ getService }: FtrProviderContext) { const handshakeCookie = request.cookie(cookies[0])!; expect(handshakeCookie.key).to.be('sid'); - expect(handshakeCookie.value).to.not.be.empty(); + expect(handshakeCookie.value).to.be.empty(); expect(handshakeCookie.path).to.be('/'); expect(handshakeCookie.httpOnly).to.be(true); + expect(handshakeCookie.maxAge).to.be(0); expect(handshakeResponse.headers.location).to.be( - '/internal/security/saml/capture-url-fragment' + '/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml' ); }); + it('should properly set cookie and redirect user to IdP', async () => { + const handshakeResponse = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .send({ + providerType: 'saml', + providerName: 'saml', + currentURL: `https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad`, + }) + .expect(200); + + const cookies = handshakeResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const handshakeCookie = request.cookie(cookies[0])!; + expect(handshakeCookie.key).to.be('sid'); + expect(handshakeCookie.value).to.not.be.empty(); + expect(handshakeCookie.path).to.be('/'); + expect(handshakeCookie.httpOnly).to.be(true); + + const redirectURL = url.parse(handshakeResponse.body.location, true /* parseQueryString */); + expect(redirectURL.href!.startsWith(`https://elastic.co/sso/saml`)).to.be(true); + expect(redirectURL.query.SAMLRequest).to.not.be.empty(); + }); + it('should start new SAML handshake even if multiple concurrent requests try to refresh access token', async () => { // Issue 5 concurrent requests with a cookie that contains access/refresh token pair without // a corresponding document in Elasticsearch. @@ -711,20 +668,18 @@ export default function ({ getService }: FtrProviderContext) { ]; beforeEach(async () => { - const captureURLResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); - const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; - const handshakeResponse = await supertest - .get( - `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` - ) - .set('Cookie', captureURLCookie.cookieString()) - .expect(302); + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml', + currentURL: `https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad`, + }) + .expect(200); const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; - const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location); + const samlRequestId = await getSAMLRequestId(handshakeResponse.body.location); const samlAuthenticationResponse = await supertest .post('/api/security/saml/callback') @@ -762,18 +717,14 @@ export default function ({ getService }: FtrProviderContext) { expect(newSessionCookie.value).to.not.be.empty(); expect(newSessionCookie.value).to.not.equal(existingSessionCookie.value); - // Tokens from old cookie are invalidated. - const rejectedResponse = await supertest + // Same user, same provider - session ID hasn't changed and cookie should still be valid. + await supertest .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', existingSessionCookie.cookieString()) - .expect(400); - expect(rejectedResponse.body).to.have.property( - 'message', - 'Both access and refresh tokens are expired.' - ); + .expect(200); - // Only tokens from new session are valid. + // New session cookie is also valid. await checkSessionCookie(newSessionCookie); }); @@ -789,7 +740,7 @@ export default function ({ getService }: FtrProviderContext) { .expect(302); expect(samlAuthenticationResponse.headers.location).to.be( - '/security/overwritten_session' + '/security/overwritten_session?next=%2F' ); const newSessionCookie = request.cookie( @@ -798,99 +749,17 @@ export default function ({ getService }: FtrProviderContext) { expect(newSessionCookie.value).to.not.be.empty(); expect(newSessionCookie.value).to.not.equal(existingSessionCookie.value); - // Tokens from old cookie are invalidated. - const rejectedResponse = await supertest + // New username - old session is invalidated and session ID in the cookie no longer valid. + await supertest .get('/internal/security/me') .set('kbn-xsrf', 'xxx') .set('Cookie', existingSessionCookie.cookieString()) - .expect(400); - expect(rejectedResponse.body).to.have.property( - 'message', - 'Both access and refresh tokens are expired.' - ); + .expect(401); // Only tokens from new session are valid. await checkSessionCookie(newSessionCookie, newUsername); }); } }); - - describe('handshake with very long URL path or fragment', () => { - it('should not try to capture URL fragment if path is too big already', async () => { - // 1. Initiate SAML handshake. - const handshakeResponse = await supertest - .get(`/abc/xyz/${'handshake'.repeat(10)}?one=two three`) - .expect(302); - const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; - const redirectURL = url.parse( - handshakeResponse.headers.location, - true /* parseQueryString */ - ); - - expect(redirectURL.href!.startsWith(`https://elastic.co/sso/saml`)).to.be(true); - expect(redirectURL.query.SAMLRequest).to.not.be.empty(); - - // 2. Finish SAML handshake - const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location); - const samlAuthenticationResponse = await supertest - .post('/api/security/saml/callback') - .set('kbn-xsrf', 'xxx') - .set('Cookie', handshakeCookie.cookieString()) - .send({ SAMLResponse: await createSAMLResponse({ inResponseTo: samlRequestId }) }) - .expect(302); - - // User should be redirected to the root URL since we couldn't even save URL path. - expect(samlAuthenticationResponse.headers.location).to.be('/'); - - await checkSessionCookie( - request.cookie(samlAuthenticationResponse.headers['set-cookie'][0])! - ); - }); - - it('should capture only URL path if URL fragment is too big', async () => { - // 1. Capture current path - const captureURLResponse = await supertest - .get('/abc/xyz/handshake?one=two three') - .expect(302); - const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; - - expect(captureURLResponse.headers.location).to.be( - '/internal/security/saml/capture-url-fragment' - ); - - // 2. Initiate SAML handshake. - const handshakeResponse = await supertest - .get(`/internal/security/saml/start?redirectURLFragment=%23%2F${'workpad'.repeat(10)}`) - .set('Cookie', captureURLCookie.cookieString()) - .expect(302); - - const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; - const redirectURL = url.parse( - handshakeResponse.headers.location, - true /* parseQueryString */ - ); - - expect(redirectURL.href!.startsWith(`https://elastic.co/sso/saml`)).to.be(true); - expect(redirectURL.query.SAMLRequest).to.not.be.empty(); - - // 3. Finish SAML handshake - const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location); - const samlAuthenticationResponse = await supertest - .post('/api/security/saml/callback') - .set('kbn-xsrf', 'xxx') - .set('Cookie', handshakeCookie.cookieString()) - .send({ SAMLResponse: await createSAMLResponse({ inResponseTo: samlRequestId }) }) - .expect(302); - - // User should be redirected to the URL path that initiated SAML handshake. - expect(samlAuthenticationResponse.headers.location).to.be( - '/abc/xyz/handshake?one=two%20three' - ); - - await checkSessionCookie( - request.cookie(samlAuthenticationResponse.headers['set-cookie'][0])! - ); - }); - }); }); } diff --git a/x-pack/test/saml_api_integration/fixtures/saml_provider/server/init_routes.ts b/x-pack/test/saml_api_integration/fixtures/saml_provider/server/init_routes.ts index 544d8313ef280..f2c91ea7d1e03 100644 --- a/x-pack/test/saml_api_integration/fixtures/saml_provider/server/init_routes.ts +++ b/x-pack/test/saml_api_integration/fixtures/saml_provider/server/init_routes.ts @@ -43,4 +43,15 @@ export function initRoutes(core: CoreSetup) { return response.renderJs({ body: 'document.getElementById("loginForm").submit();' }); } ); + + core.http.resources.register( + { + path: '/saml_provider/logout', + validate: false, + options: { authRequired: false }, + }, + async (context, request, response) => { + return response.redirected({ headers: { location: '/logout?SAMLResponse=something' } }); + } + ); } diff --git a/x-pack/test/saved_object_api_integration/common/services/legacy_es.js b/x-pack/test/saved_object_api_integration/common/services/legacy_es.js index 9267fa312ed06..c8bf1810daafe 100644 --- a/x-pack/test/saved_object_api_integration/common/services/legacy_es.js +++ b/x-pack/test/saved_object_api_integration/common/services/legacy_es.js @@ -8,7 +8,7 @@ import { format as formatUrl } from 'url'; import * as legacyElasticsearch from 'elasticsearch'; -import { elasticsearchClientPlugin } from '../../../../plugins/security/server/elasticsearch_client_plugin'; +import { elasticsearchClientPlugin } from '../../../../plugins/security/server/elasticsearch/elasticsearch_client_plugin'; export function LegacyEsProvider({ getService }) { const config = getService('config'); diff --git a/x-pack/legacy/plugins/beats_management/server/kibana.index.ts b/x-pack/test/security_api_integration/ftr_provider_context.d.ts similarity index 50% rename from x-pack/legacy/plugins/beats_management/server/kibana.index.ts rename to x-pack/test/security_api_integration/ftr_provider_context.d.ts index dd7bc443bc603..227a19df685fb 100644 --- a/x-pack/legacy/plugins/beats_management/server/kibana.index.ts +++ b/x-pack/test/security_api_integration/ftr_provider_context.d.ts @@ -4,10 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { compose } from './lib/compose/kibana'; -import { initManagementServer } from './management_server'; +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; +import { services } from './services'; -export const initServerWithKibana = (hapiServer: any) => { - const libs = compose(hapiServer); - initManagementServer(libs); -}; +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/security_api_integration/services.ts b/x-pack/test/security_api_integration/services.ts new file mode 100644 index 0000000000000..e2abfa71451bc --- /dev/null +++ b/x-pack/test/security_api_integration/services.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { services as commonServices } from '../common/services'; +import { services as apiIntegrationServices } from '../api_integration/services'; + +export const services = { + ...commonServices, + legacyEs: apiIntegrationServices.legacyEs, + supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, +}; diff --git a/x-pack/test/security_api_integration/session_idle.config.ts b/x-pack/test/security_api_integration/session_idle.config.ts new file mode 100644 index 0000000000000..da85c6342037e --- /dev/null +++ b/x-pack/test/security_api_integration/session_idle.config.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 { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +// 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 }: FtrConfigProviderContext) { + const kibanaAPITestsConfig = await readConfigFile( + require.resolve('../../../test/api_integration/config.js') + ); + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); + + return { + testFiles: [resolve(__dirname, './tests/session_idle')], + + services: { + legacyEs: kibanaAPITestsConfig.get('services.legacyEs'), + supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), + }, + + servers: xPackAPITestsConfig.get('servers'), + esTestCluster: xPackAPITestsConfig.get('esTestCluster'), + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + '--xpack.security.session.idleTimeout=5s', + '--xpack.security.session.cleanupInterval=10s', + ], + }, + + junit: { + reportName: 'X-Pack Security API Integration Tests', + }, + }; +} diff --git a/x-pack/test/security_api_integration/session_lifespan.config.ts b/x-pack/test/security_api_integration/session_lifespan.config.ts new file mode 100644 index 0000000000000..17773a7739847 --- /dev/null +++ b/x-pack/test/security_api_integration/session_lifespan.config.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 { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +// 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 }: FtrConfigProviderContext) { + const kibanaAPITestsConfig = await readConfigFile( + require.resolve('../../../test/api_integration/config.js') + ); + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); + + return { + testFiles: [resolve(__dirname, './tests/session_lifespan')], + + services: { + legacyEs: kibanaAPITestsConfig.get('services.legacyEs'), + supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), + }, + + servers: xPackAPITestsConfig.get('servers'), + esTestCluster: xPackAPITestsConfig.get('esTestCluster'), + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + '--xpack.security.session.lifespan=5s', + '--xpack.security.session.cleanupInterval=10s', + ], + }, + + junit: { + reportName: 'X-Pack Security API Integration Tests', + }, + }; +} diff --git a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts new file mode 100644 index 0000000000000..c4302b7637923 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import request, { Cookie } from 'request'; +import { delay } from 'bluebird'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const es = getService('legacyEs'); + const config = getService('config'); + const log = getService('log'); + const [username, password] = config.get('servers.elasticsearch.auth').split(':'); + + async function checkSessionCookie(sessionCookie: Cookie, providerName: string) { + const apiResponse = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponse.body.username).to.be(username); + expect(apiResponse.body.authentication_provider).to.be(providerName); + + return request.cookie(apiResponse.headers['set-cookie'][0])!; + } + + async function getNumberOfSessionDocuments() { + return (await es.search({ index: '.kibana_security_session*' })).hits.total.value; + } + + describe('Session Idle cleanup', () => { + beforeEach(async () => { + await es.cluster.health({ index: '.kibana_security_session*', waitForStatus: 'green' }); + await es.deleteByQuery({ + index: '.kibana_security_session*', + q: '*', + waitForCompletion: true, + refresh: true, + ignore: [404], + }); + }); + + it('should properly clean up session expired because of idle timeout', async function () { + this.timeout(60000); + + const response = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + + const sessionCookie = request.cookie(response.headers['set-cookie'][0])!; + await checkSessionCookie(sessionCookie, 'basic'); + expect(await getNumberOfSessionDocuments()).to.be(1); + + // Cleanup routine runs every 10s, and idle timeout threshold is three times larger than 5s + // idle timeout, let's wait for 30s to make sure cleanup routine runs when idle timeout + // threshold is exceeded. + await delay(30000); + + // Session info is removed from the index and cookie isn't valid anymore + expect(await getNumberOfSessionDocuments()).to.be(0); + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + }); + + it('should not clean up session if user is active', async function () { + this.timeout(60000); + + const response = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + + let sessionCookie = request.cookie(response.headers['set-cookie'][0])!; + await checkSessionCookie(sessionCookie, 'basic'); + expect(await getNumberOfSessionDocuments()).to.be(1); + + // Run 20 consequent requests with 1.5s delay, during this time cleanup procedure should run at + // least twice. + for (const counter of [...Array(20).keys()]) { + // Session idle timeout is 15s, let's wait 10s and make a new request that would extend the session. + await delay(1500); + + sessionCookie = await checkSessionCookie(sessionCookie, 'basic'); + log.debug(`Session is still valid after ${(counter + 1) * 1.5}s`); + } + + // Session document should still be present. + expect(await getNumberOfSessionDocuments()).to.be(1); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/security/session.ts b/x-pack/test/security_api_integration/tests/session_idle/extension.ts similarity index 93% rename from x-pack/test/api_integration/apis/security/session.ts rename to x-pack/test/security_api_integration/tests/session_idle/extension.ts index ddd36f3322558..64ecdda201301 100644 --- a/x-pack/test/api_integration/apis/security/session.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/extension.ts @@ -45,8 +45,13 @@ export default function ({ getService }: FtrProviderContext) { await supertestWithoutAuth .post('/internal/security/login') .set('kbn-xsrf', 'xxx') - .send({ username: validUsername, password: validPassword }) - .expect(204) + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username: validUsername, password: validPassword }, + }) + .expect(200) .then(saveCookie); }); diff --git a/x-pack/test/security_api_integration/tests/session_idle/index.ts b/x-pack/test/security_api_integration/tests/session_idle/index.ts new file mode 100644 index 0000000000000..85dfba2b6b5ef --- /dev/null +++ b/x-pack/test/security_api_integration/tests/session_idle/index.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. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('security APIs - Session Idle', function () { + this.tags('ciGroup6'); + + loadTestFile(require.resolve('./cleanup')); + loadTestFile(require.resolve('./extension')); + }); +} diff --git a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts new file mode 100644 index 0000000000000..d9cb671282124 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts @@ -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. + */ + +import request, { Cookie } from 'request'; +import { delay } from 'bluebird'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const es = getService('legacyEs'); + const config = getService('config'); + const [username, password] = config.get('servers.elasticsearch.auth').split(':'); + + async function checkSessionCookie(sessionCookie: Cookie, providerName: string) { + const apiResponse = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponse.body.username).to.be(username); + expect(apiResponse.body.authentication_provider).to.be(providerName); + } + + async function getNumberOfSessionDocuments() { + return (await es.search({ index: '.kibana_security_session*' })).hits.total.value; + } + + describe('Session Lifespan cleanup', () => { + beforeEach(async () => { + await es.cluster.health({ index: '.kibana_security_session*', waitForStatus: 'green' }); + await es.deleteByQuery({ + index: '.kibana_security_session*', + q: '*', + waitForCompletion: true, + refresh: true, + ignore: [404], + }); + }); + + it('should properly clean up session expired because of lifespan', async function () { + this.timeout(60000); + + const response = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + + const sessionCookie = request.cookie(response.headers['set-cookie'][0])!; + await checkSessionCookie(sessionCookie, 'basic'); + expect(await getNumberOfSessionDocuments()).to.be(1); + + // Cleanup routine runs every 10s, let's wait for 30s to make sure it runs multiple times and + // when lifespan is exceeded. + await delay(30000); + + // Session info is removed from the index and cookie isn't valid anymore + expect(await getNumberOfSessionDocuments()).to.be(0); + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + }); + }); +} diff --git a/x-pack/test/security_api_integration/tests/session_lifespan/index.ts b/x-pack/test/security_api_integration/tests/session_lifespan/index.ts new file mode 100644 index 0000000000000..bfa14dd1076f2 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/session_lifespan/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 APIs - Session Lifespan', function () { + this.tags('ciGroup6'); + + loadTestFile(require.resolve('./cleanup')); + }); +} diff --git a/x-pack/test/security_functional/ftr_provider_context.d.ts b/x-pack/test/security_functional/ftr_provider_context.d.ts new file mode 100644 index 0000000000000..d8f146e4c6f6b --- /dev/null +++ b/x-pack/test/security_functional/ftr_provider_context.d.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; + +import { pageObjects } from '../functional/page_objects'; +import { services } from '../functional/services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/functional/config_security_trial.ts b/x-pack/test/security_functional/login_selector.config.ts similarity index 92% rename from x-pack/test/functional/config_security_trial.ts rename to x-pack/test/security_functional/login_selector.config.ts index e34baef0be477..48665c93c091a 100644 --- a/x-pack/test/functional/config_security_trial.ts +++ b/x-pack/test/security_functional/login_selector.config.ts @@ -4,12 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -/* eslint-disable import/no-default-export */ - import { resolve } from 'path'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; -import { services } from './services'; -import { pageObjects } from './page_objects'; +import { services } from '../functional/services'; +import { pageObjects } from '../functional/page_objects'; // the default export of config files must be a config provider // that returns an object with the projects config values @@ -26,7 +24,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { const samlIdPPlugin = resolve(__dirname, '../saml_api_integration/fixtures/saml_provider'); return { - testFiles: [resolve(__dirname, './apps/security/trial_license')], + testFiles: [resolve(__dirname, './tests/login_selector')], services, pageObjects, @@ -78,7 +76,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { screenshots: { directory: resolve(__dirname, 'screenshots') }, junit: { - reportName: 'Chrome X-Pack UI Functional Tests', + reportName: 'Chrome X-Pack Security Functional Tests', }, }; } diff --git a/x-pack/test/security_functional/oidc.config.ts b/x-pack/test/security_functional/oidc.config.ts new file mode 100644 index 0000000000000..5fd59e049a0f4 --- /dev/null +++ b/x-pack/test/security_functional/oidc.config.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { services } from '../functional/services'; +import { pageObjects } from '../functional/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 }: FtrConfigProviderContext) { + const kibanaCommonConfig = await readConfigFile( + require.resolve('../../../test/common/config.js') + ); + const kibanaFunctionalConfig = await readConfigFile( + require.resolve('../../../test/functional/config.js') + ); + + const kibanaPort = kibanaFunctionalConfig.get('servers.kibana.port'); + const jwksPath = resolve(__dirname, '../oidc_api_integration/fixtures/jwks.json'); + const oidcOpPPlugin = resolve(__dirname, '../oidc_api_integration/fixtures/oidc_provider'); + + return { + testFiles: [resolve(__dirname, './tests/oidc')], + + services, + pageObjects, + + servers: kibanaFunctionalConfig.get('servers'), + + esTestCluster: { + license: 'trial', + from: 'snapshot', + serverArgs: [ + 'xpack.security.authc.token.enabled=true', + 'xpack.security.authc.realms.oidc.oidc1.order=0', + `xpack.security.authc.realms.oidc.oidc1.rp.client_id=0oa8sqpov3TxMWJOt356`, + `xpack.security.authc.realms.oidc.oidc1.rp.client_secret=0oa8sqpov3TxMWJOt356`, + `xpack.security.authc.realms.oidc.oidc1.rp.response_type=code`, + `xpack.security.authc.realms.oidc.oidc1.rp.redirect_uri=http://localhost:${kibanaPort}/api/security/oidc/callback`, + `xpack.security.authc.realms.oidc.oidc1.rp.post_logout_redirect_uri=http://localhost:${kibanaPort}/security/logged_out`, + `xpack.security.authc.realms.oidc.oidc1.op.authorization_endpoint=http://localhost:${kibanaPort}/oidc_provider/authorize`, + `xpack.security.authc.realms.oidc.oidc1.op.endsession_endpoint=http://localhost:${kibanaPort}/oidc_provider/endsession`, + `xpack.security.authc.realms.oidc.oidc1.op.token_endpoint=http://localhost:${kibanaPort}/api/oidc_provider/token_endpoint`, + `xpack.security.authc.realms.oidc.oidc1.op.userinfo_endpoint=http://localhost:${kibanaPort}/api/oidc_provider/userinfo_endpoint`, + `xpack.security.authc.realms.oidc.oidc1.op.issuer=https://test-op.elastic.co`, + `xpack.security.authc.realms.oidc.oidc1.op.jwkset_path=${jwksPath}`, + `xpack.security.authc.realms.oidc.oidc1.claims.principal=sub`, + ], + }, + + kbnTestServer: { + ...kibanaCommonConfig.get('kbnTestServer'), + serverArgs: [ + ...kibanaCommonConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${oidcOpPPlugin}`, + '--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d', + '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', + '--xpack.security.authc.selector.enabled=false', + '--xpack.security.authc.providers.oidc.oidc1.order=0', + '--xpack.security.authc.providers.oidc.oidc1.realm=oidc1', + '--xpack.security.authc.providers.basic.basic1.order=1', + ], + }, + uiSettings: { + defaults: { + 'accessibility:disableAnimations': true, + 'dateFormat:tz': 'UTC', + }, + }, + apps: kibanaFunctionalConfig.get('apps'), + esArchiver: { directory: resolve(__dirname, 'es_archives') }, + screenshots: { directory: resolve(__dirname, 'screenshots') }, + + junit: { + reportName: 'Chrome X-Pack Security Functional Tests', + }, + }; +} diff --git a/x-pack/test/security_functional/saml.config.ts b/x-pack/test/security_functional/saml.config.ts new file mode 100644 index 0000000000000..c47145f8bc039 --- /dev/null +++ b/x-pack/test/security_functional/saml.config.ts @@ -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. + */ + +import { resolve } from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { services } from '../functional/services'; +import { pageObjects } from '../functional/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 }: FtrConfigProviderContext) { + const kibanaCommonConfig = await readConfigFile( + require.resolve('../../../test/common/config.js') + ); + const kibanaFunctionalConfig = await readConfigFile( + require.resolve('../../../test/functional/config.js') + ); + + const kibanaPort = kibanaFunctionalConfig.get('servers.kibana.port'); + const idpPath = resolve(__dirname, '../saml_api_integration/fixtures/saml_provider/metadata.xml'); + const samlIdPPlugin = resolve(__dirname, '../saml_api_integration/fixtures/saml_provider'); + + return { + testFiles: [resolve(__dirname, './tests/saml')], + + services, + pageObjects, + + servers: kibanaFunctionalConfig.get('servers'), + + esTestCluster: { + license: 'trial', + from: 'snapshot', + serverArgs: [ + 'xpack.security.authc.token.enabled=true', + 'xpack.security.authc.realms.saml.saml1.order=0', + `xpack.security.authc.realms.saml.saml1.idp.metadata.path=${idpPath}`, + 'xpack.security.authc.realms.saml.saml1.idp.entity_id=http://www.elastic.co/saml1', + `xpack.security.authc.realms.saml.saml1.sp.entity_id=http://localhost:${kibanaPort}`, + `xpack.security.authc.realms.saml.saml1.sp.logout=http://localhost:${kibanaPort}/logout`, + `xpack.security.authc.realms.saml.saml1.sp.acs=http://localhost:${kibanaPort}/api/security/saml/callback`, + 'xpack.security.authc.realms.saml.saml1.attributes.principal=urn:oid:0.0.7', + ], + }, + + kbnTestServer: { + ...kibanaCommonConfig.get('kbnTestServer'), + serverArgs: [ + ...kibanaCommonConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${samlIdPPlugin}`, + '--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d', + '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', + '--xpack.security.authc.selector.enabled=false', + '--xpack.security.authc.providers.saml.saml1.order=0', + '--xpack.security.authc.providers.saml.saml1.realm=saml1', + '--xpack.security.authc.providers.basic.basic1.order=1', + ], + }, + uiSettings: { + defaults: { + 'accessibility:disableAnimations': true, + 'dateFormat:tz': 'UTC', + }, + }, + apps: kibanaFunctionalConfig.get('apps'), + esArchiver: { directory: resolve(__dirname, 'es_archives') }, + screenshots: { directory: resolve(__dirname, 'screenshots') }, + + junit: { + reportName: 'Chrome X-Pack Security Functional Tests', + }, + }; +} diff --git a/x-pack/test/functional/apps/security/trial_license/login_selector.ts b/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts similarity index 94% rename from x-pack/test/functional/apps/security/trial_license/login_selector.ts rename to x-pack/test/security_functional/tests/login_selector/basic_functionality.ts index e0b776cd123c1..153387c52e5c3 100644 --- a/x-pack/test/functional/apps/security/trial_license/login_selector.ts +++ b/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import { parse } from 'url'; -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -14,7 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); const PageObjects = getPageObjects(['security', 'common']); - describe('Login Selector', function () { + describe('Basic functionality', function () { this.tags('includeFirefox'); before(async () => { @@ -23,12 +23,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { .send({ roles: ['superuser'], enabled: true, rules: { field: { 'realm.name': 'saml1' } } }) .expect(200); - await esArchiver.load('empty_kibana'); + await esArchiver.load('../../functional/es_archives/empty_kibana'); await PageObjects.security.forceLogout(); }); after(async () => { - await esArchiver.unload('empty_kibana'); + await esArchiver.unload('../../functional/es_archives/empty_kibana'); }); beforeEach(async () => { diff --git a/x-pack/test/security_functional/tests/login_selector/index.ts b/x-pack/test/security_functional/tests/login_selector/index.ts new file mode 100644 index 0000000000000..0d1060fbf1f51 --- /dev/null +++ b/x-pack/test/security_functional/tests/login_selector/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 - login selector', function () { + this.tags('ciGroup4'); + + loadTestFile(require.resolve('./basic_functionality')); + }); +} diff --git a/x-pack/test/functional/apps/security/trial_license/index.ts b/x-pack/test/security_functional/tests/oidc/index.ts similarity index 65% rename from x-pack/test/functional/apps/security/trial_license/index.ts rename to x-pack/test/security_functional/tests/oidc/index.ts index 99d600c1eafda..2b6e433409fb4 100644 --- a/x-pack/test/functional/apps/security/trial_license/index.ts +++ b/x-pack/test/security_functional/tests/oidc/index.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { - describe('security app - trial license', function () { + describe('security app - OIDC interactions', function () { this.tags('ciGroup4'); - loadTestFile(require.resolve('./login_selector')); + loadTestFile(require.resolve('./url_capture')); }); } diff --git a/x-pack/test/security_functional/tests/oidc/url_capture.ts b/x-pack/test/security_functional/tests/oidc/url_capture.ts new file mode 100644 index 0000000000000..bb4917f18fc1c --- /dev/null +++ b/x-pack/test/security_functional/tests/oidc/url_capture.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const find = getService('find'); + const browser = getService('browser'); + const PageObjects = getPageObjects(['common']); + + describe('URL capture', function () { + this.tags('includeFirefox'); + + before(async () => { + await getService('esSupertest') + .post('/_security/role_mapping/oidc1') + .send({ roles: ['superuser'], enabled: true, rules: { field: { 'realm.name': 'oidc1' } } }) + .expect(200); + + await esArchiver.load('../../functional/es_archives/empty_kibana'); + }); + + after(async () => { + await esArchiver.unload('../../functional/es_archives/empty_kibana'); + }); + + afterEach(async () => { + await browser.get(PageObjects.common.getHostPort() + '/logout'); + await PageObjects.common.waitUntilUrlIncludes('logged_out'); + }); + + it('can login preserving original URL', async () => { + await browser.get( + PageObjects.common.getHostPort() + '/app/management/security/users#some=hash-value' + ); + + await find.byCssSelector( + '[data-test-subj="kibanaChrome"] .app-wrapper:not(.hidden-chrome)', + 20000 + ); + + // We need to make sure that both path and hash are respected. + const currentURL = parse(await browser.getCurrentUrl()); + expect(currentURL.pathname).to.eql('/app/management/security/users'); + expect(currentURL.hash).to.eql('#some=hash-value'); + }); + }); +} diff --git a/x-pack/test/security_functional/tests/saml/index.ts b/x-pack/test/security_functional/tests/saml/index.ts new file mode 100644 index 0000000000000..4b3d6a925bf76 --- /dev/null +++ b/x-pack/test/security_functional/tests/saml/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 - SAML interactions', function () { + this.tags('ciGroup4'); + + loadTestFile(require.resolve('./url_capture')); + }); +} diff --git a/x-pack/test/security_functional/tests/saml/url_capture.ts b/x-pack/test/security_functional/tests/saml/url_capture.ts new file mode 100644 index 0000000000000..5d47d80efadcb --- /dev/null +++ b/x-pack/test/security_functional/tests/saml/url_capture.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const find = getService('find'); + const browser = getService('browser'); + const PageObjects = getPageObjects(['common']); + + describe('URL capture', function () { + this.tags('includeFirefox'); + + before(async () => { + await getService('esSupertest') + .post('/_security/role_mapping/saml1') + .send({ roles: ['superuser'], enabled: true, rules: { field: { 'realm.name': 'saml1' } } }) + .expect(200); + + await esArchiver.load('../../functional/es_archives/empty_kibana'); + }); + + after(async () => { + await esArchiver.unload('../../functional/es_archives/empty_kibana'); + }); + + afterEach(async () => { + await browser.get(PageObjects.common.getHostPort() + '/logout'); + await PageObjects.common.waitUntilUrlIncludes('logged_out'); + }); + + it('can login preserving original URL', async () => { + await browser.get( + PageObjects.common.getHostPort() + '/app/management/security/users#some=hash-value' + ); + + await find.byCssSelector( + '[data-test-subj="kibanaChrome"] .app-wrapper:not(.hidden-chrome)', + 20000 + ); + + // We need to make sure that both path and hash are respected. + const currentURL = parse(await browser.getCurrentUrl()); + expect(currentURL.pathname).to.eql('/app/management/security/users'); + expect(currentURL.hash).to.eql('#some=hash-value'); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts index 0037c39b8fed2..1843fca29758a 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts @@ -27,8 +27,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('finds page title', async () => { - const title = await testSubjects.getVisibleText('pageViewHeaderLeftTitle'); - expect(title).to.equal('Endpoints'); + const title = await testSubjects.getVisibleText('header-page-title'); + expect(title).to.equal('Endpoints BETA'); }); it('displays table data', async () => { diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index 1119906ba5cfa..9efba2b595bde 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -47,7 +47,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); it('should display policy view', async () => { - expect(await testSubjects.getVisibleText('pageViewHeaderLeftTitle')).to.equal( + expect(await testSubjects.getVisibleText('header-page-title')).to.equal( policyInfo.packageConfig.name ); }); diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts index 0c5e15ed4104c..cf93ca1b68991 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts @@ -29,8 +29,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await testSubjects.existOrFail('policyListPage'); }); it('displays page title', async () => { - const policyTitle = await testSubjects.getVisibleText('pageViewHeaderLeftTitle'); - expect(policyTitle).to.equal('Policies'); + const policyTitle = await testSubjects.getVisibleText('header-page-title'); + expect(policyTitle).to.equal('Policies BETA'); }); it('shows header create policy button', async () => { const createButtonTitle = await testSubjects.getVisibleText('headerCreateNewPolicyButton'); diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/trusted_apps_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/trusted_apps_list.ts new file mode 100644 index 0000000000000..5f749ac272474 --- /dev/null +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/trusted_apps_list.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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const pageObjects = getPageObjects(['trustedApps']); + const testSubjects = getService('testSubjects'); + + describe('endpoint list', function () { + this.tags('ciGroup7'); + + describe('when there is data', () => { + before(async () => { + await pageObjects.trustedApps.navigateToTrustedAppsList(); + }); + + it('finds page title', async () => { + expect(await testSubjects.getVisibleText('header-page-title')).to.equal( + 'Trusted applications BETA' + ); + }); + }); + }); +}; diff --git a/x-pack/test/security_solution_endpoint/page_objects/index.ts b/x-pack/test/security_solution_endpoint/page_objects/index.ts index 68e1ad00619c7..05762ba887c6f 100644 --- a/x-pack/test/security_solution_endpoint/page_objects/index.ts +++ b/x-pack/test/security_solution_endpoint/page_objects/index.ts @@ -7,6 +7,7 @@ import { pageObjects as xpackFunctionalPageObjects } from '../../functional/page_objects'; import { EndpointPageProvider } from './endpoint_page'; import { EndpointPolicyPageProvider } from './policy_page'; +import { TrustedAppsPageProvider } from './trusted_apps_page'; import { EndpointPageUtils } from './page_utils'; import { IngestManagerCreatePackageConfig } from './ingest_manager_create_package_config_page'; @@ -14,6 +15,7 @@ export const pageObjects = { ...xpackFunctionalPageObjects, endpoint: EndpointPageProvider, policy: EndpointPolicyPageProvider, + trustedApps: TrustedAppsPageProvider, endpointPageUtils: EndpointPageUtils, ingestManagerCreatePackageConfig: IngestManagerCreatePackageConfig, }; diff --git a/x-pack/test/security_solution_endpoint/page_objects/trusted_apps_page.ts b/x-pack/test/security_solution_endpoint/page_objects/trusted_apps_page.ts new file mode 100644 index 0000000000000..c02ac0ca9ffe0 --- /dev/null +++ b/x-pack/test/security_solution_endpoint/page_objects/trusted_apps_page.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { FtrProviderContext } from '../ftr_provider_context'; + +export function TrustedAppsPageProvider({ getPageObjects }: FtrProviderContext) { + const pageObjects = getPageObjects(['common', 'header', 'endpointPageUtils']); + + return { + async navigateToTrustedAppsList(searchParams?: string) { + await pageObjects.common.navigateToUrlWithBrowserHistory( + 'securitySolutionManagement', + `/trusted_apps${searchParams ? `?${searchParams}` : ''}` + ); + await pageObjects.header.waitUntilLoadingHasFinished(); + }, + }; +} diff --git a/x-pack/test/spaces_api_integration/common/services/legacy_es.js b/x-pack/test/spaces_api_integration/common/services/legacy_es.js index 9267fa312ed06..c8bf1810daafe 100644 --- a/x-pack/test/spaces_api_integration/common/services/legacy_es.js +++ b/x-pack/test/spaces_api_integration/common/services/legacy_es.js @@ -8,7 +8,7 @@ import { format as formatUrl } from 'url'; import * as legacyElasticsearch from 'elasticsearch'; -import { elasticsearchClientPlugin } from '../../../../plugins/security/server/elasticsearch_client_plugin'; +import { elasticsearchClientPlugin } from '../../../../plugins/security/server/elasticsearch/elasticsearch_client_plugin'; export function LegacyEsProvider({ getService }) { const config = getService('config'); diff --git a/x-pack/test/token_api_integration/auth/login.js b/x-pack/test/token_api_integration/auth/login.js index 7b68298a52168..b2dd870e018da 100644 --- a/x-pack/test/token_api_integration/auth/login.js +++ b/x-pack/test/token_api_integration/auth/login.js @@ -17,20 +17,30 @@ export default function ({ getService }) { } describe('login', () => { - it('accepts valid login credentials as 204 status', async () => { + it('accepts valid login credentials as 200 status', async () => { await supertest .post('/internal/security/login') .set('kbn-xsrf', 'true') - .send({ username: 'elastic', password: 'changeme' }) - .expect(204); + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { username: 'elastic', password: 'changeme' }, + }) + .expect(200); }); it('sets HttpOnly cookie with valid login', async () => { const response = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'true') - .send({ username: 'elastic', password: 'changeme' }) - .expect(204); + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { username: 'elastic', password: 'changeme' }, + }) + .expect(200); const cookie = extractSessionCookie(response); if (!cookie) { @@ -45,7 +55,12 @@ export default function ({ getService }) { it('rejects without kbn-xsrf header as 400 status even if credentials are valid', async () => { const response = await supertest .post('/internal/security/login') - .send({ username: 'elastic', password: 'changeme' }) + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { username: 'elastic', password: 'changeme' }, + }) .expect(400); if (extractSessionCookie(response)) { @@ -68,7 +83,12 @@ export default function ({ getService }) { const response = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'true') - .send({ username: 'elastic' }) + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { username: 'elastic' }, + }) .expect(400); if (extractSessionCookie(response)) { @@ -80,7 +100,12 @@ export default function ({ getService }) { const response = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'true') - .send({ password: 'changme' }) + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { password: 'changeme' }, + }) .expect(400); if (extractSessionCookie(response)) { @@ -92,7 +117,12 @@ export default function ({ getService }) { const response = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'true') - .send({ username: 'elastic', password: 'notvalidpassword' }) + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { username: 'elastic', password: 'notvalidpassword' }, + }) .expect(401); if (extractSessionCookie(response)) { diff --git a/x-pack/test/token_api_integration/auth/logout.js b/x-pack/test/token_api_integration/auth/logout.js index cc63c54a94345..fcc0e8182158f 100644 --- a/x-pack/test/token_api_integration/auth/logout.js +++ b/x-pack/test/token_api_integration/auth/logout.js @@ -20,7 +20,12 @@ export default function ({ getService }) { const response = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'true') - .send({ username: 'elastic', password: 'changeme' }); + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { username: 'elastic', password: 'changeme' }, + }); const cookie = extractSessionCookie(response); if (!cookie) { @@ -68,7 +73,7 @@ export default function ({ getService }) { .get('/internal/security/me') .set('kbn-xsrf', 'true') .set('cookie', cookie.cookieString()) - .expect(400); + .expect(401); }); }); } diff --git a/x-pack/test/token_api_integration/auth/session.js b/x-pack/test/token_api_integration/auth/session.js index 3967a44e593f9..1f69b06315a80 100644 --- a/x-pack/test/token_api_integration/auth/session.js +++ b/x-pack/test/token_api_integration/auth/session.js @@ -23,7 +23,12 @@ export default function ({ getService }) { const response = await supertest .post('/internal/security/login') .set('kbn-xsrf', 'true') - .send({ username: 'elastic', password: 'changeme' }); + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { username: 'elastic', password: 'changeme' }, + }); const cookie = extractSessionCookie(response); if (!cookie) { diff --git a/yarn.lock b/yarn.lock index 81bb7338e615f..6dd6a39c1142e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4102,15 +4102,10 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-3.10.3.tgz#aaddec6a3c93bf03b402db3acf5d4c77bce8bdff" integrity sha512-b9zScBKmB/RJqETbxu3YRya61vJOik89/lR+NdxjZAFMDcMSjwX6IhQoP4terJkhsa9TE1C+l6XwxCkhhsaZXg== -"@types/lodash@^4.14.116": - version "4.14.150" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.150.tgz#649fe44684c3f1fcb6164d943c5a61977e8cf0bd" - integrity sha512-kMNLM5JBcasgYscD9x/Gvr6lTAv2NVgsKtet/hm93qMyf/D1pt+7jeEZklKJKxMVmXjxbRVQQGfqDSfipYCO6w== - -"@types/lodash@^4.14.155": - version "4.14.156" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.156.tgz#cbe30909c89a1feeb7c60803e785344ea0ec82d1" - integrity sha512-l2AgHXcKUwx2DsvP19wtRPqZ4NkONjmorOdq4sMcxIjqdIuuV/ULo2ftuv4NUpevwfW7Ju/UKLqo0ZXuEt/8lQ== +"@types/lodash@^4.14.116", "@types/lodash@^4.14.159": + version "4.14.159" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.159.tgz#61089719dc6fdd9c5cb46efc827f2571d1517065" + integrity sha512-gF7A72f7WQN33DpqOWw9geApQPh4M3PxluMtaHxWHXEGSN12/WbcEk/eNSqWNQcQhF66VSZ06vCF94CrHwXJDg== "@types/log-symbols@^2.0.0": version "2.0.0" @@ -19329,15 +19324,10 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@4.17.11, lodash@4.17.19, lodash@>4.17.4, lodash@^4, lodash@^4.0.0, lodash@^4.0.1, lodash@^4.10.0, lodash@^4.11.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.16, lodash@^4.17.19, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@~4.17.10, lodash@~4.17.15, lodash@~4.17.5: - version "4.17.19" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" - integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== - -lodash@4.17.15: - version "4.17.15" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" - integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== +lodash@4.17.11, lodash@4.17.15, lodash@4.17.19, lodash@>4.17.4, lodash@^4, lodash@^4.0.0, lodash@^4.0.1, lodash@^4.10.0, lodash@^4.11.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.16, lodash@^4.17.19, lodash@^4.17.2, lodash@^4.17.20, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@~4.17.10, lodash@~4.17.15, lodash@~4.17.5: + version "4.17.20" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" + integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== lodash@^3.10.1: version "3.10.1"