): IHttpConfig => ({
port: 5601,
socketTimeout: 120000,
keepaliveTimeout: 120000,
+ shutdownTimeout: moment.duration(30, 'seconds'),
maxPayload: ByteSizeValue.parse('1048576b'),
...parts,
cors: {
diff --git a/packages/kbn-server-http-tools/src/types.ts b/packages/kbn-server-http-tools/src/types.ts
index 3cc117d542eee..9aec520fb3a31 100644
--- a/packages/kbn-server-http-tools/src/types.ts
+++ b/packages/kbn-server-http-tools/src/types.ts
@@ -7,6 +7,7 @@
*/
import { ByteSizeValue } from '@kbn/config-schema';
+import type { Duration } from 'moment';
export interface IHttpConfig {
host: string;
@@ -16,6 +17,7 @@ export interface IHttpConfig {
socketTimeout: number;
cors: ICorsConfig;
ssl: ISslConfig;
+ shutdownTimeout: Duration;
}
export interface ICorsConfig {
diff --git a/packages/kbn-test/package.json b/packages/kbn-test/package.json
index a2dc8f84cfb51..2afbe41e0e00e 100644
--- a/packages/kbn-test/package.json
+++ b/packages/kbn-test/package.json
@@ -19,7 +19,6 @@
"@kbn/optimizer": "link:../kbn-optimizer"
},
"devDependencies": {
- "@kbn/babel-preset": "link:../kbn-babel-preset",
"@kbn/dev-utils": "link:../kbn-dev-utils",
"@kbn/expect": "link:../kbn-expect",
"@kbn/utils": "link:../kbn-utils"
diff --git a/packages/kbn-ui-shared-deps/entry.js b/packages/kbn-ui-shared-deps/entry.js
index f14c793d22a09..4029ce28faf5b 100644
--- a/packages/kbn-ui-shared-deps/entry.js
+++ b/packages/kbn-ui-shared-deps/entry.js
@@ -49,3 +49,4 @@ export const TsLib = require('tslib');
export const KbnAnalytics = require('@kbn/analytics');
export const KbnStd = require('@kbn/std');
export const SaferLodashSet = require('@elastic/safer-lodash-set');
+export const RisonNode = require('rison-node');
diff --git a/packages/kbn-ui-shared-deps/index.js b/packages/kbn-ui-shared-deps/index.js
index 0542bc89ff9e4..62ddb09d25add 100644
--- a/packages/kbn-ui-shared-deps/index.js
+++ b/packages/kbn-ui-shared-deps/index.js
@@ -60,5 +60,6 @@ exports.externals = {
'@kbn/analytics': '__kbnSharedDeps__.KbnAnalytics',
'@kbn/std': '__kbnSharedDeps__.KbnStd',
'@elastic/safer-lodash-set': '__kbnSharedDeps__.SaferLodashSet',
+ 'rison-node': '__kbnSharedDeps__.RisonNode',
};
exports.publicPathLoader = require.resolve('./public_path_loader');
diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json
index 47a2fa19e7a8e..00c6f677cd223 100644
--- a/packages/kbn-ui-shared-deps/package.json
+++ b/packages/kbn-ui-shared-deps/package.json
@@ -14,7 +14,6 @@
"@kbn/monaco": "link:../kbn-monaco"
},
"devDependencies": {
- "@kbn/babel-preset": "link:../kbn-babel-preset",
"@kbn/dev-utils": "link:../kbn-dev-utils"
}
}
\ No newline at end of file
diff --git a/rfcs/images/url_service/new_architecture.png b/rfcs/images/url_service/new_architecture.png
new file mode 100644
index 0000000000000..9faa025d429bf
Binary files /dev/null and b/rfcs/images/url_service/new_architecture.png differ
diff --git a/rfcs/images/url_service/old_architecture.png b/rfcs/images/url_service/old_architecture.png
new file mode 100644
index 0000000000000..fdb1c13fabf34
Binary files /dev/null and b/rfcs/images/url_service/old_architecture.png differ
diff --git a/rfcs/text/0017_url_service.md b/rfcs/text/0017_url_service.md
new file mode 100644
index 0000000000000..87a8a92c090d6
--- /dev/null
+++ b/rfcs/text/0017_url_service.md
@@ -0,0 +1,600 @@
+- Start Date: 2021-03-26
+- RFC PR: (leave this empty)
+- Kibana Issue: (leave this empty)
+
+
+# Summary
+
+Currently in the Kibana `share` plugin we have two services that deal with URLs.
+
+One is *Short URL Service*: given a long internal Kibana URL it returns an ID.
+That ID can be used to "resolve" back to the long URL and redirect the user to
+that long URL page. (The Short URL Service is now used in Dashboard, Discover,
+Visualize apps, and have a few upcoming users, for example, when sharing panels
+by Slack or e-mail we will want to use short URLs.)
+
+```ts
+// It does not have a plugin API, you can only use it through an HTTP request.
+const shortUrl = await http.post('/api/shorten_url', {
+ url: '/some/long/kibana/url/.../very?long=true#q=(rison:approved)'
+});
+```
+
+The other is the *URL Generator Service*: it simply receives an object of
+parameters and returns back a deep link within Kibana. (You can use it, for
+example, to navigate to some specific query with specific filters for a
+specific index pattern in the Discover app. As of this writing, there are
+eight registered URL generators, which are used by ten plugins.)
+
+```ts
+// You first register a URL generator.
+const myGenerator = plugins.share.registerUrlGenerator(/* ... */);
+
+// You can fetch it from the registry (if you don't already have it).
+const myGenerator = plugins.share.getUrlGenerator(/* ... */);
+
+// Now you can use it to generate a deep link into Kibana.
+const deepLink: string = myGenerator.createUrl({ /* ... */ });
+```
+
+
+## Goals of the project
+
+The proposal is to unify both of these services (Short URL Service and URL
+Generator Service) into a single new *URL Service*. The new unified service
+will still provide all the functionality the above mentioned services provide
+and in addition will implement the following improvements:
+
+1. Standardize a way for apps to deep link and navigate into other Kibana apps,
+ with ability to use *location state* to specify the state of the app which is
+ not part of the URL.
+2. Combine Short URL Service with URL Generator Service to allow short URLs to
+ be constructed from URL generators, which will also allow us to automatically
+ migrate the short URLs if the parameters of the underlying URL generator
+ change and be able to store location state in every short URL.
+3. Make the short url service easier to use. (It was previously undocumented,
+ and no server side plugin APIs existed, which meant consumers had to use
+ REST APIs which is discouraged. Merging the two services will help achieve
+ this goal by simplifying the APIs.)
+4. Support short urls being deleted (previously not possible).
+5. Support short urls being migrated (previously not possible).
+
+See more detailed explanation and other small improvements in the "Motivation"
+section below.
+
+
+# Terminology
+
+In the proposed new service we introduce "locators". This is mostly a change
+in language, we are renaming "URL generators" to "locators". The old name would
+no longer make sense as we are not returning URLs from locators.
+
+
+# Basic example
+
+The URL Service will have a client (`UrlServiceClient`) which will have the same
+interface, both, on the server-side and the client-side. It will also have a
+documented public set of HTTP API endpoints for use by: (1) the client-side
+client; (2) external users, Elastic Cloud, and Support.
+
+The following code examples will work, both, on the server-side and the
+client-side, as the base `UrlServiceClient` interface will be similar in both
+environments.
+
+Below we consider four main examples of usage of the URL Service. All four
+examples are existing use cases we currently have in Kibana.
+
+
+## Navigating within Kibana using locators
+
+In this example let's consider a case where Discover app creates a locator,
+then another plugin uses that locator to navigate to a deep link within the
+Discover app.
+
+First, the Discover plugin creates its locator (usually one per app). It needs
+to do this on the client and server.
+
+
+```ts
+const locator = plugins.share.locators.create({
+ id: 'DISCOVER_DEEP_LINKS',
+ getLocation: ({
+ indexPattern,
+ highlightedField,
+ filters: [],
+ query: {},
+ fields: [],
+ activeDoc: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx',
+ }) => {
+ app: 'discover',
+ route: `/${indexPatten}#_a=(${risonEncode({filters, query, fields})})`,
+ state: {
+ highlightedField,
+ activeDoc,
+ },
+ },
+});
+```
+
+Now, the Discover plugin exports this locator from its plugin contract.
+
+```ts
+class DiscoverPlugin() {
+ start() {
+ return {
+ locator,
+ };
+ }
+}
+```
+
+Finally, if any other app now wants to navigate to a deep link within the
+Discover application, they use this exported locator.
+
+```ts
+plugins.discover.locator.navigate({
+ indexPattern: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
+ highlightedField: 'foo',
+});
+```
+
+Note, in this example the `highlightedField` parameter will not appear in the
+URL bar, it will be passed to the Discover app through [`history.pushState()`](https://developer.mozilla.org/en-US/docs/Web/API/History/pushState)
+mechanism (in Kibana case, using the [`history`](https://www.npmjs.com/package/history) package, which is used by `core.application.navigateToApp`).
+
+
+## Sending a deep link to Kibana
+
+We have use cases were a deep link to some Kibana app is sent out, for example,
+through e-mail or as a Slack message.
+
+In this example, lets consider some plugin gets hold of the Discover locator
+on the server-side.
+
+```ts
+const location = plugins.discover.locator.getRedirectPath({
+ indexPattern: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
+ highlightedField: 'foo',
+});
+```
+
+This would return the location of the client-side redirect endpoint. The redirect
+endpoint could look like this:
+
+```
+/app/goto/_redirect/DISCOVER_DEEP_LINKS?params={"indexPattern":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","highlightedField":"foo"}¶msVersion=7.x
+```
+
+This redirect client-side endpoint would find the Discover locator and and
+execute the `.navigate()` method on it.
+
+
+## Creating a short link
+
+In this example, lets create a short link using the Discover locator.
+
+```ts
+const shortUrl = await plugins.discover.locator.createShortUrl(
+ {
+ indexPattern: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
+ highlightedField: 'foo',
+ }
+ 'human-readable-slug',
+});
+```
+
+The above example creates a short link and persists it in a saved object. The
+short URL can have a human-readable slug, which uniquely identifies that short
+URL.
+
+```ts
+shortUrl.slug === 'human-readable-slug'
+```
+
+The short URL can be used to navigate to the Discover app. The redirect
+client-side endpoint currently looks like this:
+
+```
+/app/goto/human-readable-slug
+```
+
+This persisted short URL would effectively work the same as the full version:
+
+```
+/app/goto/_redirect/DISCOVER_DEEP_LINKS?params={"indexPattern":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","highlightedField":"foo"}¶msVersion=7.x
+```
+
+
+## External users navigating to a Kibana deep link
+
+Currently Elastic Cloud and Support have many links linking into Kibana. Most of
+them are deep links into Discover and Dashboard apps where, for example, index
+pattern is selected, or filters and time range are set.
+
+The external users could use the above mentioned client-side redirect endpoint
+to navigate to their desired deep location within Kibana, for example, to the
+Discover application:
+
+```
+/app/goto/_redirect/DISCOVER_DEEP_LINKS?params={"indexPattern":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx","highlightedField":"foo"}¶msVersion=7.x
+```
+
+
+# Motivation
+
+Our motivation to improve the URL services comes from us intending to use them
+more, for example, for panel sharing to Slack or e-mail; and we believe that the
+current state of the URL services needs an upgrade.
+
+
+## Limitations of the Short URL Service
+
+We have identified the following limitations in the current implementation of
+the Short URL Service:
+
+1. There is no migration system. If an application exposes this functionality,
+ every possible URL that might be generated should be supported forever. A
+ migration could be written inside the app itself, on page load, but this is a
+ risky path for URLs with many possibilities.
+ 1. __Will do:__ Short URLs will be created using locators. We will use
+ migrations provided by the locators to migrate the stored parameters
+ in the short URL saved object.
+1. Short URLs store only the URL of the destination page. However, the
+ destination page might have other state which affects the display of the page
+ but is not present in the URL. Once the short URL is used to navigate to that
+ page, any state that is kept only in memory is lost.
+ 1. __Will do:__ The new implementation of the short URLs will also persist
+ the location state of the URL. That state would be provided to a
+ Kibana app once a user navigates to that app using a short URL.
+1. It exposes only HTTP endpoint API.
+ 1. __Will do:__ We will also expose a URL Service client through plugin
+ contract on the server and browser.
+1. It only has 3 HTTP endpoints, yet all three have different paths:
+ (1) `/short_url`, (2) `/shorten_url`; and (3) `/goto`.
+ 1. __Will do:__ We will normalize the HTTP endpoints. We will use HTTP
+ method "verbs" like POST, instead of verbs in the url like "shorten_url".
+1. There is not much documentation for developers.
+ 1. __Will do:__ The new service will have a much nicer API and docs.
+1. There is no way to delete short URLs once they are created.
+ 1. __Will do:__ The new service will provide CRUD API to manage short URLs,
+ including deletion.
+1. Short URL service uses MD5 algorithm to hash long URLs. Security team
+ requested to stop using that algorithm.
+ 1. __Will do:__ The new URL Service will not use MD5 algorithm.
+1. Short URLs are not automatically deleted when the target (say dashboard) is
+ deleted. (#10450)
+ 1. __Could do:__ The URL Service will not provide such feature. Though the
+ short URLs will keep track of saved object references used in the params
+ to generate a short URL. Maybe those saved references could somehow be
+ used in the future to provide such a facility.
+
+ Currently, there are two possible avenues for deleting a short URL when
+ the underlying dashboard is deleted:
+
+ 1. The Dashboard app could keep track of short URLs it generates for each
+ dashboard. Once a dashboard is deleted, the Dashboard app also
+ deletes all short URLs associated with that dashboard.
+ 1. Saved Objects Service could implement *cascading deletes*. Once a saved
+ object is deleted, the associated saved objects are also deleted
+ (#71453).
+1. Add additional metadata to each short URL.
+ 1. __Could do:__ Each short URL already keeps a counter of how often it was
+ resolved, we could also keep track of a timestamp when it was last
+ resolved, and have an ability for users to give a title to each short URL.
+1. Short URLs don't have a management UI.
+ 1. __Will NOT do:__ We will not create a dedicated UI for managing short
+ URLs. We could improve how short URLs saved objects are presented in saved
+ object management UI.
+1. Short URLs can't be created by read-only users (#18006).
+ 1. __Will NOT do:__ Currently short URLs are stored as saved objects of type
+ `url`, we would like to keep it that way and benefit from saved object
+ facilities like references, migrations, authorization etc.. The consensus
+ is that we will not allow anonymous users to create short URLs. We want to
+ continue using saved object for short URLs going forward and not
+ compromise on their security model.
+
+
+## Limitations of the URL Generator Service
+
+We have identified the following limitations in the current implementation of
+the URL Generator Service:
+
+1. URL generator generate only the URL of the destination. However there is
+ also the ability to use location state with `core.application.navigateToApp`
+ navigation method.
+ 1. __Will do:__ The new locators will also generate the location state, which
+ will be used in `.navigateToApp` method.
+1. URL generators are available only on the client-side. There is no way to use
+ them together with short URLs.
+ 1. __Will do:__ We will implement locators also on the server-side
+ (they will be available in both environments) and we will combine them
+ with the Short URL Service.
+1. URL generators are not exposed externally, thus Cloud and Support cannot use
+ them to generate deep links into Kibana.
+ 1. __Will do:__ We will expose HTTP endpoints on the server-side and the
+ "redirect" app on the client-side which external users will be able to use
+ to deep link into Kibana using locators.
+
+
+## Limitations of the architecture
+
+One major reason we want to "refresh" the Short URL Service and the URL
+Generator Service is their architecture.
+
+Currently, the Short URL Service is implemented on top of the `url` type saved
+object on the server-side. However, it only exposes the
+HTTP endpoints, it does not expose any API on the server for the server-side
+plugins to consume; on the client-side there is no plugin API either, developers
+need to manually execute HTTP requests.
+
+The URL Generator Service is only available on the client-side, there is no way
+to use it on the server-side, yet we already have use cases (for example ML
+team) where a server-side plugin wants to use a URL generator.
+
+![Current Short URL Service and URL Generator Service architecture](../images/url_service/old_architecture.png)
+
+The current architecture does not allow both services to be conveniently used,
+also as they are implemented in different locations, they are disjointed—
+we cannot create a short URL using an URL generator.
+
+
+# Detailed design
+
+In general we will try to provide as much as possible the same API on the
+server-side and the client-side.
+
+
+## High level architecture
+
+Below diagram shows the proposed architecture of the URL Service.
+
+![URL Service architecture](../images/url_service/new_architecture.png)
+
+
+## Plugin contracts
+
+The aim is to provide developers the same experience on the server and browser.
+
+Below are preliminary interfaces of the new URL Service. `IUrlService` will be
+a shared interface defined in `/common` folder shared across server and browser.
+This will allow us to provide users a common API interface on the server and
+browser, wherever they choose to use the URL Service:
+
+```ts
+/**
+ * Common URL Service client interface for the server-side and the client-side.
+ */
+interface IUrlService {
+ locators: ILocatorClient;
+ shortUrls: IShortUrlClient;
+}
+```
+
+
+### Locators
+
+The locator business logic will be contained in `ILocatorClient` client and will
+provide two main functionalities:
+
+1. It will provide a facility to create locators.
+1. It will also be a registry of locators, every newly created locator is
+ automatically added to the registry. The registry should never be used when
+ locator ID is known at the compile time, but is reserved only for use cases
+ when we only know ID of a locator at runtime.
+
+```ts
+interface ILocatorClient {
+ create(definition: LocatorDefinition
): Locator
;
+ get
(id: string): Locator
;
+}
+```
+
+The `LocatorDefinition` interface is a developer-friendly interface for creating
+new locators. Mainly two things will be required from each new locator:
+
+1. Implement the `getLocation()` method, which gives the locator specific `params`
+ object returns a Kibana location, see description of `KibanaLocation` below.
+2. Implement the `PersistableState` interface which we use in Kibana. This will
+ allow to migrate the locator `params`. Implementation of the `PersistableState`
+ interface will replace the `.isDeprecated` and `.migrate()` properties of URL
+ generators.
+
+
+```ts
+interface LocatorDefinition
extends PeristableState
{
+ id: string;
+ getLocation(params: P): KibanaLocation;
+}
+```
+
+Each constructed locator will have the following interface:
+
+```ts
+interface Locator
{
+ /** Creates a new short URL saved object using this locator. */
+ createShortUrl(params: P, slug?: string): Promise;
+ /** Returns a relative URL to the client-side redirect endpoint using this locator. */
+ getRedirectPath(params: P): string;
+ /** Navigate using core.application.navigateToApp() using this locator. */
+ navigate(params: P): void; // Only on browser.
+}
+```
+
+
+### Short URLs
+
+The short URL client `IShortUrlClient` which will be the same on the server and
+browser. However, the server and browser might add extra utility methods for
+convenience.
+
+```ts
+/**
+ * CRUD-like API for short URLs.
+ */
+interface IShortUrlClient {
+ /**
+ * Delete a short URL.
+ *
+ * @param slug The slug (ID) of the short URL.
+ * @return Returns true if deletion was successful.
+ */
+ delete(slug: string): Promise;
+
+ /**
+ * Fetch short URL.
+ *
+ * @param slug The slug (ID) of the short URL.
+ */
+ get(slug: string): Promise;
+
+ /**
+ * Same as `get()` but it also increments the "view" counter and the
+ * "last view" timestamp of this short URL.
+ *
+ * @param slug The slug (ID) of the short URL.
+ */
+ resolve(slug: string): Promise;
+}
+```
+
+Note, that in this new service to create a short URL the developer will have to
+use a locator (instead of creating it directly from a long URL).
+
+```ts
+const shortUrl = await plugins.share.shortUrls.create(
+ plugins.discover.locator,
+ {
+ indexPattern: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
+ highlightedField: 'foo',
+ },
+ 'optional-human-readable-slug',
+);
+```
+
+These short URLs will be stored in saved objects of type `url` and will be
+automatically migrated using the locator. The long URL will NOT be stored in the
+saved object. The locator ID and locator params will be stored in the saved
+object, that will allow us to do the migrations for short URLs.
+
+
+### `KibanaLocation` interface
+
+The `KibanaLocation` interface is a simple interface to store a location in some
+Kibana application.
+
+```ts
+interface KibanaLocation {
+ app: string;
+ route: string;
+ state: object;
+}
+```
+
+It maps directly to a `.navigateToApp()` call.
+
+```ts
+let location: KibanaLocation;
+
+core.application.navigateToApp(location.app, {
+ route: location.route,
+ state: location.state,
+});
+```
+
+
+## HTTP endpoints
+
+
+### Short URL CRUD+ HTTP endpoints
+
+Below HTTP endpoints are designed to work specifically with short URLs:
+
+| HTTP method | Path | Description |
+|-----------------------|-------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
+| __POST__ | `/api/short_url` | Endpoint for creating new short URLs. |
+| __GET__ | `/api/short_url/` | Endpoint for retrieving information about an existing short URL. |
+| __DELETE__ | `/api/short_url/` | Endpoint for deleting an existing short URL. |
+| __POST__ | `/api/short_url/` | Endpoint for updating information about an existing short URL. |
+| __POST__ | `/api/short_url//_resolve` | Similar to `GET /api/short_url/`, but also increments the short URL access count counter and the last access timestamp. |
+
+
+### The client-side navigate endpoint
+
+__NOTE.__ We are currently investigating if we really need this endpoint. The
+main user of it was expected to be Cloud and Support to deeply link into Kibana,
+but we are now reconsidering if we want to support this endpoint and possibly
+find a different solution.
+
+The `/app/goto/_redirect/?params=...¶msVersion=...` client-side
+endpoint will receive the locator ID and locator params, it will use those to
+find the locator and execute `locator.navigate(params)` method.
+
+The `paramsVersion` parameter will be used to specify the version of the
+`params` parameter. If the version is behind the latest version, then the migration
+facilities of the locator will be used to on-the-fly migrate the `params` to the
+latest version.
+
+
+### Legacy endpoints
+
+Below are the legacy HTTP endpoints implemented by the `share` plugin, with a
+plan of action for each endpoint:
+
+| HTTP method | Path | Description |
+|-----------------------|-------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
+| __ANY__ | `/goto/` | Endpoint for redirecting short URLs, we will keep it to redirect short URLs. |
+| __GET__ | `/api/short_url/` | The new `GET /api/short_url/` endpoint will return a superset of the payload that the legacy endpoint now returns. |
+| __POST__ | `/api/shorten_url` | The legacy endpoints for creating short URLs. We will remove it or deprecate this endpoint and maintain it until 8.0 major release. |
+
+
+# Drawbacks
+
+Why should we *not* do this?
+
+- Implementation cost will be a few weeks, but the code complexity and quality
+ will improve.
+- There is a cost of migrating existing Kibana plugins to use the new API.
+
+
+# Alternatives
+
+We haven't considered other design alternatives.
+
+One alternative is still do the short URL improvements outlined above. But
+reconsider URL generators:
+
+- Do we need URL generators at all?
+ - Kibana URLs are not stable and have changed in our past experience. Hence,
+ the URL generators were created to make the URL generator parameters stable
+ unless a migration is available.
+- Do we want to put migration support in URL generators?
+ - Alternative would be for each app to support URLs forever or do the
+ migrations on the fly for old URLs.
+- Should Kibana URLs be stable and break only during major releases?
+- Should the Kibana application interface be extended such that some version of
+ URL generators is built in?
+
+The impact of not doing this change is essentially extending technical debt.
+
+
+# Adoption strategy
+
+Is this a breaking change? It is a breaking change in the sense that the API
+will change. However, all the existing use cases will be supported. When
+implementing this we will also adjust all Kibana code to use the new API. From
+the perspective of the developers when using the existing URL services nothing
+will change, they will simply need to review a PR which stops using the URL
+Generator Service and uses the combined URL Service instead, which will provide
+a superset of features.
+
+Alternatively, we can deprecate the URL Generator Service and maintain it for a
+few minor releases.
+
+
+# How we teach this
+
+For the existing short URL and URL generator functionality there is nothing to
+teach, as they will continue working with a largely similar API.
+
+Everything else in the new URL Service will have JSDoc comments and good
+documentation on our website.
diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts
index 4220d3e490f63..0ecfc152197d3 100644
--- a/src/core/public/doc_links/doc_links_service.ts
+++ b/src/core/public/doc_links/doc_links_service.ts
@@ -21,12 +21,16 @@ export class DocLinksService {
const DOC_LINK_VERSION = injectedMetadata.getKibanaBranch();
const ELASTIC_WEBSITE_URL = 'https://www.elastic.co/';
const ELASTICSEARCH_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`;
+ const KIBANA_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/`;
const PLUGIN_DOCS = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/plugins/${DOC_LINK_VERSION}/`;
return deepFreeze({
DOC_LINK_VERSION,
ELASTIC_WEBSITE_URL,
links: {
+ canvas: {
+ guide: `${KIBANA_DOCS}canvas.html`,
+ },
dashboard: {
guide: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/dashboard.html`,
drilldowns: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/drilldowns.html`,
@@ -245,10 +249,10 @@ export class DocLinksService {
guide: `${ELASTIC_WEBSITE_URL}guide/en/observability/${DOC_LINK_VERSION}/index.html`,
},
alerting: {
- guide: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/managing-alerts-and-actions.html`,
+ guide: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alert-management.html`,
actionTypes: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/action-types.html`,
emailAction: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/email-action-type.html`,
- emailActionConfig: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/email-action-type.html#configuring-email`,
+ emailActionConfig: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/email-action-type.html`,
generalSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alert-action-settings-kb.html#general-alert-action-settings`,
indexAction: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index-action-type.html`,
esQuery: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/rule-type-es-query.html`,
@@ -397,6 +401,9 @@ export interface DocLinksStart {
readonly DOC_LINK_VERSION: string;
readonly ELASTIC_WEBSITE_URL: string;
readonly links: {
+ readonly canvas: {
+ readonly guide: string;
+ };
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md
index 18133ebec3353..b3ded52a98171 100644
--- a/src/core/public/public.api.md
+++ b/src/core/public/public.api.md
@@ -490,6 +490,9 @@ export interface DocLinksStart {
readonly ELASTIC_WEBSITE_URL: string;
// (undocumented)
readonly links: {
+ readonly canvas: {
+ readonly guide: string;
+ };
readonly dashboard: {
readonly guide: string;
readonly drilldowns: string;
diff --git a/src/core/server/core_app/core_app.ts b/src/core/server/core_app/core_app.ts
index bc1098832bac5..e728cb0b82475 100644
--- a/src/core/server/core_app/core_app.ts
+++ b/src/core/server/core_app/core_app.ts
@@ -65,7 +65,7 @@ export class CoreApp {
async (context, req, res) => {
const { query, params } = req;
const { path } = params;
- if (!path || !path.endsWith('/')) {
+ if (!path || !path.endsWith('/') || path.startsWith('/')) {
return res.notFound();
}
diff --git a/src/core/server/core_app/integration_tests/core_app_routes.test.ts b/src/core/server/core_app/integration_tests/core_app_routes.test.ts
index 6b0643f7d1bc7..faa1c905afa9d 100644
--- a/src/core/server/core_app/integration_tests/core_app_routes.test.ts
+++ b/src/core/server/core_app/integration_tests/core_app_routes.test.ts
@@ -39,6 +39,10 @@ describe('Core app routes', () => {
expect(response.get('location')).toEqual('/base-path/some-path?foo=bar');
});
+ it('does not redirect if the path starts with `//`', async () => {
+ await kbnTestServer.request.get(root, '//some-path/').expect(404);
+ });
+
it('does not redirect if the path does not end with `/`', async () => {
await kbnTestServer.request.get(root, '/some-path').expect(404);
});
diff --git a/src/core/server/core_usage_data/core_usage_data_service.mock.ts b/src/core/server/core_usage_data/core_usage_data_service.mock.ts
index 8ed627cebec7e..e09f595747c30 100644
--- a/src/core/server/core_usage_data/core_usage_data_service.mock.ts
+++ b/src/core/server/core_usage_data/core_usage_data_service.mock.ts
@@ -95,6 +95,13 @@ const createStartContractMock = () => {
supportedProtocols: ['TLSv1.1', 'TLSv1.2'],
truststoreConfigured: false,
},
+ securityResponseHeaders: {
+ strictTransportSecurity: 'NULL', // `null` values are coalesced to `"NULL"` strings
+ xContentTypeOptions: 'nosniff',
+ referrerPolicy: 'no-referrer-when-downgrade',
+ permissionsPolicyConfigured: false,
+ disableEmbedding: false,
+ },
xsrf: {
disableProtection: false,
allowlistConfigured: false,
@@ -132,6 +139,7 @@ const createStartContractMock = () => {
},
})
),
+ getConfigsUsageData: jest.fn(),
};
return startContract;
diff --git a/src/core/server/core_usage_data/core_usage_data_service.test.ts b/src/core/server/core_usage_data/core_usage_data_service.test.ts
index 1c28eca1f1dec..dc74b65c8dcfc 100644
--- a/src/core/server/core_usage_data/core_usage_data_service.test.ts
+++ b/src/core/server/core_usage_data/core_usage_data_service.test.ts
@@ -35,7 +35,35 @@ describe('CoreUsageDataService', () => {
});
let service: CoreUsageDataService;
- const configService = configServiceMock.create();
+ const mockConfig = {
+ unused_config: {},
+ elasticsearch: { username: 'kibana_system', password: 'changeme' },
+ plugins: { paths: ['pluginA', 'pluginAB', 'pluginB'] },
+ server: { port: 5603, basePath: '/zvt', rewriteBasePath: true },
+ logging: { json: false },
+ pluginA: {
+ enabled: true,
+ objectConfig: {
+ debug: true,
+ username: 'some_user',
+ },
+ arrayOfNumbers: [1, 2, 3],
+ },
+ pluginAB: {
+ enabled: false,
+ },
+ pluginB: {
+ arrayOfObjects: [
+ { propA: 'a', propB: 'b' },
+ { propA: 'a2', propB: 'b2' },
+ ],
+ },
+ };
+
+ const configService = configServiceMock.create({
+ getConfig$: mockConfig,
+ });
+
configService.atPath.mockImplementation((path) => {
if (path === 'elasticsearch') {
return new BehaviorSubject(RawElasticsearchConfig.schema.validate({}));
@@ -146,6 +174,7 @@ describe('CoreUsageDataService', () => {
const { getCoreUsageData } = service.start({
savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage: new Map(),
elasticsearch,
});
expect(getCoreUsageData()).resolves.toMatchInlineSnapshot(`
@@ -187,6 +216,13 @@ describe('CoreUsageDataService', () => {
"ipAllowlistConfigured": false,
},
"rewriteBasePath": false,
+ "securityResponseHeaders": Object {
+ "disableEmbedding": false,
+ "permissionsPolicyConfigured": false,
+ "referrerPolicy": "no-referrer-when-downgrade",
+ "strictTransportSecurity": "NULL",
+ "xContentTypeOptions": "nosniff",
+ },
"socketTimeout": 120000,
"ssl": Object {
"certificateAuthoritiesConfigured": false,
@@ -274,6 +310,453 @@ describe('CoreUsageDataService', () => {
`);
});
});
+
+ describe('getConfigsUsageData', () => {
+ const elasticsearch = elasticsearchServiceMock.createStart();
+ const typeRegistry = savedObjectsServiceMock.createTypeRegistryMock();
+ let exposedConfigsToUsage: Map>;
+ beforeEach(() => {
+ exposedConfigsToUsage = new Map();
+ });
+
+ it('loops over all used configs once each', async () => {
+ configService.getUsedPaths.mockResolvedValue([
+ 'pluginA.objectConfig.debug',
+ 'logging.json',
+ ]);
+
+ exposedConfigsToUsage.set('pluginA', {
+ objectConfig: true,
+ });
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ const mockGetMarkedAsSafe = jest.fn().mockReturnValue({});
+ // @ts-expect-error
+ service.getMarkedAsSafe = mockGetMarkedAsSafe;
+ await getConfigsUsageData();
+
+ expect(mockGetMarkedAsSafe).toBeCalledTimes(2);
+ expect(mockGetMarkedAsSafe.mock.calls).toMatchInlineSnapshot(`
+ Array [
+ Array [
+ Map {
+ "pluginA" => Object {
+ "objectConfig": true,
+ },
+ },
+ "pluginA.objectConfig.debug",
+ "pluginA",
+ ],
+ Array [
+ Map {
+ "pluginA" => Object {
+ "objectConfig": true,
+ },
+ },
+ "logging.json",
+ undefined,
+ ],
+ ]
+ `);
+ });
+
+ it('plucks pluginId from config path correctly', async () => {
+ exposedConfigsToUsage.set('pluginA', {
+ enabled: false,
+ });
+ exposedConfigsToUsage.set('pluginAB', {
+ enabled: false,
+ });
+
+ configService.getUsedPaths.mockResolvedValue(['pluginA.enabled', 'pluginAB.enabled']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginA.enabled": "[redacted]",
+ "pluginAB.enabled": "[redacted]",
+ }
+ `);
+ });
+
+ it('returns an object of plugin config usage', async () => {
+ exposedConfigsToUsage.set('unused_config', { never_reported: true });
+ exposedConfigsToUsage.set('server', { basePath: true });
+ exposedConfigsToUsage.set('pluginA', { elasticsearch: false });
+ exposedConfigsToUsage.set('plugins', { paths: false });
+ exposedConfigsToUsage.set('pluginA', { arrayOfNumbers: false });
+
+ configService.getUsedPaths.mockResolvedValue([
+ 'elasticsearch.username',
+ 'elasticsearch.password',
+ 'plugins.paths',
+ 'server.port',
+ 'server.basePath',
+ 'server.rewriteBasePath',
+ 'logging.json',
+ 'pluginA.enabled',
+ 'pluginA.objectConfig.debug',
+ 'pluginA.objectConfig.username',
+ 'pluginA.arrayOfNumbers',
+ 'pluginAB.enabled',
+ 'pluginB.arrayOfObjects',
+ ]);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "elasticsearch.password": "[redacted]",
+ "elasticsearch.username": "[redacted]",
+ "logging.json": false,
+ "pluginA.arrayOfNumbers": "[redacted]",
+ "pluginA.enabled": true,
+ "pluginA.objectConfig.debug": true,
+ "pluginA.objectConfig.username": "[redacted]",
+ "pluginAB.enabled": false,
+ "pluginB.arrayOfObjects": "[redacted]",
+ "plugins.paths": "[redacted]",
+ "server.basePath": "/zvt",
+ "server.port": 5603,
+ "server.rewriteBasePath": true,
+ }
+ `);
+ });
+
+ describe('config explicitly exposed to usage', () => {
+ it('returns [redacted] on unsafe complete match', async () => {
+ exposedConfigsToUsage.set('pluginA', {
+ 'objectConfig.debug': false,
+ });
+ exposedConfigsToUsage.set('server', {
+ basePath: false,
+ });
+
+ configService.getUsedPaths.mockResolvedValue([
+ 'pluginA.objectConfig.debug',
+ 'server.basePath',
+ ]);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginA.objectConfig.debug": "[redacted]",
+ "server.basePath": "[redacted]",
+ }
+ `);
+ });
+
+ it('returns config value on safe complete match', async () => {
+ exposedConfigsToUsage.set('server', {
+ basePath: true,
+ });
+
+ configService.getUsedPaths.mockResolvedValue(['server.basePath']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "server.basePath": "/zvt",
+ }
+ `);
+ });
+
+ it('returns [redacted] on unsafe parent match', async () => {
+ exposedConfigsToUsage.set('pluginA', {
+ objectConfig: false,
+ });
+
+ configService.getUsedPaths.mockResolvedValue([
+ 'pluginA.objectConfig.debug',
+ 'pluginA.objectConfig.username',
+ ]);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginA.objectConfig.debug": "[redacted]",
+ "pluginA.objectConfig.username": "[redacted]",
+ }
+ `);
+ });
+
+ it('returns config value on safe parent match', async () => {
+ exposedConfigsToUsage.set('pluginA', {
+ objectConfig: true,
+ });
+
+ configService.getUsedPaths.mockResolvedValue([
+ 'pluginA.objectConfig.debug',
+ 'pluginA.objectConfig.username',
+ ]);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginA.objectConfig.debug": true,
+ "pluginA.objectConfig.username": "some_user",
+ }
+ `);
+ });
+
+ it('returns [redacted] on explicitly marked as safe array of objects', async () => {
+ exposedConfigsToUsage.set('pluginB', {
+ arrayOfObjects: true,
+ });
+
+ configService.getUsedPaths.mockResolvedValue(['pluginB.arrayOfObjects']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginB.arrayOfObjects": "[redacted]",
+ }
+ `);
+ });
+
+ it('returns values on explicitly marked as safe array of numbers', async () => {
+ exposedConfigsToUsage.set('pluginA', {
+ arrayOfNumbers: true,
+ });
+
+ configService.getUsedPaths.mockResolvedValue(['pluginA.arrayOfNumbers']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginA.arrayOfNumbers": Array [
+ 1,
+ 2,
+ 3,
+ ],
+ }
+ `);
+ });
+
+ it('returns values on explicitly marked as safe array of strings', async () => {
+ exposedConfigsToUsage.set('plugins', {
+ paths: true,
+ });
+
+ configService.getUsedPaths.mockResolvedValue(['plugins.paths']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "plugins.paths": Array [
+ "pluginA",
+ "pluginAB",
+ "pluginB",
+ ],
+ }
+ `);
+ });
+ });
+
+ describe('config not explicitly exposed to usage', () => {
+ it('returns [redacted] for string configs', async () => {
+ exposedConfigsToUsage.set('pluginA', {
+ objectConfig: false,
+ });
+
+ configService.getUsedPaths.mockResolvedValue([
+ 'pluginA.objectConfig.debug',
+ 'pluginA.objectConfig.username',
+ ]);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginA.objectConfig.debug": "[redacted]",
+ "pluginA.objectConfig.username": "[redacted]",
+ }
+ `);
+ });
+
+ it('returns config value on safe parent match', async () => {
+ configService.getUsedPaths.mockResolvedValue([
+ 'elasticsearch.password',
+ 'elasticsearch.username',
+ 'pluginA.objectConfig.username',
+ ]);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "elasticsearch.password": "[redacted]",
+ "elasticsearch.username": "[redacted]",
+ "pluginA.objectConfig.username": "[redacted]",
+ }
+ `);
+ });
+
+ it('returns [redacted] on implicit array of objects', async () => {
+ configService.getUsedPaths.mockResolvedValue(['pluginB.arrayOfObjects']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginB.arrayOfObjects": "[redacted]",
+ }
+ `);
+ });
+
+ it('returns values on implicit array of numbers', async () => {
+ configService.getUsedPaths.mockResolvedValue(['pluginA.arrayOfNumbers']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "pluginA.arrayOfNumbers": Array [
+ 1,
+ 2,
+ 3,
+ ],
+ }
+ `);
+ });
+ it('returns [redacted] on implicit array of strings', async () => {
+ configService.getUsedPaths.mockResolvedValue(['plugins.paths']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "plugins.paths": "[redacted]",
+ }
+ `);
+ });
+
+ it('returns config value for numbers', async () => {
+ configService.getUsedPaths.mockResolvedValue(['server.port']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "server.port": 5603,
+ }
+ `);
+ });
+
+ it('returns config value for booleans', async () => {
+ configService.getUsedPaths.mockResolvedValue([
+ 'pluginA.objectConfig.debug',
+ 'logging.json',
+ ]);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "logging.json": false,
+ "pluginA.objectConfig.debug": true,
+ }
+ `);
+ });
+
+ it('ignores exposed to usage configs but not used', async () => {
+ exposedConfigsToUsage.set('pluginA', {
+ objectConfig: true,
+ });
+
+ configService.getUsedPaths.mockResolvedValue(['logging.json']);
+
+ const { getConfigsUsageData } = service.start({
+ savedObjects: savedObjectsServiceMock.createInternalStartContract(typeRegistry),
+ exposedConfigsToUsage,
+ elasticsearch,
+ });
+
+ await expect(getConfigsUsageData()).resolves.toMatchInlineSnapshot(`
+ Object {
+ "logging.json": false,
+ }
+ `);
+ });
+ });
+ });
});
describe('setup and stop', () => {
diff --git a/src/core/server/core_usage_data/core_usage_data_service.ts b/src/core/server/core_usage_data/core_usage_data_service.ts
index dff68bf1c524f..85abdca9ea5dc 100644
--- a/src/core/server/core_usage_data/core_usage_data_service.ts
+++ b/src/core/server/core_usage_data/core_usage_data_service.ts
@@ -7,7 +7,9 @@
*/
import { Subject } from 'rxjs';
-import { takeUntil } from 'rxjs/operators';
+import { takeUntil, first } from 'rxjs/operators';
+import { get } from 'lodash';
+import { hasConfigPathIntersection } from '@kbn/config';
import { CoreService } from 'src/core/types';
import { Logger, SavedObjectsServiceStart, SavedObjectTypeRegistry } from 'src/core/server';
@@ -16,11 +18,12 @@ import { ElasticsearchConfigType } from '../elasticsearch/elasticsearch_config';
import { HttpConfigType, InternalHttpServiceSetup } from '../http';
import { LoggingConfigType } from '../logging';
import { SavedObjectsConfigType } from '../saved_objects/saved_objects_config';
-import {
+import type {
CoreServicesUsageData,
CoreUsageData,
CoreUsageDataStart,
CoreUsageDataSetup,
+ ConfigUsageData,
} from './types';
import { isConfigured } from './is_configured';
import { ElasticsearchServiceStart } from '../elasticsearch';
@@ -30,6 +33,8 @@ import { CORE_USAGE_STATS_TYPE } from './constants';
import { CoreUsageStatsClient } from './core_usage_stats_client';
import { MetricsServiceSetup, OpsMetrics } from '..';
+export type ExposedConfigsToUsage = Map>;
+
export interface SetupDeps {
http: InternalHttpServiceSetup;
metrics: MetricsServiceSetup;
@@ -39,6 +44,7 @@ export interface SetupDeps {
export interface StartDeps {
savedObjects: SavedObjectsServiceStart;
elasticsearch: ElasticsearchServiceStart;
+ exposedConfigsToUsage: ExposedConfigsToUsage;
}
/**
@@ -225,6 +231,16 @@ export class CoreUsageDataService implements CoreService {
+ const fullPath = `${pluginId}.${exposeKey}`;
+ return hasConfigPathIntersection(usedPath, fullPath);
+ });
+
+ if (exposeKeyDetails) {
+ const explicitlyMarkedAsSafe = exposeDetails[exposeKeyDetails];
+
+ if (typeof explicitlyMarkedAsSafe === 'boolean') {
+ return {
+ explicitlyMarked: true,
+ isSafe: explicitlyMarkedAsSafe,
+ };
+ }
+ }
+ }
+
+ return { explicitlyMarked: false, isSafe: false };
+ }
+
+ private async getNonDefaultKibanaConfigs(
+ exposedConfigsToUsage: ExposedConfigsToUsage
+ ): Promise {
+ const config = await this.configService.getConfig$().pipe(first()).toPromise();
+ const nonDefaultConfigs = config.toRaw();
+ const usedPaths = await this.configService.getUsedPaths();
+ const exposedConfigsKeys = [...exposedConfigsToUsage.keys()];
+
+ return usedPaths.reduce((acc, usedPath) => {
+ const rawConfigValue = get(nonDefaultConfigs, usedPath);
+ const pluginId = exposedConfigsKeys.find(
+ (exposedConfigsKey) =>
+ usedPath === exposedConfigsKey || usedPath.startsWith(`${exposedConfigsKey}.`)
+ );
+
+ const { explicitlyMarked, isSafe } = this.getMarkedAsSafe(
+ exposedConfigsToUsage,
+ usedPath,
+ pluginId
+ );
+
+ // explicitly marked as safe
+ if (explicitlyMarked && isSafe) {
+ // report array of objects as redacted even if explicitly marked as safe.
+ // TS typings prevent explicitly marking arrays of objects as safe
+ // this makes sure to report redacted even if TS was bypassed.
+ if (
+ Array.isArray(rawConfigValue) &&
+ rawConfigValue.some((item) => typeof item === 'object')
+ ) {
+ acc[usedPath] = '[redacted]';
+ } else {
+ acc[usedPath] = rawConfigValue;
+ }
+ }
+
+ // explicitly marked as unsafe
+ if (explicitlyMarked && !isSafe) {
+ acc[usedPath] = '[redacted]';
+ }
+
+ /**
+ * not all types of values may contain sensitive values.
+ * Report boolean and number configs if not explicitly marked as unsafe.
+ */
+ if (!explicitlyMarked) {
+ switch (typeof rawConfigValue) {
+ case 'number':
+ case 'boolean':
+ acc[usedPath] = rawConfigValue;
+ break;
+ case 'undefined':
+ acc[usedPath] = 'undefined';
+ break;
+ case 'object': {
+ // non-array object types are already handled
+ if (Array.isArray(rawConfigValue)) {
+ if (
+ rawConfigValue.every(
+ (item) => typeof item === 'number' || typeof item === 'boolean'
+ )
+ ) {
+ acc[usedPath] = rawConfigValue;
+ break;
+ }
+ }
+ }
+ default: {
+ acc[usedPath] = '[redacted]';
+ }
+ }
+ }
+
+ return acc;
+ }, {} as Record);
+ }
+
setup({ http, metrics, savedObjectsStartPromise }: SetupDeps) {
metrics
.getOpsMetrics$()
@@ -316,10 +436,13 @@ export class CoreUsageDataService implements CoreService {
- return this.getCoreUsageData(savedObjects, elasticsearch);
+ getCoreUsageData: async () => {
+ return await this.getCoreUsageData(savedObjects, elasticsearch);
+ },
+ getConfigsUsageData: async () => {
+ return await this.getNonDefaultKibanaConfigs(exposedConfigsToUsage);
},
};
}
diff --git a/src/core/server/core_usage_data/index.ts b/src/core/server/core_usage_data/index.ts
index 4e0200ed1e4ea..638fc65522433 100644
--- a/src/core/server/core_usage_data/index.ts
+++ b/src/core/server/core_usage_data/index.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-export type { CoreUsageDataSetup, CoreUsageDataStart } from './types';
+export type { CoreUsageDataSetup, ConfigUsageData, CoreUsageDataStart } from './types';
export { CoreUsageDataService } from './core_usage_data_service';
export { CoreUsageStatsClient } from './core_usage_stats_client';
diff --git a/src/core/server/core_usage_data/types.ts b/src/core/server/core_usage_data/types.ts
index 46148e314bfee..1d5ef6d893f53 100644
--- a/src/core/server/core_usage_data/types.ts
+++ b/src/core/server/core_usage_data/types.ts
@@ -122,6 +122,18 @@ export interface CoreUsageData extends CoreUsageStats {
environment: CoreEnvironmentUsageData;
}
+/**
+ * Type describing Core's usage data payload
+ * @internal
+ */
+export type ConfigUsageData = Record;
+
+/**
+ * Type describing Core's usage data payload
+ * @internal
+ */
+export type ExposedConfigsToUsage = Map>;
+
/**
* Usage data from Core services
* @internal
@@ -212,6 +224,13 @@ export interface CoreConfigUsageData {
supportedProtocols: string[];
clientAuthentication: 'none' | 'optional' | 'required';
};
+ securityResponseHeaders: {
+ strictTransportSecurity: string;
+ xContentTypeOptions: string;
+ referrerPolicy: string;
+ permissionsPolicyConfigured: boolean;
+ disableEmbedding: boolean;
+ };
};
logging: {
@@ -263,4 +282,5 @@ export interface CoreUsageDataStart {
* @internal
* */
getCoreUsageData(): Promise;
+ getConfigsUsageData(): Promise;
}
diff --git a/src/core/server/csp/config.test.ts b/src/core/server/csp/config.test.ts
new file mode 100644
index 0000000000000..c7f6c4a214fac
--- /dev/null
+++ b/src/core/server/csp/config.test.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { config } from './config';
+
+describe('config.validate()', () => {
+ test(`does not allow "disableEmbedding" to be set to true`, () => {
+ // This is intentionally not editable in the raw CSP config.
+ // Users should set `server.securityResponseHeaders.disableEmbedding` to control this config property.
+ expect(() => config.schema.validate({ disableEmbedding: true })).toThrowError(
+ '[disableEmbedding.0]: expected value to equal [false]'
+ );
+ });
+});
diff --git a/src/core/server/csp/config.ts b/src/core/server/csp/config.ts
index 3fc9faa26179e..a61fa1b03a45c 100644
--- a/src/core/server/csp/config.ts
+++ b/src/core/server/csp/config.ts
@@ -27,5 +27,8 @@ export const config = {
}),
strict: schema.boolean({ defaultValue: true }),
warnLegacyBrowsers: schema.boolean({ defaultValue: true }),
+ disableEmbedding: schema.oneOf([schema.literal(false)], { defaultValue: false }),
}),
};
+
+export const FRAME_ANCESTORS_RULE = `frame-ancestors 'self'`; // only used by CspConfig when embedding is disabled
diff --git a/src/core/server/csp/csp_config.test.ts b/src/core/server/csp/csp_config.test.ts
index ed13d363c4166..1e023c6f08ea8 100644
--- a/src/core/server/csp/csp_config.test.ts
+++ b/src/core/server/csp/csp_config.test.ts
@@ -6,7 +6,8 @@
* Side Public License, v 1.
*/
-import { CspConfig } from '.';
+import { CspConfig } from './csp_config';
+import { FRAME_ANCESTORS_RULE } from './config';
// CSP rules aren't strictly additive, so any change can potentially expand or
// restrict the policy in a way we consider a breaking change. For that reason,
@@ -25,6 +26,7 @@ describe('CspConfig', () => {
test('DEFAULT', () => {
expect(CspConfig.DEFAULT).toMatchInlineSnapshot(`
CspConfig {
+ "disableEmbedding": false,
"header": "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'",
"rules": Array [
"script-src 'unsafe-eval' 'self'",
@@ -38,49 +40,51 @@ describe('CspConfig', () => {
});
test('defaults from config', () => {
- expect(new CspConfig()).toMatchInlineSnapshot(`
- CspConfig {
- "header": "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'",
- "rules": Array [
- "script-src 'unsafe-eval' 'self'",
- "worker-src blob: 'self'",
- "style-src 'unsafe-inline' 'self'",
- ],
- "strict": true,
- "warnLegacyBrowsers": true,
- }
- `);
+ expect(new CspConfig()).toEqual(CspConfig.DEFAULT);
});
- test('creates from partial config', () => {
- expect(new CspConfig({ strict: false, warnLegacyBrowsers: false })).toMatchInlineSnapshot(`
- CspConfig {
- "header": "script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'",
- "rules": Array [
- "script-src 'unsafe-eval' 'self'",
- "worker-src blob: 'self'",
- "style-src 'unsafe-inline' 'self'",
- ],
- "strict": false,
- "warnLegacyBrowsers": false,
- }
- `);
- });
+ describe('partial config', () => {
+ test('allows "rules" to be set and changes header', () => {
+ const rules = ['foo', 'bar'];
+ const config = new CspConfig({ rules });
+ expect(config.rules).toEqual(rules);
+ expect(config.header).toMatchInlineSnapshot(`"foo; bar"`);
+ });
- test('computes header from rules', () => {
- const cspConfig = new CspConfig({ rules: ['alpha', 'beta', 'gamma'] });
+ test('allows "strict" to be set', () => {
+ const config = new CspConfig({ strict: false });
+ expect(config.strict).toEqual(false);
+ expect(config.strict).not.toEqual(CspConfig.DEFAULT.strict);
+ });
- expect(cspConfig).toMatchInlineSnapshot(`
- CspConfig {
- "header": "alpha; beta; gamma",
- "rules": Array [
- "alpha",
- "beta",
- "gamma",
- ],
- "strict": true,
- "warnLegacyBrowsers": true,
- }
- `);
+ test('allows "warnLegacyBrowsers" to be set', () => {
+ const warnLegacyBrowsers = false;
+ const config = new CspConfig({ warnLegacyBrowsers });
+ expect(config.warnLegacyBrowsers).toEqual(warnLegacyBrowsers);
+ expect(config.warnLegacyBrowsers).not.toEqual(CspConfig.DEFAULT.warnLegacyBrowsers);
+ });
+
+ describe('allows "disableEmbedding" to be set', () => {
+ const disableEmbedding = true;
+
+ test('and changes rules/header if custom rules are not defined', () => {
+ const config = new CspConfig({ disableEmbedding });
+ expect(config.disableEmbedding).toEqual(disableEmbedding);
+ expect(config.disableEmbedding).not.toEqual(CspConfig.DEFAULT.disableEmbedding);
+ expect(config.rules).toEqual(expect.arrayContaining([FRAME_ANCESTORS_RULE]));
+ expect(config.header).toMatchInlineSnapshot(
+ `"script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'; frame-ancestors 'self'"`
+ );
+ });
+
+ test('and does not change rules/header if custom rules are defined', () => {
+ const rules = ['foo', 'bar'];
+ const config = new CspConfig({ disableEmbedding, rules });
+ expect(config.disableEmbedding).toEqual(disableEmbedding);
+ expect(config.disableEmbedding).not.toEqual(CspConfig.DEFAULT.disableEmbedding);
+ expect(config.rules).toEqual(rules);
+ expect(config.header).toMatchInlineSnapshot(`"foo; bar"`);
+ });
+ });
});
});
diff --git a/src/core/server/csp/csp_config.ts b/src/core/server/csp/csp_config.ts
index dd0e7ef2dbee4..649c81576ef52 100644
--- a/src/core/server/csp/csp_config.ts
+++ b/src/core/server/csp/csp_config.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import { config } from './config';
+import { config, FRAME_ANCESTORS_RULE } from './config';
const DEFAULT_CONFIG = Object.freeze(config.schema.validate({}));
@@ -32,6 +32,12 @@ export interface ICspConfig {
*/
readonly warnLegacyBrowsers: boolean;
+ /**
+ * Whether or not embedding (using iframes) should be allowed by the CSP. If embedding is disabled *and* no custom rules have been
+ * defined, a restrictive 'frame-ancestors' rule will be added to the default CSP rules.
+ */
+ readonly disableEmbedding: boolean;
+
/**
* The CSP rules in a formatted directives string for use
* in a `Content-Security-Policy` header.
@@ -49,6 +55,7 @@ export class CspConfig implements ICspConfig {
public readonly rules: string[];
public readonly strict: boolean;
public readonly warnLegacyBrowsers: boolean;
+ public readonly disableEmbedding: boolean;
public readonly header: string;
/**
@@ -58,9 +65,13 @@ export class CspConfig implements ICspConfig {
constructor(rawCspConfig: Partial> = {}) {
const source = { ...DEFAULT_CONFIG, ...rawCspConfig };
- this.rules = source.rules;
+ this.rules = [...source.rules];
this.strict = source.strict;
this.warnLegacyBrowsers = source.warnLegacyBrowsers;
- this.header = source.rules.join('; ');
+ this.disableEmbedding = source.disableEmbedding;
+ if (!rawCspConfig.rules?.length && source.disableEmbedding) {
+ this.rules.push(FRAME_ANCESTORS_RULE);
+ }
+ this.header = this.rules.join('; ');
}
}
diff --git a/src/core/server/environment/write_pid_file.ts b/src/core/server/environment/write_pid_file.ts
index b7d47111a4d53..46096ca347e8a 100644
--- a/src/core/server/environment/write_pid_file.ts
+++ b/src/core/server/environment/write_pid_file.ts
@@ -31,13 +31,23 @@ export const writePidFile = async ({
if (pidConfig.exclusive) {
throw new Error(message);
} else {
- logger.warn(message, { path, pid });
+ logger.warn(message, {
+ process: {
+ pid: process.pid,
+ path,
+ },
+ });
}
}
await writeFile(path, pid);
- logger.debug(`wrote pid file to ${path}`, { path, pid });
+ logger.debug(`wrote pid file to ${path}`, {
+ process: {
+ pid: process.pid,
+ path,
+ },
+ });
const clean = once(() => {
unlink(path);
diff --git a/src/core/server/http/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__snapshots__/http_config.test.ts.snap
index 4545396c27b5e..42710aad40ac1 100644
--- a/src/core/server/http/__snapshots__/http_config.test.ts.snap
+++ b/src/core/server/http/__snapshots__/http_config.test.ts.snap
@@ -64,6 +64,14 @@ Object {
"ipAllowlist": Array [],
},
"rewriteBasePath": false,
+ "securityResponseHeaders": Object {
+ "disableEmbedding": false,
+ "permissionsPolicy": null,
+ "referrerPolicy": "no-referrer-when-downgrade",
+ "strictTransportSecurity": null,
+ "xContentTypeOptions": "nosniff",
+ },
+ "shutdownTimeout": "PT30S",
"socketTimeout": 120000,
"ssl": Object {
"cipherSuites": Array [
diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts
index 9868d89888110..2a140388cc184 100644
--- a/src/core/server/http/http_config.test.ts
+++ b/src/core/server/http/http_config.test.ts
@@ -108,6 +108,35 @@ test('can specify max payload as string', () => {
expect(configValue.maxPayload.getValueInBytes()).toBe(2 * 1024 * 1024);
});
+describe('shutdownTimeout', () => {
+ test('can specify a valid shutdownTimeout', () => {
+ const configValue = config.schema.validate({ shutdownTimeout: '5s' });
+ expect(configValue.shutdownTimeout.asMilliseconds()).toBe(5000);
+ });
+
+ test('can specify a valid shutdownTimeout (lower-edge of 1 second)', () => {
+ const configValue = config.schema.validate({ shutdownTimeout: '1s' });
+ expect(configValue.shutdownTimeout.asMilliseconds()).toBe(1000);
+ });
+
+ test('can specify a valid shutdownTimeout (upper-edge of 2 minutes)', () => {
+ const configValue = config.schema.validate({ shutdownTimeout: '2m' });
+ expect(configValue.shutdownTimeout.asMilliseconds()).toBe(120000);
+ });
+
+ test('should error if below 1s', () => {
+ expect(() => config.schema.validate({ shutdownTimeout: '100ms' })).toThrow(
+ '[shutdownTimeout]: the value should be between 1 second and 2 minutes'
+ );
+ });
+
+ test('should error if over 2 minutes', () => {
+ expect(() => config.schema.validate({ shutdownTimeout: '3m' })).toThrow(
+ '[shutdownTimeout]: the value should be between 1 second and 2 minutes'
+ );
+ });
+});
+
describe('basePath', () => {
test('throws if missing prepended slash', () => {
const httpSchema = config.schema;
diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts
index daf7424b8f8bd..9d0008e1c4011 100644
--- a/src/core/server/http/http_config.ts
+++ b/src/core/server/http/http_config.ts
@@ -11,9 +11,14 @@ import { IHttpConfig, SslConfig, sslSchema } from '@kbn/server-http-tools';
import { hostname } from 'os';
import url from 'url';
+import type { Duration } from 'moment';
import { ServiceConfigDescriptor } from '../internal_types';
import { CspConfigType, CspConfig, ICspConfig } from '../csp';
import { ExternalUrlConfig, IExternalUrlConfig } from '../external_url';
+import {
+ securityResponseHeadersSchema,
+ parseRawSecurityResponseHeadersConfig,
+} from './security_response_headers_config';
const validBasePathRegex = /^\/.*[^\/]$/;
const uuidRegexp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -31,6 +36,15 @@ const configSchema = schema.object(
validate: match(validBasePathRegex, "must start with a slash, don't end with one"),
})
),
+ shutdownTimeout: schema.duration({
+ defaultValue: '30s',
+ validate: (duration) => {
+ const durationMs = duration.asMilliseconds();
+ if (durationMs < 1000 || durationMs > 2 * 60 * 1000) {
+ return 'the value should be between 1 second and 2 minutes';
+ }
+ },
+ }),
cors: schema.object(
{
enabled: schema.boolean({ defaultValue: false }),
@@ -53,6 +67,7 @@ const configSchema = schema.object(
},
}
),
+ securityResponseHeaders: securityResponseHeadersSchema,
customResponseHeaders: schema.recordOf(schema.string(), schema.any(), {
defaultValue: {},
}),
@@ -171,6 +186,7 @@ export class HttpConfig implements IHttpConfig {
allowCredentials: boolean;
allowOrigin: string[];
};
+ public securityResponseHeaders: Record;
public customResponseHeaders: Record;
public maxPayload: ByteSizeValue;
public basePath?: string;
@@ -182,6 +198,7 @@ export class HttpConfig implements IHttpConfig {
public externalUrl: IExternalUrlConfig;
public xsrf: { disableProtection: boolean; allowlist: string[] };
public requestId: { allowFromAnyIp: boolean; ipAllowlist: string[] };
+ public shutdownTimeout: Duration;
/**
* @internal
@@ -195,6 +212,10 @@ export class HttpConfig implements IHttpConfig {
this.host = rawHttpConfig.host;
this.port = rawHttpConfig.port;
this.cors = rawHttpConfig.cors;
+ const { securityResponseHeaders, disableEmbedding } = parseRawSecurityResponseHeadersConfig(
+ rawHttpConfig.securityResponseHeaders
+ );
+ this.securityResponseHeaders = securityResponseHeaders;
this.customResponseHeaders = Object.entries(rawHttpConfig.customResponseHeaders ?? {}).reduce(
(headers, [key, value]) => {
return {
@@ -213,10 +234,11 @@ export class HttpConfig implements IHttpConfig {
this.rewriteBasePath = rawHttpConfig.rewriteBasePath;
this.ssl = new SslConfig(rawHttpConfig.ssl || {});
this.compression = rawHttpConfig.compression;
- this.csp = new CspConfig(rawCspConfig);
+ this.csp = new CspConfig({ ...rawCspConfig, disableEmbedding });
this.externalUrl = rawExternalUrlConfig;
this.xsrf = rawHttpConfig.xsrf;
this.requestId = rawHttpConfig.requestId;
+ this.shutdownTimeout = rawHttpConfig.shutdownTimeout;
}
}
diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts
index ccd14d4b99e11..1a82907849cea 100644
--- a/src/core/server/http/http_server.test.ts
+++ b/src/core/server/http/http_server.test.ts
@@ -26,6 +26,8 @@ import { HttpServer } from './http_server';
import { Readable } from 'stream';
import { RequestHandlerContext } from 'kibana/server';
import { KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils';
+import moment from 'moment';
+import { of } from 'rxjs';
const cookieOptions = {
name: 'sid',
@@ -65,6 +67,7 @@ beforeEach(() => {
cors: {
enabled: false,
},
+ shutdownTimeout: moment.duration(500, 'ms'),
} as any;
configWithSSL = {
@@ -79,7 +82,7 @@ beforeEach(() => {
},
} as HttpConfig;
- server = new HttpServer(loggingService, 'tests');
+ server = new HttpServer(loggingService, 'tests', of(config.shutdownTimeout));
});
afterEach(async () => {
@@ -1431,3 +1434,79 @@ describe('setup contract', () => {
});
});
});
+
+describe('Graceful shutdown', () => {
+ let shutdownTimeout: number;
+ let innerServerListener: Server;
+
+ beforeEach(async () => {
+ shutdownTimeout = config.shutdownTimeout.asMilliseconds();
+ const { registerRouter, server: innerServer } = await server.setup(config);
+ innerServerListener = innerServer.listener;
+
+ const router = new Router('', logger, enhanceWithContext);
+ router.post(
+ {
+ path: '/',
+ validate: false,
+ options: { body: { accepts: 'application/json' } },
+ },
+ async (context, req, res) => {
+ // It takes to resolve the same period of the shutdownTimeout.
+ // Since we'll trigger the stop a few ms after, it should have time to finish
+ await new Promise((resolve) => setTimeout(resolve, shutdownTimeout));
+ return res.ok({ body: { ok: 1 } });
+ }
+ );
+ registerRouter(router);
+
+ await server.start();
+ });
+
+ test('any ongoing requests should be resolved with `connection: close`', async () => {
+ const [response] = await Promise.all([
+ // Trigger a request that should hold the server from stopping until fulfilled
+ supertest(innerServerListener).post('/'),
+ // Stop the server while the request is in progress
+ (async () => {
+ await new Promise((resolve) => setTimeout(resolve, shutdownTimeout / 3));
+ await server.stop();
+ })(),
+ ]);
+
+ expect(response.status).toBe(200);
+ expect(response.body).toStrictEqual({ ok: 1 });
+ // The server is about to be closed, we need to ask connections to close on their end (stop their keep-alive policies)
+ expect(response.header.connection).toBe('close');
+ });
+
+ test('any requests triggered while stopping should be rejected with 503', async () => {
+ const [, , response] = await Promise.all([
+ // Trigger a request that should hold the server from stopping until fulfilled (otherwise the server will stop straight away)
+ supertest(innerServerListener).post('/'),
+ // Stop the server while the request is in progress
+ (async () => {
+ await new Promise((resolve) => setTimeout(resolve, shutdownTimeout / 3));
+ await server.stop();
+ })(),
+ // Trigger a new request while shutting down (should be rejected)
+ (async () => {
+ await new Promise((resolve) => setTimeout(resolve, (2 * shutdownTimeout) / 3));
+ return supertest(innerServerListener).post('/');
+ })(),
+ ]);
+ expect(response.status).toBe(503);
+ expect(response.body).toStrictEqual({
+ statusCode: 503,
+ error: 'Service Unavailable',
+ message: 'Kibana is shutting down and not accepting new incoming requests',
+ });
+ expect(response.header.connection).toBe('close');
+ });
+
+ test('when no ongoing connections, the server should stop without waiting any longer', async () => {
+ const preStop = Date.now();
+ await server.stop();
+ expect(Date.now() - preStop).toBeLessThan(shutdownTimeout);
+ });
+});
diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts
index cd7d7ccc5aeff..d845ac1b639b6 100644
--- a/src/core/server/http/http_server.ts
+++ b/src/core/server/http/http_server.ts
@@ -17,6 +17,9 @@ import {
getRequestId,
} from '@kbn/server-http-tools';
+import type { Duration } from 'moment';
+import { Observable } from 'rxjs';
+import { take } from 'rxjs/operators';
import { Logger, LoggerFactory } from '../logging';
import { HttpConfig } from './http_config';
import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth';
@@ -80,6 +83,7 @@ export class HttpServer {
private authRegistered = false;
private cookieSessionStorageCreated = false;
private handleServerResponseEvent?: (req: Request) => void;
+ private stopping = false;
private stopped = false;
private readonly log: Logger;
@@ -87,7 +91,11 @@ export class HttpServer {
private readonly authRequestHeaders: AuthHeadersStorage;
private readonly authResponseHeaders: AuthHeadersStorage;
- constructor(private readonly logger: LoggerFactory, private readonly name: string) {
+ constructor(
+ private readonly logger: LoggerFactory,
+ private readonly name: string,
+ private readonly shutdownTimeout$: Observable
+ ) {
this.authState = new AuthStateStorage(() => this.authRegistered);
this.authRequestHeaders = new AuthHeadersStorage();
this.authResponseHeaders = new AuthHeadersStorage();
@@ -118,6 +126,7 @@ export class HttpServer {
this.setupConditionalCompression(config);
this.setupResponseLogging();
this.setupRequestStateAssignment(config);
+ this.setupGracefulShutdownHandlers();
return {
registerRouter: this.registerRouter.bind(this),
@@ -153,7 +162,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Http server is not setup up yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`start called after stop`);
return;
}
@@ -213,19 +222,29 @@ export class HttpServer {
}
public async stop() {
- this.stopped = true;
+ this.stopping = true;
if (this.server === undefined) {
+ this.stopping = false;
+ this.stopped = true;
return;
}
const hasStarted = this.server.info.started > 0;
if (hasStarted) {
this.log.debug('stopping http server');
+
+ const shutdownTimeout = await this.shutdownTimeout$.pipe(take(1)).toPromise();
+ await this.server.stop({ timeout: shutdownTimeout.asMilliseconds() });
+
+ this.log.debug(`http server stopped`);
+
+ // Removing the listener after stopping so we don't leave any pending requests unhandled
if (this.handleServerResponseEvent) {
this.server.events.removeListener('response', this.handleServerResponseEvent);
}
- await this.server.stop();
}
+ this.stopping = false;
+ this.stopped = true;
}
private getAuthOption(
@@ -246,6 +265,18 @@ export class HttpServer {
}
}
+ private setupGracefulShutdownHandlers() {
+ this.registerOnPreRouting((request, response, toolkit) => {
+ if (this.stopping || this.stopped) {
+ return response.customError({
+ statusCode: 503,
+ body: { message: 'Kibana is shutting down and not accepting new incoming requests' },
+ });
+ }
+ return toolkit.next();
+ });
+ }
+
private setupBasePathRewrite(config: HttpConfig, basePathService: BasePath) {
if (config.basePath === undefined || !config.rewriteBasePath) {
return;
@@ -266,7 +297,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`setupConditionalCompression called after stop`);
}
@@ -296,14 +327,14 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`setupResponseLogging called after stop`);
}
const log = this.logger.get('http', 'server', 'response');
this.handleServerResponseEvent = (request) => {
- const { message, ...meta } = getEcsResponseLog(request, this.log);
+ const { message, meta } = getEcsResponseLog(request, this.log);
log.debug(message!, meta);
};
@@ -325,7 +356,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`registerOnPreAuth called after stop`);
}
@@ -336,7 +367,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`registerOnPostAuth called after stop`);
}
@@ -347,7 +378,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`registerOnPreRouting called after stop`);
}
@@ -358,7 +389,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`registerOnPreResponse called after stop`);
}
@@ -372,7 +403,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`createCookieSessionStorageFactory called after stop`);
}
if (this.cookieSessionStorageCreated) {
@@ -392,7 +423,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Server is not created yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`registerAuth called after stop`);
}
if (this.authRegistered) {
@@ -438,7 +469,7 @@ export class HttpServer {
if (this.server === undefined) {
throw new Error('Http server is not setup up yet');
}
- if (this.stopped) {
+ if (this.stopping || this.stopped) {
this.log.warn(`registerStaticDir called after stop`);
}
diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts
index 5b90440f6ad70..fdf9b738a9833 100644
--- a/src/core/server/http/http_service.ts
+++ b/src/core/server/http/http_service.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import { Observable, Subscription, combineLatest } from 'rxjs';
+import { Observable, Subscription, combineLatest, of } from 'rxjs';
import { first, map } from 'rxjs/operators';
import { Server } from '@hapi/hapi';
import { pick } from '@kbn/std';
@@ -69,7 +69,8 @@ export class HttpService
configService.atPath(cspConfig.path),
configService.atPath(externalUrlConfig.path),
]).pipe(map(([http, csp, externalUrl]) => new HttpConfig(http, csp, externalUrl)));
- this.httpServer = new HttpServer(logger, 'Kibana');
+ const shutdownTimeout$ = this.config$.pipe(map(({ shutdownTimeout }) => shutdownTimeout));
+ this.httpServer = new HttpServer(logger, 'Kibana', shutdownTimeout$);
this.httpsRedirectServer = new HttpsRedirectServer(logger.get('http', 'redirect', 'server'));
}
@@ -167,7 +168,7 @@ export class HttpService
return;
}
- this.configSubscription.unsubscribe();
+ this.configSubscription?.unsubscribe();
this.configSubscription = undefined;
if (this.notReadyServer) {
@@ -179,7 +180,7 @@ export class HttpService
private async runNotReadyServer(config: HttpConfig) {
this.log.debug('starting NotReady server');
- const httpServer = new HttpServer(this.logger, 'NotReady');
+ const httpServer = new HttpServer(this.logger, 'NotReady', of(config.shutdownTimeout));
const { server } = await httpServer.setup(config);
this.notReadyServer = server;
// use hapi server while KibanaResponseFactory doesn't allow specifying custom headers
diff --git a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts
index 2f9d393b632f7..cbd300fdc9c09 100644
--- a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts
+++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts
@@ -7,6 +7,7 @@
*/
import supertest from 'supertest';
+import moment from 'moment';
import { BehaviorSubject } from 'rxjs';
import { ByteSizeValue } from '@kbn/config-schema';
@@ -44,6 +45,7 @@ describe('core lifecycle handlers', () => {
return new BehaviorSubject({
hosts: ['localhost'],
maxPayload: new ByteSizeValue(1024),
+ shutdownTimeout: moment.duration(30, 'seconds'),
autoListen: true,
ssl: {
enabled: false,
@@ -53,8 +55,16 @@ describe('core lifecycle handlers', () => {
},
compression: { enabled: true },
name: kibanaName,
+ securityResponseHeaders: {
+ // reflects default config
+ strictTransportSecurity: null,
+ xContentTypeOptions: 'nosniff',
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ permissionsPolicy: null,
+ },
customResponseHeaders: {
'some-header': 'some-value',
+ 'referrer-policy': 'strict-origin', // overrides a header that is defined by securityResponseHeaders
},
xsrf: { disableProtection: false, allowlist: [allowlistedTestPath] },
requestId: {
@@ -117,6 +127,13 @@ describe('core lifecycle handlers', () => {
const testRoute = '/custom_headers/test/route';
const testErrorRoute = '/custom_headers/test/error_route';
+ const expectedHeaders = {
+ [nameHeader]: kibanaName,
+ 'x-content-type-options': 'nosniff',
+ 'referrer-policy': 'strict-origin',
+ 'some-header': 'some-value',
+ };
+
beforeEach(async () => {
router.get({ path: testRoute, validate: false }, (context, req, res) => {
return res.ok({ body: 'ok' });
@@ -127,36 +144,16 @@ describe('core lifecycle handlers', () => {
await server.start();
});
- it('adds the kbn-name header', async () => {
- const result = await supertest(innerServer.listener).get(testRoute).expect(200, 'ok');
- const headers = result.header as Record;
- expect(headers).toEqual(
- expect.objectContaining({
- [nameHeader]: kibanaName,
- })
- );
- });
-
- it('adds the kbn-name header in case of error', async () => {
- const result = await supertest(innerServer.listener).get(testErrorRoute).expect(400);
- const headers = result.header as Record;
- expect(headers).toEqual(
- expect.objectContaining({
- [nameHeader]: kibanaName,
- })
- );
- });
-
- it('adds the custom headers', async () => {
+ it('adds the expected headers in case of success', async () => {
const result = await supertest(innerServer.listener).get(testRoute).expect(200, 'ok');
const headers = result.header as Record;
- expect(headers).toEqual(expect.objectContaining({ 'some-header': 'some-value' }));
+ expect(headers).toEqual(expect.objectContaining(expectedHeaders));
});
- it('adds the custom headers in case of error', async () => {
+ it('adds the expected headers in case of error', async () => {
const result = await supertest(innerServer.listener).get(testErrorRoute).expect(400);
const headers = result.header as Record;
- expect(headers).toEqual(expect.objectContaining({ 'some-header': 'some-value' }));
+ expect(headers).toEqual(expect.objectContaining(expectedHeaders));
});
});
diff --git a/src/core/server/http/lifecycle_handlers.test.ts b/src/core/server/http/lifecycle_handlers.test.ts
index cd8caa7c76ab1..e777cbb1c1ff0 100644
--- a/src/core/server/http/lifecycle_handlers.test.ts
+++ b/src/core/server/http/lifecycle_handlers.test.ts
@@ -241,12 +241,15 @@ describe('customHeaders pre-response handler', () => {
expect(toolkit.next).toHaveBeenCalledWith({ headers: { 'kbn-name': 'my-server-name' } });
});
- it('adds the custom headers defined in the configuration', () => {
+ it('adds the security headers and custom headers defined in the configuration', () => {
const config = createConfig({
name: 'my-server-name',
- customResponseHeaders: {
+ securityResponseHeaders: {
headerA: 'value-A',
- headerB: 'value-B',
+ headerB: 'value-B', // will be overridden by the custom response header below
+ },
+ customResponseHeaders: {
+ headerB: 'x',
},
});
const handler = createCustomHeadersPreResponseHandler(config as HttpConfig);
@@ -258,7 +261,7 @@ describe('customHeaders pre-response handler', () => {
headers: {
'kbn-name': 'my-server-name',
headerA: 'value-A',
- headerB: 'value-B',
+ headerB: 'x',
},
});
});
diff --git a/src/core/server/http/lifecycle_handlers.ts b/src/core/server/http/lifecycle_handlers.ts
index a1fae89b68e11..eed24c8071eaf 100644
--- a/src/core/server/http/lifecycle_handlers.ts
+++ b/src/core/server/http/lifecycle_handlers.ts
@@ -62,12 +62,12 @@ export const createVersionCheckPostAuthHandler = (kibanaVersion: string): OnPost
};
export const createCustomHeadersPreResponseHandler = (config: HttpConfig): OnPreResponseHandler => {
- const serverName = config.name;
- const customHeaders = config.customResponseHeaders;
+ const { name: serverName, securityResponseHeaders, customResponseHeaders } = config;
return (request, response, toolkit) => {
const additionalHeaders = {
- ...customHeaders,
+ ...securityResponseHeaders,
+ ...customResponseHeaders,
[KIBANA_NAME_HEADER]: serverName,
};
diff --git a/src/core/server/http/logging/get_response_log.test.ts b/src/core/server/http/logging/get_response_log.test.ts
index 64241ff44fc6b..5f749220138d7 100644
--- a/src/core/server/http/logging/get_response_log.test.ts
+++ b/src/core/server/http/logging/get_response_log.test.ts
@@ -81,7 +81,8 @@ describe('getEcsResponseLog', () => {
},
});
const result = getEcsResponseLog(req, logger);
- expect(result.http.response.responseTime).toBe(1000);
+ // @ts-expect-error ECS custom field
+ expect(result.meta.http.response.responseTime).toBe(1000);
});
test('with response.info.responded', () => {
@@ -92,14 +93,16 @@ describe('getEcsResponseLog', () => {
},
});
const result = getEcsResponseLog(req, logger);
- expect(result.http.response.responseTime).toBe(500);
+ // @ts-expect-error ECS custom field
+ expect(result.meta.http.response.responseTime).toBe(500);
});
test('excludes responseTime from message if none is provided', () => {
const req = createMockHapiRequest();
const result = getEcsResponseLog(req, logger);
expect(result.message).toMatchInlineSnapshot(`"GET /path 200 - 1.2KB"`);
- expect(result.http.response.responseTime).toBeUndefined();
+ // @ts-expect-error ECS custom field
+ expect(result.meta.http.response.responseTime).toBeUndefined();
});
});
@@ -112,7 +115,7 @@ describe('getEcsResponseLog', () => {
},
});
const result = getEcsResponseLog(req, logger);
- expect(result.url.query).toMatchInlineSnapshot(`"a=hello&b=world"`);
+ expect(result.meta.url!.query).toMatchInlineSnapshot(`"a=hello&b=world"`);
expect(result.message).toMatchInlineSnapshot(`"GET /path?a=hello&b=world 200 - 1.2KB"`);
});
@@ -121,7 +124,7 @@ describe('getEcsResponseLog', () => {
query: { a: '¡hola!' },
});
const result = getEcsResponseLog(req, logger);
- expect(result.url.query).toMatchInlineSnapshot(`"a=%C2%A1hola!"`);
+ expect(result.meta.url!.query).toMatchInlineSnapshot(`"a=%C2%A1hola!"`);
expect(result.message).toMatchInlineSnapshot(`"GET /path?a=%C2%A1hola! 200 - 1.2KB"`);
});
});
@@ -145,7 +148,7 @@ describe('getEcsResponseLog', () => {
response: Boom.badRequest(),
});
const result = getEcsResponseLog(req, logger);
- expect(result.http.response.status_code).toBe(400);
+ expect(result.meta.http!.response!.status_code).toBe(400);
});
describe('filters sensitive headers', () => {
@@ -155,14 +158,16 @@ describe('getEcsResponseLog', () => {
response: { headers: { 'content-length': 123, 'set-cookie': 'c' } },
});
const result = getEcsResponseLog(req, logger);
- expect(result.http.request.headers).toMatchInlineSnapshot(`
+ // @ts-expect-error ECS custom field
+ expect(result.meta.http.request.headers).toMatchInlineSnapshot(`
Object {
"authorization": "[REDACTED]",
"cookie": "[REDACTED]",
"user-agent": "hi",
}
`);
- expect(result.http.response.headers).toMatchInlineSnapshot(`
+ // @ts-expect-error ECS custom field
+ expect(result.meta.http.response.headers).toMatchInlineSnapshot(`
Object {
"content-length": 123,
"set-cookie": "[REDACTED]",
@@ -196,9 +201,12 @@ describe('getEcsResponseLog', () => {
}
`);
- responseLog.http.request.headers.a = 'testA';
- responseLog.http.request.headers.b[1] = 'testB';
- responseLog.http.request.headers.c = 'testC';
+ // @ts-expect-error ECS custom field
+ responseLog.meta.http.request.headers.a = 'testA';
+ // @ts-expect-error ECS custom field
+ responseLog.meta.http.request.headers.b[1] = 'testB';
+ // @ts-expect-error ECS custom field
+ responseLog.meta.http.request.headers.c = 'testC';
expect(reqHeaders).toMatchInlineSnapshot(`
Object {
"a": "foo",
@@ -244,48 +252,41 @@ describe('getEcsResponseLog', () => {
});
describe('ecs', () => {
- test('specifies correct ECS version', () => {
- const req = createMockHapiRequest();
- const result = getEcsResponseLog(req, logger);
- expect(result.ecs.version).toBe('1.7.0');
- });
-
test('provides an ECS-compatible response', () => {
const req = createMockHapiRequest();
const result = getEcsResponseLog(req, logger);
expect(result).toMatchInlineSnapshot(`
Object {
- "client": Object {
- "ip": undefined,
- },
- "ecs": Object {
- "version": "1.7.0",
- },
- "http": Object {
- "request": Object {
- "headers": Object {
- "user-agent": "",
- },
- "method": "GET",
- "mime_type": "application/json",
- "referrer": "localhost:5601/app/home",
+ "message": "GET /path 200 - 1.2KB",
+ "meta": Object {
+ "client": Object {
+ "ip": undefined,
},
- "response": Object {
- "body": Object {
- "bytes": 1234,
+ "http": Object {
+ "request": Object {
+ "headers": Object {
+ "user-agent": "",
+ },
+ "method": "GET",
+ "mime_type": "application/json",
+ "referrer": "localhost:5601/app/home",
+ },
+ "response": Object {
+ "body": Object {
+ "bytes": 1234,
+ },
+ "headers": Object {},
+ "responseTime": undefined,
+ "status_code": 200,
},
- "headers": Object {},
- "responseTime": undefined,
- "status_code": 200,
},
- },
- "message": "GET /path 200 - 1.2KB",
- "url": Object {
- "path": "/path",
- "query": "",
- },
- "user_agent": Object {
- "original": "",
+ "url": Object {
+ "path": "/path",
+ "query": "",
+ },
+ "user_agent": Object {
+ "original": "",
+ },
},
}
`);
diff --git a/src/core/server/http/logging/get_response_log.ts b/src/core/server/http/logging/get_response_log.ts
index 57c02e05bebff..37ee618e43395 100644
--- a/src/core/server/http/logging/get_response_log.ts
+++ b/src/core/server/http/logging/get_response_log.ts
@@ -11,10 +11,9 @@ import { isBoom } from '@hapi/boom';
import type { Request } from '@hapi/hapi';
import numeral from '@elastic/numeral';
import { LogMeta } from '@kbn/logging';
-import { EcsEvent, Logger } from '../../logging';
+import { Logger } from '../../logging';
import { getResponsePayloadBytes } from './get_payload_size';
-const ECS_VERSION = '1.7.0';
const FORBIDDEN_HEADERS = ['authorization', 'cookie', 'set-cookie'];
const REDACTED_HEADER_TEXT = '[REDACTED]';
@@ -44,7 +43,7 @@ function cloneAndFilterHeaders(headers?: HapiHeaders) {
*
* @internal
*/
-export function getEcsResponseLog(request: Request, log: Logger): LogMeta {
+export function getEcsResponseLog(request: Request, log: Logger) {
const { path, response } = request;
const method = request.method.toUpperCase();
@@ -66,9 +65,7 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta {
const bytes = getResponsePayloadBytes(response, log);
const bytesMsg = bytes ? ` - ${numeral(bytes).format('0.0b')}` : '';
- const meta: EcsEvent = {
- ecs: { version: ECS_VERSION },
- message: `${method} ${pathWithQuery} ${status_code}${responseTimeMsg}${bytesMsg}`,
+ const meta: LogMeta = {
client: {
ip: request.info.remoteAddress,
},
@@ -77,7 +74,7 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta {
method,
mime_type: request.mime,
referrer: request.info.referrer,
- // @ts-expect-error Headers are not yet part of ECS: https://github.com/elastic/ecs/issues/232.
+ // @ts-expect-error ECS custom field: https://github.com/elastic/ecs/issues/232.
headers: requestHeaders,
},
response: {
@@ -85,7 +82,7 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta {
bytes,
},
status_code,
- // @ts-expect-error Headers are not yet part of ECS: https://github.com/elastic/ecs/issues/232.
+ // @ts-expect-error ECS custom field: https://github.com/elastic/ecs/issues/232.
headers: responseHeaders,
// responseTime is a custom non-ECS field
responseTime: !isNaN(responseTime) ? responseTime : undefined,
@@ -100,5 +97,8 @@ export function getEcsResponseLog(request: Request, log: Logger): LogMeta {
},
};
- return meta;
+ return {
+ message: `${method} ${pathWithQuery} ${status_code}${responseTimeMsg}${bytesMsg}`,
+ meta,
+ };
}
diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts
index 77b40ca5995bb..ea70f1b4f543b 100644
--- a/src/core/server/http/router/route.ts
+++ b/src/core/server/http/router/route.ts
@@ -70,7 +70,7 @@ export interface RouteConfigOptionsBody {
/**
* Limits the size of incoming payloads to the specified byte count. Allowing very large payloads may cause the server to run out of memory.
*
- * Default value: The one set in the kibana.yml config file under the parameter `server.maxPayloadBytes`.
+ * Default value: The one set in the kibana.yml config file under the parameter `server.maxPayload`.
*/
maxBytes?: number;
diff --git a/src/core/server/http/security_response_headers_config.test.ts b/src/core/server/http/security_response_headers_config.test.ts
new file mode 100644
index 0000000000000..b1c8bb23102f5
--- /dev/null
+++ b/src/core/server/http/security_response_headers_config.test.ts
@@ -0,0 +1,99 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import {
+ securityResponseHeadersSchema as schema,
+ parseRawSecurityResponseHeadersConfig as parse,
+} from './security_response_headers_config';
+
+describe('parseRawSecurityResponseHeadersConfig', () => {
+ it('returns default values', () => {
+ const config = schema.validate({});
+ const result = parse(config);
+ expect(result.disableEmbedding).toBe(false);
+ expect(result.securityResponseHeaders).toMatchInlineSnapshot(`
+ Object {
+ "Referrer-Policy": "no-referrer-when-downgrade",
+ "X-Content-Type-Options": "nosniff",
+ }
+ `);
+ });
+
+ describe('strictTransportSecurity', () => {
+ it('a custom value results in the expected Strict-Transport-Security header', () => {
+ const strictTransportSecurity = 'max-age=31536000; includeSubDomains';
+ const config = schema.validate({ strictTransportSecurity });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['Strict-Transport-Security']).toEqual(
+ strictTransportSecurity
+ );
+ });
+
+ it('a null value removes the Strict-Transport-Security header', () => {
+ const config = schema.validate({ strictTransportSecurity: null });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['Strict-Transport-Security']).toBeUndefined();
+ });
+ });
+
+ describe('xContentTypeOptions', () => {
+ it('a custom value results in the expected X-Content-Type-Options header', () => {
+ const xContentTypeOptions = 'nosniff'; // there is no other valid value to test with
+ const config = schema.validate({ xContentTypeOptions });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['X-Content-Type-Options']).toEqual(xContentTypeOptions);
+ });
+
+ it('a null value removes the X-Content-Type-Options header', () => {
+ const config = schema.validate({ xContentTypeOptions: null });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['X-Content-Type-Options']).toBeUndefined();
+ });
+ });
+
+ describe('referrerPolicy', () => {
+ it('a custom value results in the expected Referrer-Policy header', () => {
+ const referrerPolicy = 'strict-origin-when-cross-origin';
+ const config = schema.validate({ referrerPolicy });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['Referrer-Policy']).toEqual(referrerPolicy);
+ });
+
+ it('a null value removes the Referrer-Policy header', () => {
+ const config = schema.validate({ referrerPolicy: null });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['Referrer-Policy']).toBeUndefined();
+ });
+ });
+
+ describe('permissionsPolicy', () => {
+ it('a custom value results in the expected Permissions-Policy header', () => {
+ const permissionsPolicy = 'display-capture=(self)';
+ const config = schema.validate({ permissionsPolicy });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['Permissions-Policy']).toEqual(permissionsPolicy);
+ });
+
+ it('a null value removes the Permissions-Policy header', () => {
+ const config = schema.validate({ permissionsPolicy: null });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['Permissions-Policy']).toBeUndefined();
+ });
+ });
+
+ describe('disableEmbedding', () => {
+ it('a true value results in the expected X-Frame-Options header and expected disableEmbedding result value', () => {
+ const config = schema.validate({ disableEmbedding: true });
+ const result = parse(config);
+ expect(result.securityResponseHeaders['X-Frame-Options']).toMatchInlineSnapshot(
+ `"SAMEORIGIN"`
+ );
+ expect(result.disableEmbedding).toBe(true);
+ });
+ });
+});
diff --git a/src/core/server/http/security_response_headers_config.ts b/src/core/server/http/security_response_headers_config.ts
new file mode 100644
index 0000000000000..917d737d59297
--- /dev/null
+++ b/src/core/server/http/security_response_headers_config.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
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { schema, TypeOf } from '@kbn/config-schema';
+
+export const securityResponseHeadersSchema = schema.object({
+ strictTransportSecurity: schema.oneOf([schema.string(), schema.literal(null)], {
+ // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
+ defaultValue: null,
+ }),
+ xContentTypeOptions: schema.oneOf([schema.literal('nosniff'), schema.literal(null)], {
+ // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options
+ defaultValue: 'nosniff',
+ }),
+ referrerPolicy: schema.oneOf(
+ // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
+ [
+ schema.literal('no-referrer'),
+ schema.literal('no-referrer-when-downgrade'),
+ schema.literal('origin'),
+ schema.literal('origin-when-cross-origin'),
+ schema.literal('same-origin'),
+ schema.literal('strict-origin'),
+ schema.literal('strict-origin-when-cross-origin'),
+ schema.literal('unsafe-url'),
+ schema.literal(null),
+ ],
+ { defaultValue: 'no-referrer-when-downgrade' }
+ ),
+ permissionsPolicy: schema.oneOf([schema.string(), schema.literal(null)], {
+ // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy
+ // Note: Feature-Policy is superseded by Permissions-Policy; the link above is temporary until MDN releases an updated page
+ defaultValue: null,
+ }),
+ disableEmbedding: schema.boolean({ defaultValue: false }), // is used to control X-Frame-Options and CSP headers
+});
+
+/**
+ * Parses raw security header config info, returning an object with the appropriate header keys and values.
+ *
+ * @param raw
+ * @internal
+ */
+export function parseRawSecurityResponseHeadersConfig(
+ raw: TypeOf
+) {
+ const securityResponseHeaders: Record = {};
+ const { disableEmbedding } = raw;
+
+ if (raw.strictTransportSecurity) {
+ securityResponseHeaders['Strict-Transport-Security'] = raw.strictTransportSecurity;
+ }
+ if (raw.xContentTypeOptions) {
+ securityResponseHeaders['X-Content-Type-Options'] = raw.xContentTypeOptions;
+ }
+ if (raw.referrerPolicy) {
+ securityResponseHeaders['Referrer-Policy'] = raw.referrerPolicy;
+ }
+ if (raw.permissionsPolicy) {
+ securityResponseHeaders['Permissions-Policy'] = raw.permissionsPolicy;
+ }
+ if (disableEmbedding) {
+ securityResponseHeaders['X-Frame-Options'] = 'SAMEORIGIN';
+ }
+
+ return { securityResponseHeaders, disableEmbedding };
+}
diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts
index b9b877e193fbd..b3180b43d0026 100644
--- a/src/core/server/http/test_utils.ts
+++ b/src/core/server/http/test_utils.ts
@@ -7,6 +7,7 @@
*/
import { BehaviorSubject } from 'rxjs';
+import moment from 'moment';
import { REPO_ROOT } from '@kbn/dev-utils';
import { ByteSizeValue } from '@kbn/config-schema';
import { Env } from '../config';
@@ -38,11 +39,13 @@ configService.atPath.mockImplementation((path) => {
disableProtection: true,
allowlist: [],
},
+ securityResponseHeaders: {},
customResponseHeaders: {},
requestId: {
allowFromAnyIp: true,
ipAllowlist: [],
},
+ shutdownTimeout: moment.duration(30, 'seconds'),
keepaliveTimeout: 120_000,
socketTimeout: 120_000,
} as any);
diff --git a/src/core/server/index.ts b/src/core/server/index.ts
index 2c6fa74cb54a0..9fccc4b8bc1f0 100644
--- a/src/core/server/index.ts
+++ b/src/core/server/index.ts
@@ -64,6 +64,7 @@ import {
CoreUsageStats,
CoreUsageData,
CoreConfigUsageData,
+ ConfigUsageData,
CoreEnvironmentUsageData,
CoreServicesUsageData,
} from './core_usage_data';
@@ -74,6 +75,7 @@ export type {
CoreConfigUsageData,
CoreEnvironmentUsageData,
CoreServicesUsageData,
+ ConfigUsageData,
};
export { bootstrap } from './bootstrap';
@@ -236,6 +238,11 @@ export type { IRenderOptions } from './rendering';
export type {
Logger,
LoggerFactory,
+ Ecs,
+ EcsEventCategory,
+ EcsEventKind,
+ EcsEventOutcome,
+ EcsEventType,
LogMeta,
LogRecord,
LogLevel,
@@ -256,6 +263,7 @@ export type {
PluginManifest,
PluginName,
SharedGlobalConfig,
+ MakeUsageFromSchema,
} from './plugins';
export {
diff --git a/src/core/server/kibana_config.ts b/src/core/server/kibana_config.ts
index 97783a7657db5..848c51dcb69f3 100644
--- a/src/core/server/kibana_config.ts
+++ b/src/core/server/kibana_config.ts
@@ -33,4 +33,8 @@ export const config = {
autocompleteTimeout: schema.duration({ defaultValue: 1000 }),
}),
deprecations,
+ exposeToUsage: {
+ autocompleteTerminateAfter: true,
+ autocompleteTimeout: true,
+ },
};
diff --git a/src/core/server/logging/__snapshots__/logging_system.test.ts.snap b/src/core/server/logging/__snapshots__/logging_system.test.ts.snap
index 81321a3b1fe44..d74317203d78e 100644
--- a/src/core/server/logging/__snapshots__/logging_system.test.ts.snap
+++ b/src/core/server/logging/__snapshots__/logging_system.test.ts.snap
@@ -15,6 +15,9 @@ exports[`appends records via multiple appenders.: file logs 2`] = `
exports[`asLoggerFactory() only allows to create new loggers. 1`] = `
Object {
"@timestamp": "2012-01-30T22:33:22.011-05:00",
+ "ecs": Object {
+ "version": "1.9.0",
+ },
"log": Object {
"level": "TRACE",
"logger": "test.context",
@@ -29,6 +32,9 @@ Object {
exports[`asLoggerFactory() only allows to create new loggers. 2`] = `
Object {
"@timestamp": "2012-01-30T17:33:22.011-05:00",
+ "ecs": Object {
+ "version": "1.9.0",
+ },
"log": Object {
"level": "INFO",
"logger": "test.context",
@@ -44,6 +50,9 @@ Object {
exports[`asLoggerFactory() only allows to create new loggers. 3`] = `
Object {
"@timestamp": "2012-01-30T12:33:22.011-05:00",
+ "ecs": Object {
+ "version": "1.9.0",
+ },
"log": Object {
"level": "FATAL",
"logger": "test.context",
@@ -58,6 +67,9 @@ Object {
exports[`flushes memory buffer logger and switches to real logger once config is provided: buffered messages 1`] = `
Object {
"@timestamp": "2012-02-01T09:33:22.011-05:00",
+ "ecs": Object {
+ "version": "1.9.0",
+ },
"log": Object {
"level": "INFO",
"logger": "test.context",
@@ -73,6 +85,9 @@ Object {
exports[`flushes memory buffer logger and switches to real logger once config is provided: new messages 1`] = `
Object {
"@timestamp": "2012-01-31T23:33:22.011-05:00",
+ "ecs": Object {
+ "version": "1.9.0",
+ },
"log": Object {
"level": "INFO",
"logger": "test.context",
diff --git a/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts b/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts
index 52b88331a75be..faa026363ed40 100644
--- a/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts
+++ b/src/core/server/logging/appenders/rewrite/policies/meta/meta_policy.test.ts
@@ -26,12 +26,14 @@ describe('MetaRewritePolicy', () => {
describe('mode: update', () => {
it('updates existing properties in LogMeta', () => {
+ // @ts-expect-error ECS custom meta
const log = createLogRecord({ a: 'before' });
const policy = createPolicy('update', [{ path: 'a', value: 'after' }]);
expect(policy.rewrite(log).meta!.a).toBe('after');
});
it('updates nested properties in LogMeta', () => {
+ // @ts-expect-error ECS custom meta
const log = createLogRecord({ a: 'before a', b: { c: 'before b.c' }, d: [0, 1] });
const policy = createPolicy('update', [
{ path: 'a', value: 'after a' },
@@ -60,6 +62,7 @@ describe('MetaRewritePolicy', () => {
{ path: 'd', value: 'hi' },
]);
const log = createLogRecord({
+ // @ts-expect-error ECS custom meta
a: 'a',
b: 'b',
c: 'c',
@@ -80,6 +83,7 @@ describe('MetaRewritePolicy', () => {
{ path: 'a.b', value: 'foo' },
{ path: 'a.c', value: 'bar' },
]);
+ // @ts-expect-error ECS custom meta
const log = createLogRecord({ a: { b: 'existing meta' } });
const { meta } = policy.rewrite(log);
expect(meta!.a.b).toBe('foo');
@@ -106,12 +110,14 @@ describe('MetaRewritePolicy', () => {
describe('mode: remove', () => {
it('removes existing properties in LogMeta', () => {
+ // @ts-expect-error ECS custom meta
const log = createLogRecord({ a: 'goodbye' });
const policy = createPolicy('remove', [{ path: 'a' }]);
expect(policy.rewrite(log).meta!.a).toBeUndefined();
});
it('removes nested properties in LogMeta', () => {
+ // @ts-expect-error ECS custom meta
const log = createLogRecord({ a: 'a', b: { c: 'b.c' }, d: [0, 1] });
const policy = createPolicy('remove', [{ path: 'b.c' }, { path: 'd[1]' }]);
expect(policy.rewrite(log).meta).toMatchInlineSnapshot(`
@@ -127,6 +133,7 @@ describe('MetaRewritePolicy', () => {
});
it('has no effect if property does not exist', () => {
+ // @ts-expect-error ECS custom meta
const log = createLogRecord({ a: 'a' });
const policy = createPolicy('remove', [{ path: 'b' }]);
expect(policy.rewrite(log).meta).toMatchInlineSnapshot(`
diff --git a/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts b/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts
index 72a54b5012ce5..f4ce64ee65075 100644
--- a/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts
+++ b/src/core/server/logging/appenders/rewrite/rewrite_appender.test.ts
@@ -85,8 +85,8 @@ describe('RewriteAppender', () => {
const appender = new RewriteAppender(config);
appenderMocks.forEach((mock) => appender.addAppender(...mock));
- const log1 = createLogRecord({ a: 'b' });
- const log2 = createLogRecord({ c: 'd' });
+ const log1 = createLogRecord({ user_agent: { name: 'a' } });
+ const log2 = createLogRecord({ user_agent: { name: 'b' } });
appender.append(log1);
@@ -109,8 +109,8 @@ describe('RewriteAppender', () => {
const appender = new RewriteAppender(config);
appender.addAppender(...createAppenderMock('mock1'));
- const log1 = createLogRecord({ a: 'b' });
- const log2 = createLogRecord({ c: 'd' });
+ const log1 = createLogRecord({ user_agent: { name: 'a' } });
+ const log2 = createLogRecord({ user_agent: { name: 'b' } });
appender.append(log1);
diff --git a/src/core/server/logging/ecs.ts b/src/core/server/logging/ecs.ts
deleted file mode 100644
index f6db79819d819..0000000000000
--- a/src/core/server/logging/ecs.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License
- * 2.0 and the Server Side Public License, v 1; you may not use this file except
- * in compliance with, at your election, the Elastic License 2.0 or the Server
- * Side Public License, v 1.
- */
-
-/**
- * Typings for some ECS fields which core uses internally.
- * These are not a complete set of ECS typings and should not
- * be used externally; the only types included here are ones
- * currently used in core.
- *
- * @internal
- */
-export interface EcsEvent {
- /**
- * These typings were written as of ECS 1.7.0.
- * Don't change this value without checking the rest
- * of the types to conform to that ECS version.
- *
- * https://www.elastic.co/guide/en/ecs/1.7/index.html
- */
- ecs: { version: '1.7.0' };
-
- // base fields
- ['@timestamp']?: string;
- labels?: Record;
- message?: string;
- tags?: string[];
-
- // other fields
- client?: EcsClientField;
- event?: EcsEventField;
- http?: EcsHttpField;
- process?: EcsProcessField;
- url?: EcsUrlField;
- user_agent?: EcsUserAgentField;
-}
-
-/** @internal */
-export enum EcsEventKind {
- ALERT = 'alert',
- EVENT = 'event',
- METRIC = 'metric',
- STATE = 'state',
- PIPELINE_ERROR = 'pipeline_error',
- SIGNAL = 'signal',
-}
-
-/** @internal */
-export enum EcsEventCategory {
- AUTHENTICATION = 'authentication',
- CONFIGURATION = 'configuration',
- DATABASE = 'database',
- DRIVER = 'driver',
- FILE = 'file',
- HOST = 'host',
- IAM = 'iam',
- INTRUSION_DETECTION = 'intrusion_detection',
- MALWARE = 'malware',
- NETWORK = 'network',
- PACKAGE = 'package',
- PROCESS = 'process',
- WEB = 'web',
-}
-
-/** @internal */
-export enum EcsEventType {
- ACCESS = 'access',
- ADMIN = 'admin',
- ALLOWED = 'allowed',
- CHANGE = 'change',
- CONNECTION = 'connection',
- CREATION = 'creation',
- DELETION = 'deletion',
- DENIED = 'denied',
- END = 'end',
- ERROR = 'error',
- GROUP = 'group',
- INFO = 'info',
- INSTALLATION = 'installation',
- PROTOCOL = 'protocol',
- START = 'start',
- USER = 'user',
-}
-
-interface EcsEventField {
- kind?: EcsEventKind;
- category?: EcsEventCategory[];
- type?: EcsEventType;
-}
-
-interface EcsProcessField {
- uptime?: number;
-}
-
-interface EcsClientField {
- ip?: string;
-}
-
-interface EcsHttpFieldRequest {
- body?: { bytes?: number; content?: string };
- method?: string;
- mime_type?: string;
- referrer?: string;
-}
-
-interface EcsHttpFieldResponse {
- body?: { bytes?: number; content?: string };
- bytes?: number;
- status_code?: number;
-}
-
-interface EcsHttpField {
- version?: string;
- request?: EcsHttpFieldRequest;
- response?: EcsHttpFieldResponse;
-}
-
-interface EcsUrlField {
- path?: string;
- query?: string;
-}
-
-interface EcsUserAgentField {
- original?: string;
-}
diff --git a/src/core/server/logging/index.ts b/src/core/server/logging/index.ts
index cef96be54870e..9d17b289bfa4c 100644
--- a/src/core/server/logging/index.ts
+++ b/src/core/server/logging/index.ts
@@ -9,6 +9,11 @@ export { LogLevel } from '@kbn/logging';
export type {
DisposableAppender,
Appender,
+ Ecs,
+ EcsEventCategory,
+ EcsEventKind,
+ EcsEventOutcome,
+ EcsEventType,
LogRecord,
Layout,
LoggerFactory,
@@ -16,8 +21,6 @@ export type {
Logger,
LogLevelId,
} from '@kbn/logging';
-export { EcsEventType, EcsEventCategory, EcsEventKind } from './ecs';
-export type { EcsEvent } from './ecs';
export { config } from './logging_config';
export type {
LoggingConfigType,
diff --git a/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap b/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap
index 0e7ce8d0b2f3c..a131d5c8a9248 100644
--- a/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap
+++ b/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap
@@ -1,13 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`\`format()\` correctly formats record. 1`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-1\\",\\"error\\":{\\"message\\":\\"Some error message\\",\\"type\\":\\"Some error name\\",\\"stack_trace\\":\\"Some error stack\\"},\\"log\\":{\\"level\\":\\"FATAL\\",\\"logger\\":\\"context-1\\"},\\"process\\":{\\"pid\\":5355}}"`;
+exports[`\`format()\` correctly formats record. 1`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-1\\",\\"error\\":{\\"message\\":\\"Some error message\\",\\"type\\":\\"Some error name\\",\\"stack_trace\\":\\"Some error stack\\"},\\"log\\":{\\"level\\":\\"FATAL\\",\\"logger\\":\\"context-1\\"},\\"process\\":{\\"pid\\":5355}}"`;
-exports[`\`format()\` correctly formats record. 2`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-2\\",\\"log\\":{\\"level\\":\\"ERROR\\",\\"logger\\":\\"context-2\\"},\\"process\\":{\\"pid\\":5355}}"`;
+exports[`\`format()\` correctly formats record. 2`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-2\\",\\"log\\":{\\"level\\":\\"ERROR\\",\\"logger\\":\\"context-2\\"},\\"process\\":{\\"pid\\":5355}}"`;
-exports[`\`format()\` correctly formats record. 3`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-3\\",\\"log\\":{\\"level\\":\\"WARN\\",\\"logger\\":\\"context-3\\"},\\"process\\":{\\"pid\\":5355}}"`;
+exports[`\`format()\` correctly formats record. 3`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-3\\",\\"log\\":{\\"level\\":\\"WARN\\",\\"logger\\":\\"context-3\\"},\\"process\\":{\\"pid\\":5355}}"`;
-exports[`\`format()\` correctly formats record. 4`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-4\\",\\"log\\":{\\"level\\":\\"DEBUG\\",\\"logger\\":\\"context-4\\"},\\"process\\":{\\"pid\\":5355}}"`;
+exports[`\`format()\` correctly formats record. 4`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-4\\",\\"log\\":{\\"level\\":\\"DEBUG\\",\\"logger\\":\\"context-4\\"},\\"process\\":{\\"pid\\":5355}}"`;
-exports[`\`format()\` correctly formats record. 5`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-5\\",\\"log\\":{\\"level\\":\\"INFO\\",\\"logger\\":\\"context-5\\"},\\"process\\":{\\"pid\\":5355}}"`;
+exports[`\`format()\` correctly formats record. 5`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-5\\",\\"log\\":{\\"level\\":\\"INFO\\",\\"logger\\":\\"context-5\\"},\\"process\\":{\\"pid\\":5355}}"`;
-exports[`\`format()\` correctly formats record. 6`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-6\\",\\"log\\":{\\"level\\":\\"TRACE\\",\\"logger\\":\\"context-6\\"},\\"process\\":{\\"pid\\":5355}}"`;
+exports[`\`format()\` correctly formats record. 6`] = `"{\\"ecs\\":{\\"version\\":\\"1.9.0\\"},\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-6\\",\\"log\\":{\\"level\\":\\"TRACE\\",\\"logger\\":\\"context-6\\"},\\"process\\":{\\"pid\\":5355}}"`;
diff --git a/src/core/server/logging/layouts/json_layout.test.ts b/src/core/server/logging/layouts/json_layout.test.ts
index e55f69daab110..e76e3fb4402bb 100644
--- a/src/core/server/logging/layouts/json_layout.test.ts
+++ b/src/core/server/logging/layouts/json_layout.test.ts
@@ -94,6 +94,7 @@ test('`format()` correctly formats record with meta-data', () => {
})
)
).toStrictEqual({
+ ecs: { version: '1.9.0' },
'@timestamp': '2012-02-01T09:30:22.011-05:00',
log: {
level: 'DEBUG',
@@ -135,6 +136,7 @@ test('`format()` correctly formats error record with meta-data', () => {
})
)
).toStrictEqual({
+ ecs: { version: '1.9.0' },
'@timestamp': '2012-02-01T09:30:22.011-05:00',
log: {
level: 'DEBUG',
@@ -156,7 +158,39 @@ test('`format()` correctly formats error record with meta-data', () => {
});
});
-test('format() meta can override @timestamp', () => {
+test('format() meta can merge override logs', () => {
+ const layout = new JsonLayout();
+ expect(
+ JSON.parse(
+ layout.format({
+ timestamp,
+ message: 'foo',
+ level: LogLevel.Error,
+ context: 'bar',
+ pid: 3,
+ meta: {
+ log: {
+ kbn_custom_field: 'hello',
+ },
+ },
+ })
+ )
+ ).toStrictEqual({
+ ecs: { version: '1.9.0' },
+ '@timestamp': '2012-02-01T09:30:22.011-05:00',
+ message: 'foo',
+ log: {
+ level: 'ERROR',
+ logger: 'bar',
+ kbn_custom_field: 'hello',
+ },
+ process: {
+ pid: 3,
+ },
+ });
+});
+
+test('format() meta can not override message', () => {
const layout = new JsonLayout();
expect(
JSON.parse(
@@ -167,12 +201,13 @@ test('format() meta can override @timestamp', () => {
context: 'bar',
pid: 3,
meta: {
- '@timestamp': '2099-05-01T09:30:22.011-05:00',
+ message: 'baz',
},
})
)
).toStrictEqual({
- '@timestamp': '2099-05-01T09:30:22.011-05:00',
+ ecs: { version: '1.9.0' },
+ '@timestamp': '2012-02-01T09:30:22.011-05:00',
message: 'foo',
log: {
level: 'DEBUG',
@@ -184,30 +219,60 @@ test('format() meta can override @timestamp', () => {
});
});
-test('format() meta can merge override logs', () => {
+test('format() meta can not override ecs version', () => {
const layout = new JsonLayout();
expect(
JSON.parse(
layout.format({
+ message: 'foo',
timestamp,
+ level: LogLevel.Debug,
+ context: 'bar',
+ pid: 3,
+ meta: {
+ message: 'baz',
+ },
+ })
+ )
+ ).toStrictEqual({
+ ecs: { version: '1.9.0' },
+ '@timestamp': '2012-02-01T09:30:22.011-05:00',
+ message: 'foo',
+ log: {
+ level: 'DEBUG',
+ logger: 'bar',
+ },
+ process: {
+ pid: 3,
+ },
+ });
+});
+
+test('format() meta can not override logger or level', () => {
+ const layout = new JsonLayout();
+ expect(
+ JSON.parse(
+ layout.format({
message: 'foo',
- level: LogLevel.Error,
+ timestamp,
+ level: LogLevel.Debug,
context: 'bar',
pid: 3,
meta: {
log: {
- kbn_custom_field: 'hello',
+ level: 'IGNORE',
+ logger: 'me',
},
},
})
)
).toStrictEqual({
+ ecs: { version: '1.9.0' },
'@timestamp': '2012-02-01T09:30:22.011-05:00',
message: 'foo',
log: {
- level: 'ERROR',
+ level: 'DEBUG',
logger: 'bar',
- kbn_custom_field: 'hello',
},
process: {
pid: 3,
@@ -215,29 +280,28 @@ test('format() meta can merge override logs', () => {
});
});
-test('format() meta can override log level objects', () => {
+test('format() meta can not override timestamp', () => {
const layout = new JsonLayout();
expect(
JSON.parse(
layout.format({
- timestamp,
- context: '123',
message: 'foo',
- level: LogLevel.Error,
+ timestamp,
+ level: LogLevel.Debug,
+ context: 'bar',
pid: 3,
meta: {
- log: {
- level: 'FATAL',
- },
+ '@timestamp': '2099-02-01T09:30:22.011-05:00',
},
})
)
).toStrictEqual({
+ ecs: { version: '1.9.0' },
'@timestamp': '2012-02-01T09:30:22.011-05:00',
message: 'foo',
log: {
- level: 'FATAL',
- logger: '123',
+ level: 'DEBUG',
+ logger: 'bar',
},
process: {
pid: 3,
diff --git a/src/core/server/logging/layouts/json_layout.ts b/src/core/server/logging/layouts/json_layout.ts
index bb8423f8240af..add88cc01b6d2 100644
--- a/src/core/server/logging/layouts/json_layout.ts
+++ b/src/core/server/logging/layouts/json_layout.ts
@@ -9,7 +9,7 @@
import moment from 'moment-timezone';
import { merge } from '@kbn/std';
import { schema } from '@kbn/config-schema';
-import { LogRecord, Layout } from '@kbn/logging';
+import { Ecs, LogRecord, Layout } from '@kbn/logging';
const { literal, object } = schema;
@@ -42,7 +42,8 @@ export class JsonLayout implements Layout {
}
public format(record: LogRecord): string {
- const log = {
+ const log: Ecs = {
+ ecs: { version: '1.9.0' },
'@timestamp': moment(record.timestamp).format('YYYY-MM-DDTHH:mm:ss.SSSZ'),
message: record.message,
error: JsonLayout.errorToSerializableObject(record.error),
@@ -54,7 +55,8 @@ export class JsonLayout implements Layout {
pid: record.pid,
},
};
- const output = record.meta ? merge(log, record.meta) : log;
+ const output = record.meta ? merge({ ...record.meta }, log) : log;
+
return JSON.stringify(output);
}
}
diff --git a/src/core/server/logging/logger.test.ts b/src/core/server/logging/logger.test.ts
index b7f224e73cb8b..c57ce2563ca3d 100644
--- a/src/core/server/logging/logger.test.ts
+++ b/src/core/server/logging/logger.test.ts
@@ -45,6 +45,7 @@ test('`trace()` correctly forms `LogRecord` and passes it to all appenders.', ()
});
}
+ // @ts-expect-error ECS custom meta
logger.trace('message-2', { trace: true });
for (const appenderMock of appenderMocks) {
expect(appenderMock.append).toHaveBeenCalledTimes(2);
@@ -75,6 +76,7 @@ test('`debug()` correctly forms `LogRecord` and passes it to all appenders.', ()
});
}
+ // @ts-expect-error ECS custom meta
logger.debug('message-2', { debug: true });
for (const appenderMock of appenderMocks) {
expect(appenderMock.append).toHaveBeenCalledTimes(2);
@@ -105,6 +107,7 @@ test('`info()` correctly forms `LogRecord` and passes it to all appenders.', ()
});
}
+ // @ts-expect-error ECS custom meta
logger.info('message-2', { info: true });
for (const appenderMock of appenderMocks) {
expect(appenderMock.append).toHaveBeenCalledTimes(2);
@@ -150,6 +153,7 @@ test('`warn()` correctly forms `LogRecord` and passes it to all appenders.', ()
});
}
+ // @ts-expect-error ECS custom meta
logger.warn('message-3', { warn: true });
for (const appenderMock of appenderMocks) {
expect(appenderMock.append).toHaveBeenCalledTimes(3);
@@ -195,6 +199,7 @@ test('`error()` correctly forms `LogRecord` and passes it to all appenders.', ()
});
}
+ // @ts-expect-error ECS custom meta
logger.error('message-3', { error: true });
for (const appenderMock of appenderMocks) {
expect(appenderMock.append).toHaveBeenCalledTimes(3);
@@ -240,6 +245,7 @@ test('`fatal()` correctly forms `LogRecord` and passes it to all appenders.', ()
});
}
+ // @ts-expect-error ECS custom meta
logger.fatal('message-3', { fatal: true });
for (const appenderMock of appenderMocks) {
expect(appenderMock.append).toHaveBeenCalledTimes(3);
diff --git a/src/core/server/logging/logger.ts b/src/core/server/logging/logger.ts
index 4ba334cec2fb9..e025c28a88f0e 100644
--- a/src/core/server/logging/logger.ts
+++ b/src/core/server/logging/logger.ts
@@ -21,28 +21,28 @@ export class BaseLogger implements Logger {
private readonly factory: LoggerFactory
) {}
- public trace(message: string, meta?: LogMeta): void {
- this.log(this.createLogRecord(LogLevel.Trace, message, meta));
+ public trace(message: string, meta?: Meta): void {
+ this.log(this.createLogRecord(LogLevel.Trace, message, meta));
}
- public debug(message: string, meta?: LogMeta): void {
- this.log(this.createLogRecord(LogLevel.Debug, message, meta));
+ public debug(message: string, meta?: Meta): void {
+ this.log(this.createLogRecord(LogLevel.Debug, message, meta));
}
- public info(message: string, meta?: LogMeta): void {
- this.log(this.createLogRecord(LogLevel.Info, message, meta));
+ public info(message: string, meta?: Meta): void {
+ this.log(this.createLogRecord(LogLevel.Info, message, meta));
}
- public warn(errorOrMessage: string | Error, meta?: LogMeta): void {
- this.log(this.createLogRecord(LogLevel.Warn, errorOrMessage, meta));
+ public warn(errorOrMessage: string | Error, meta?: Meta): void {
+ this.log(this.createLogRecord(LogLevel.Warn, errorOrMessage, meta));
}
- public error(errorOrMessage: string | Error, meta?: LogMeta): void {
- this.log(this.createLogRecord(LogLevel.Error, errorOrMessage, meta));
+ public error(errorOrMessage: string | Error, meta?: Meta): void {
+ this.log(this.createLogRecord(LogLevel.Error, errorOrMessage, meta));
}
- public fatal(errorOrMessage: string | Error, meta?: LogMeta): void {
- this.log(this.createLogRecord(LogLevel.Fatal, errorOrMessage, meta));
+ public fatal(errorOrMessage: string | Error, meta?: Meta): void {
+ this.log(this.createLogRecord(LogLevel.Fatal, errorOrMessage, meta));
}
public log(record: LogRecord) {
@@ -59,10 +59,10 @@ export class BaseLogger implements Logger {
return this.factory.get(...[this.context, ...childContextPaths]);
}
- private createLogRecord(
+ private createLogRecord(
level: LogLevel,
errorOrMessage: string | Error,
- meta?: LogMeta
+ meta?: Meta
): LogRecord {
if (isError(errorOrMessage)) {
return {
diff --git a/src/core/server/logging/logging_system.test.ts b/src/core/server/logging/logging_system.test.ts
index b67be384732cb..9c4313bc0c49d 100644
--- a/src/core/server/logging/logging_system.test.ts
+++ b/src/core/server/logging/logging_system.test.ts
@@ -49,6 +49,7 @@ test('uses default memory buffer logger until config is provided', () => {
// We shouldn't create new buffer appender for another context name.
const anotherLogger = system.get('test', 'context2');
+ // @ts-expect-error ECS custom meta
anotherLogger.fatal('fatal message', { some: 'value' });
expect(bufferAppendSpy).toHaveBeenCalledTimes(2);
@@ -62,6 +63,7 @@ test('flushes memory buffer logger and switches to real logger once config is pr
const logger = system.get('test', 'context');
logger.trace('buffered trace message');
+ // @ts-expect-error ECS custom meta
logger.info('buffered info message', { some: 'value' });
logger.fatal('buffered fatal message');
@@ -159,6 +161,7 @@ test('attaches appenders to appenders that declare refs', async () => {
);
const testLogger = system.get('tests');
+ // @ts-expect-error ECS custom meta
testLogger.warn('This message goes to a test context.', { a: 'hi', b: 'remove me' });
expect(mockConsoleLog).toHaveBeenCalledTimes(1);
@@ -233,6 +236,7 @@ test('asLoggerFactory() only allows to create new loggers.', async () => {
);
logger.trace('buffered trace message');
+ // @ts-expect-error ECS custom meta
logger.info('buffered info message', { some: 'value' });
logger.fatal('buffered fatal message');
diff --git a/src/core/server/metrics/logging/get_ops_metrics_log.test.ts b/src/core/server/metrics/logging/get_ops_metrics_log.test.ts
index 014d3ae258823..e535b9babf92b 100644
--- a/src/core/server/metrics/logging/get_ops_metrics_log.test.ts
+++ b/src/core/server/metrics/logging/get_ops_metrics_log.test.ts
@@ -66,7 +66,7 @@ describe('getEcsOpsMetricsLog', () => {
it('correctly formats process uptime', () => {
const logMeta = getEcsOpsMetricsLog(createMockOpsMetrics(testMetrics));
- expect(logMeta.process!.uptime).toEqual(1);
+ expect(logMeta.meta.process!.uptime).toEqual(1);
});
it('excludes values from the message if unavailable', () => {
@@ -80,44 +80,40 @@ describe('getEcsOpsMetricsLog', () => {
expect(logMeta.message).toMatchInlineSnapshot(`""`);
});
- it('specifies correct ECS version', () => {
- const logMeta = getEcsOpsMetricsLog(createBaseOpsMetrics());
- expect(logMeta.ecs.version).toBe('1.7.0');
- });
-
it('provides an ECS-compatible response', () => {
const logMeta = getEcsOpsMetricsLog(createBaseOpsMetrics());
expect(logMeta).toMatchInlineSnapshot(`
Object {
- "ecs": Object {
- "version": "1.7.0",
- },
- "event": Object {
- "category": Array [
- "process",
- "host",
- ],
- "kind": "metric",
- "type": "info",
- },
- "host": Object {
- "os": Object {
- "load": Object {
- "15m": 1,
- "1m": 1,
- "5m": 1,
+ "message": "memory: 1.0B load: [1.00,1.00,1.00] delay: 1.000",
+ "meta": Object {
+ "event": Object {
+ "category": Array [
+ "process",
+ "host",
+ ],
+ "kind": "metric",
+ "type": Array [
+ "info",
+ ],
+ },
+ "host": Object {
+ "os": Object {
+ "load": Object {
+ "15m": 1,
+ "1m": 1,
+ "5m": 1,
+ },
},
},
- },
- "message": "memory: 1.0B load: [1.00,1.00,1.00] delay: 1.000",
- "process": Object {
- "eventLoopDelay": 1,
- "memory": Object {
- "heap": Object {
- "usedInBytes": 1,
+ "process": Object {
+ "eventLoopDelay": 1,
+ "memory": Object {
+ "heap": Object {
+ "usedInBytes": 1,
+ },
},
+ "uptime": 0,
},
- "uptime": 0,
},
}
`);
@@ -125,8 +121,8 @@ describe('getEcsOpsMetricsLog', () => {
it('logs ECS fields in the log meta', () => {
const logMeta = getEcsOpsMetricsLog(createBaseOpsMetrics());
- expect(logMeta.event!.kind).toBe('metric');
- expect(logMeta.event!.category).toEqual(expect.arrayContaining(['process', 'host']));
- expect(logMeta.event!.type).toBe('info');
+ expect(logMeta.meta.event!.kind).toBe('metric');
+ expect(logMeta.meta.event!.category).toEqual(expect.arrayContaining(['process', 'host']));
+ expect(logMeta.meta.event!.type).toEqual(expect.arrayContaining(['info']));
});
});
diff --git a/src/core/server/metrics/logging/get_ops_metrics_log.ts b/src/core/server/metrics/logging/get_ops_metrics_log.ts
index 02c3ad312c7dd..7e13f35889ec7 100644
--- a/src/core/server/metrics/logging/get_ops_metrics_log.ts
+++ b/src/core/server/metrics/logging/get_ops_metrics_log.ts
@@ -7,16 +7,15 @@
*/
import numeral from '@elastic/numeral';
-import { EcsEvent, EcsEventKind, EcsEventCategory, EcsEventType } from '../../logging';
+import { LogMeta } from '@kbn/logging';
import { OpsMetrics } from '..';
-const ECS_VERSION = '1.7.0';
/**
* Converts ops metrics into ECS-compliant `LogMeta` for logging
*
* @internal
*/
-export function getEcsOpsMetricsLog(metrics: OpsMetrics): EcsEvent {
+export function getEcsOpsMetricsLog(metrics: OpsMetrics) {
const { process, os } = metrics;
const processMemoryUsedInBytes = process?.memory?.heap?.used_in_bytes;
const processMemoryUsedInBytesMsg = processMemoryUsedInBytes
@@ -51,13 +50,11 @@ export function getEcsOpsMetricsLog(metrics: OpsMetrics): EcsEvent {
})}] `
: '';
- return {
- ecs: { version: ECS_VERSION },
- message: `${processMemoryUsedInBytesMsg}${uptimeValMsg}${loadValsMsg}${eventLoopDelayValMsg}`,
+ const meta: LogMeta = {
event: {
- kind: EcsEventKind.METRIC,
- category: [EcsEventCategory.PROCESS, EcsEventCategory.HOST],
- type: EcsEventType.INFO,
+ kind: 'metric',
+ category: ['process', 'host'],
+ type: ['info'],
},
process: {
uptime: uptimeVal,
@@ -71,8 +68,14 @@ export function getEcsOpsMetricsLog(metrics: OpsMetrics): EcsEvent {
},
host: {
os: {
+ // @ts-expect-error custom fields not yet part of ECS
load: loadEntries,
},
},
};
+
+ return {
+ message: `${processMemoryUsedInBytesMsg}${uptimeValMsg}${loadValsMsg}${eventLoopDelayValMsg}`,
+ meta,
+ };
}
diff --git a/src/core/server/metrics/metrics_service.test.ts b/src/core/server/metrics/metrics_service.test.ts
index 4fbca5addda11..d7de41fd7ccf7 100644
--- a/src/core/server/metrics/metrics_service.test.ts
+++ b/src/core/server/metrics/metrics_service.test.ts
@@ -182,16 +182,15 @@ describe('MetricsService', () => {
Array [
"",
Object {
- "ecs": Object {
- "version": "1.7.0",
- },
"event": Object {
"category": Array [
"process",
"host",
],
"kind": "metric",
- "type": "info",
+ "type": Array [
+ "info",
+ ],
},
"host": Object {
"os": Object {
diff --git a/src/core/server/metrics/metrics_service.ts b/src/core/server/metrics/metrics_service.ts
index 382848e0a80c3..78e4dd98f93d6 100644
--- a/src/core/server/metrics/metrics_service.ts
+++ b/src/core/server/metrics/metrics_service.ts
@@ -73,7 +73,7 @@ export class MetricsService
private async refreshMetrics() {
const metrics = await this.metricsCollector!.collect();
- const { message, ...meta } = getEcsOpsMetricsLog(metrics);
+ const { message, meta } = getEcsOpsMetricsLog(metrics);
this.opsMetricsLogger.debug(message!, meta);
this.metricsCollector!.reset();
this.metrics$.next(metrics);
diff --git a/src/core/server/plugins/plugins_service.mock.ts b/src/core/server/plugins/plugins_service.mock.ts
index 1d0ed7cb09299..f4f2263a1bdb0 100644
--- a/src/core/server/plugins/plugins_service.mock.ts
+++ b/src/core/server/plugins/plugins_service.mock.ts
@@ -19,6 +19,7 @@ const createStartContractMock = () => ({ contracts: new Map() });
const createServiceMock = (): PluginsServiceMock => ({
discover: jest.fn(),
+ getExposedPluginConfigsToUsage: jest.fn(),
setup: jest.fn().mockResolvedValue(createSetupContractMock()),
start: jest.fn().mockResolvedValue(createStartContractMock()),
stop: jest.fn(),
diff --git a/src/core/server/plugins/plugins_service.test.ts b/src/core/server/plugins/plugins_service.test.ts
index 6bf7a1fadb4d3..5c50df07dc697 100644
--- a/src/core/server/plugins/plugins_service.test.ts
+++ b/src/core/server/plugins/plugins_service.test.ts
@@ -78,7 +78,7 @@ const createPlugin = (
manifest: {
id,
version,
- configPath: `${configPath}${disabled ? '-disabled' : ''}`,
+ configPath: disabled ? configPath.concat('-disabled') : configPath,
kibanaVersion,
requiredPlugins,
requiredBundles,
@@ -374,7 +374,6 @@ describe('PluginsService', () => {
expect(mockPluginSystem.addPlugin).toHaveBeenCalledTimes(2);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(firstPlugin);
expect(mockPluginSystem.addPlugin).toHaveBeenCalledWith(secondPlugin);
-
expect(mockDiscover).toHaveBeenCalledTimes(1);
expect(mockDiscover).toHaveBeenCalledWith(
{
@@ -472,6 +471,88 @@ describe('PluginsService', () => {
expect(pluginPaths).toEqual(['/plugin-A-path', '/plugin-B-path']);
});
+
+ it('ppopulates pluginConfigUsageDescriptors with plugins exposeToUsage property', async () => {
+ const pluginA = createPlugin('plugin-with-expose-usage', {
+ path: 'plugin-with-expose-usage',
+ configPath: 'pathA',
+ });
+
+ jest.doMock(
+ join('plugin-with-expose-usage', 'server'),
+ () => ({
+ config: {
+ exposeToUsage: {
+ test: true,
+ nested: {
+ prop: true,
+ },
+ },
+ schema: schema.maybe(schema.any()),
+ },
+ }),
+ {
+ virtual: true,
+ }
+ );
+
+ const pluginB = createPlugin('plugin-with-array-configPath', {
+ path: 'plugin-with-array-configPath',
+ configPath: ['plugin', 'pathB'],
+ });
+
+ jest.doMock(
+ join('plugin-with-array-configPath', 'server'),
+ () => ({
+ config: {
+ exposeToUsage: {
+ test: true,
+ },
+ schema: schema.maybe(schema.any()),
+ },
+ }),
+ {
+ virtual: true,
+ }
+ );
+
+ jest.doMock(
+ join('plugin-without-expose', 'server'),
+ () => ({
+ config: {
+ schema: schema.maybe(schema.any()),
+ },
+ }),
+ {
+ virtual: true,
+ }
+ );
+
+ const pluginC = createPlugin('plugin-without-expose', {
+ path: 'plugin-without-expose',
+ configPath: 'pathC',
+ });
+
+ mockDiscover.mockReturnValue({
+ error$: from([]),
+ plugin$: from([pluginA, pluginB, pluginC]),
+ });
+
+ await pluginsService.discover({ environment: environmentSetup });
+
+ // eslint-disable-next-line dot-notation
+ expect(pluginsService['pluginConfigUsageDescriptors']).toMatchInlineSnapshot(`
+ Map {
+ "pathA" => Object {
+ "nested.prop": true,
+ "test": true,
+ },
+ "plugin.pathB" => Object {
+ "test": true,
+ },
+ }
+ `);
+ });
});
describe('#generateUiPluginsConfigs()', () => {
@@ -624,6 +705,20 @@ describe('PluginsService', () => {
});
});
+ describe('#getExposedPluginConfigsToUsage', () => {
+ it('returns pluginConfigUsageDescriptors', () => {
+ // eslint-disable-next-line dot-notation
+ pluginsService['pluginConfigUsageDescriptors'].set('test', { enabled: true });
+ expect(pluginsService.getExposedPluginConfigsToUsage()).toMatchInlineSnapshot(`
+ Map {
+ "test" => Object {
+ "enabled": true,
+ },
+ }
+ `);
+ });
+ });
+
describe('#stop()', () => {
it('`stop` stops plugins system', async () => {
await pluginsService.stop();
diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts
index 09be40ecaf2a2..547fe00fdb1cf 100644
--- a/src/core/server/plugins/plugins_service.ts
+++ b/src/core/server/plugins/plugins_service.ts
@@ -9,7 +9,7 @@
import Path from 'path';
import { Observable } from 'rxjs';
import { filter, first, map, mergeMap, tap, toArray } from 'rxjs/operators';
-import { pick } from '@kbn/std';
+import { pick, getFlattenedObject } from '@kbn/std';
import { CoreService } from '../../types';
import { CoreContext } from '../core_context';
@@ -75,6 +75,7 @@ export class PluginsService implements CoreService;
private readonly pluginConfigDescriptors = new Map();
private readonly uiPluginInternalInfo = new Map();
+ private readonly pluginConfigUsageDescriptors = new Map>();
constructor(private readonly coreContext: CoreContext) {
this.log = coreContext.logger.get('plugins-service');
@@ -109,6 +110,10 @@ export class PluginsService implements CoreService = T | undefined;
+
/**
* Dedicated type for plugin configuration schema.
*
@@ -70,8 +72,39 @@ export interface PluginConfigDescriptor {
* {@link PluginConfigSchema}
*/
schema: PluginConfigSchema;
+ /**
+ * Expose non-default configs to usage collection to be sent via telemetry.
+ * set a config to `true` to report the actual changed config value.
+ * set a config to `false` to report the changed config value as [redacted].
+ *
+ * All changed configs except booleans and numbers will be reported
+ * as [redacted] unless otherwise specified.
+ *
+ * {@link MakeUsageFromSchema}
+ */
+ exposeToUsage?: MakeUsageFromSchema;
}
+/**
+ * List of configuration values that will be exposed to usage collection.
+ * If parent node or actual config path is set to `true` then the actual value
+ * of these configs will be reoprted.
+ * If parent node or actual config path is set to `false` then the config
+ * will be reported as [redacted].
+ *
+ * @public
+ */
+export type MakeUsageFromSchema = {
+ [Key in keyof T]?: T[Key] extends Maybe