Add UiSettings validation & Kibana default route redirection (#59694)

* add schema to ui settings params

* add validation for defaults and overrides

* validate in ui settings client

* ui settings routes validation

* clean up tests

* use schema for defaultRoutes

* move URL redirection to NP

* fix spaces test

* update docs

* update kbn pm

* fix karma test

* fix tests

* address comments

* get rid of getDEfaultRoute

* regen docs

* fix tests

* fix enter-spaces test

* validate on relative url format

* update i18n

* fix enter-spoace test

* move relative url validation to utils

* add CoreApp containing application logic

* extract public uiSettings params in a separate type

* make schema required

* update docs
This commit is contained in:
Mikhail Shustov 2020-03-16 14:30:20 +01:00 committed by GitHub
parent 271c9597be
commit dd7531deb4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 730 additions and 378 deletions

View file

@ -9,5 +9,5 @@ Gets the metadata about all uiSettings, including the type, default value, and u
<b>Signature:</b>
```typescript
getAll: () => Readonly<Record<string, UiSettingsParams & UserProvidedValues>>;
getAll: () => Readonly<Record<string, PublicUiSettingsParams & UserProvidedValues>>;
```

View file

@ -18,7 +18,7 @@ export interface IUiSettingsClient
| --- | --- | --- |
| [get](./kibana-plugin-core-public.iuisettingsclient.get.md) | <code>&lt;T = any&gt;(key: string, defaultOverride?: T) =&gt; T</code> | Gets the value for a specific uiSetting. If this setting has no user-defined value then the <code>defaultOverride</code> parameter is returned (and parsed if setting is of type "json" or "number). If the parameter is not defined and the key is not registered by any plugin then an error is thrown, otherwise reads the default value defined by a plugin. |
| [get$](./kibana-plugin-core-public.iuisettingsclient.get_.md) | <code>&lt;T = any&gt;(key: string, defaultOverride?: T) =&gt; Observable&lt;T&gt;</code> | Gets an observable of the current value for a config key, and all updates to that config key in the future. Providing a <code>defaultOverride</code> argument behaves the same as it does in \#get() |
| [getAll](./kibana-plugin-core-public.iuisettingsclient.getall.md) | <code>() =&gt; Readonly&lt;Record&lt;string, UiSettingsParams &amp; UserProvidedValues&gt;&gt;</code> | Gets the metadata about all uiSettings, including the type, default value, and user value for each key. |
| [getAll](./kibana-plugin-core-public.iuisettingsclient.getall.md) | <code>() =&gt; Readonly&lt;Record&lt;string, PublicUiSettingsParams &amp; UserProvidedValues&gt;&gt;</code> | Gets the metadata about all uiSettings, including the type, default value, and user value for each key. |
| [getSaved$](./kibana-plugin-core-public.iuisettingsclient.getsaved_.md) | <code>&lt;T = any&gt;() =&gt; Observable&lt;{</code><br/><code> key: string;</code><br/><code> newValue: T;</code><br/><code> oldValue: T;</code><br/><code> }&gt;</code> | Returns an Observable that notifies subscribers of each update to the uiSettings, including the key, newValue, and oldValue of the setting that changed. |
| [getUpdate$](./kibana-plugin-core-public.iuisettingsclient.getupdate_.md) | <code>&lt;T = any&gt;() =&gt; Observable&lt;{</code><br/><code> key: string;</code><br/><code> newValue: T;</code><br/><code> oldValue: T;</code><br/><code> }&gt;</code> | Returns an Observable that notifies subscribers of each update to the uiSettings, including the key, newValue, and oldValue of the setting that changed. |
| [getUpdateErrors$](./kibana-plugin-core-public.iuisettingsclient.getupdateerrors_.md) | <code>() =&gt; Observable&lt;Error&gt;</code> | Returns an Observable that notifies subscribers of each error while trying to update the settings, containing the actual Error class. |

View file

@ -147,6 +147,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [MountPoint](./kibana-plugin-core-public.mountpoint.md) | A function that should mount DOM content inside the provided container element and return a handler to unmount it. |
| [PluginInitializer](./kibana-plugin-core-public.plugininitializer.md) | The <code>plugin</code> export at the root of a plugin's <code>public</code> directory should conform to this interface. |
| [PluginOpaqueId](./kibana-plugin-core-public.pluginopaqueid.md) | |
| [PublicUiSettingsParams](./kibana-plugin-core-public.publicuisettingsparams.md) | A sub-set of [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) exposed to the client-side. |
| [RecursiveReadonly](./kibana-plugin-core-public.recursivereadonly.md) | |
| [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value |
| [SavedObjectAttributeSingle](./kibana-plugin-core-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [PublicUiSettingsParams](./kibana-plugin-core-public.publicuisettingsparams.md)
## PublicUiSettingsParams type
A sub-set of [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) exposed to the client-side.
<b>Signature:</b>
```typescript
export declare type PublicUiSettingsParams = Omit<UiSettingsParams, 'schema'>;
```

View file

@ -9,7 +9,7 @@ UiSettings parameters defined by the plugins.
<b>Signature:</b>
```typescript
export interface UiSettingsParams
export interface UiSettingsParams<T = unknown>
```
## Properties
@ -24,7 +24,8 @@ export interface UiSettingsParams
| [options](./kibana-plugin-core-public.uisettingsparams.options.md) | <code>string[]</code> | array of permitted values for this setting |
| [readonly](./kibana-plugin-core-public.uisettingsparams.readonly.md) | <code>boolean</code> | a flag indicating that value cannot be changed |
| [requiresPageReload](./kibana-plugin-core-public.uisettingsparams.requirespagereload.md) | <code>boolean</code> | a flag indicating whether new value applying requires page reloading |
| [schema](./kibana-plugin-core-public.uisettingsparams.schema.md) | <code>Type&lt;T&gt;</code> | |
| [type](./kibana-plugin-core-public.uisettingsparams.type.md) | <code>UiSettingsType</code> | defines a type of UI element [UiSettingsType](./kibana-plugin-core-public.uisettingstype.md) |
| [validation](./kibana-plugin-core-public.uisettingsparams.validation.md) | <code>ImageValidation &#124; StringValidation</code> | |
| [value](./kibana-plugin-core-public.uisettingsparams.value.md) | <code>SavedObjectAttribute</code> | default value to fall back to if a user doesn't provide any |
| [value](./kibana-plugin-core-public.uisettingsparams.value.md) | <code>T</code> | default value to fall back to if a user doesn't provide any |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) &gt; [schema](./kibana-plugin-core-public.uisettingsparams.schema.md)
## UiSettingsParams.schema property
<b>Signature:</b>
```typescript
schema: Type<T>;
```

View file

@ -9,5 +9,5 @@ default value to fall back to if a user doesn't provide any
<b>Signature:</b>
```typescript
value?: SavedObjectAttribute;
value?: T;
```

View file

@ -9,5 +9,5 @@ Returns registered uiSettings values [UiSettingsParams](./kibana-plugin-core-ser
<b>Signature:</b>
```typescript
getRegistered: () => Readonly<Record<string, UiSettingsParams>>;
getRegistered: () => Readonly<Record<string, PublicUiSettingsParams>>;
```

View file

@ -18,7 +18,7 @@ export interface IUiSettingsClient
| --- | --- | --- |
| [get](./kibana-plugin-core-server.iuisettingsclient.get.md) | <code>&lt;T = any&gt;(key: string) =&gt; Promise&lt;T&gt;</code> | Retrieves uiSettings values set by the user with fallbacks to default values if not specified. |
| [getAll](./kibana-plugin-core-server.iuisettingsclient.getall.md) | <code>&lt;T = any&gt;() =&gt; Promise&lt;Record&lt;string, T&gt;&gt;</code> | Retrieves a set of all uiSettings values set by the user with fallbacks to default values if not specified. |
| [getRegistered](./kibana-plugin-core-server.iuisettingsclient.getregistered.md) | <code>() =&gt; Readonly&lt;Record&lt;string, UiSettingsParams&gt;&gt;</code> | Returns registered uiSettings values [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) |
| [getRegistered](./kibana-plugin-core-server.iuisettingsclient.getregistered.md) | <code>() =&gt; Readonly&lt;Record&lt;string, PublicUiSettingsParams&gt;&gt;</code> | Returns registered uiSettings values [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) |
| [getUserProvided](./kibana-plugin-core-server.iuisettingsclient.getuserprovided.md) | <code>&lt;T = any&gt;() =&gt; Promise&lt;Record&lt;string, UserProvidedValues&lt;T&gt;&gt;&gt;</code> | Retrieves a set of all uiSettings values set by the user. |
| [isOverridden](./kibana-plugin-core-server.iuisettingsclient.isoverridden.md) | <code>(key: string) =&gt; boolean</code> | Shows whether the uiSettings value set by the user. |
| [remove](./kibana-plugin-core-server.iuisettingsclient.remove.md) | <code>(key: string) =&gt; Promise&lt;void&gt;</code> | Removes uiSettings value by key. |

View file

@ -232,6 +232,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [PluginInitializer](./kibana-plugin-core-server.plugininitializer.md) | The <code>plugin</code> export at the root of a plugin's <code>server</code> directory should conform to this interface. |
| [PluginName](./kibana-plugin-core-server.pluginname.md) | Dedicated type for plugin name/id that is supposed to make Map/Set/Arrays that use it as a key or value more obvious. |
| [PluginOpaqueId](./kibana-plugin-core-server.pluginopaqueid.md) | |
| [PublicUiSettingsParams](./kibana-plugin-core-server.publicuisettingsparams.md) | A sub-set of [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) exposed to the client-side. |
| [RecursiveReadonly](./kibana-plugin-core-server.recursivereadonly.md) | |
| [RedirectResponseOptions](./kibana-plugin-core-server.redirectresponseoptions.md) | HTTP response parameters for redirection response |
| [RequestHandler](./kibana-plugin-core-server.requesthandler.md) | A function executed when route path matched requested resource path. Request handler is expected to return a result of one of [KibanaResponseFactory](./kibana-plugin-core-server.kibanaresponsefactory.md) functions. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [PublicUiSettingsParams](./kibana-plugin-core-server.publicuisettingsparams.md)
## PublicUiSettingsParams type
A sub-set of [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) exposed to the client-side.
<b>Signature:</b>
```typescript
export declare type PublicUiSettingsParams = Omit<UiSettingsParams, 'schema'>;
```

View file

@ -9,7 +9,7 @@ UiSettings parameters defined by the plugins.
<b>Signature:</b>
```typescript
export interface UiSettingsParams
export interface UiSettingsParams<T = unknown>
```
## Properties
@ -24,7 +24,8 @@ export interface UiSettingsParams
| [options](./kibana-plugin-core-server.uisettingsparams.options.md) | <code>string[]</code> | array of permitted values for this setting |
| [readonly](./kibana-plugin-core-server.uisettingsparams.readonly.md) | <code>boolean</code> | a flag indicating that value cannot be changed |
| [requiresPageReload](./kibana-plugin-core-server.uisettingsparams.requirespagereload.md) | <code>boolean</code> | a flag indicating whether new value applying requires page reloading |
| [schema](./kibana-plugin-core-server.uisettingsparams.schema.md) | <code>Type&lt;T&gt;</code> | |
| [type](./kibana-plugin-core-server.uisettingsparams.type.md) | <code>UiSettingsType</code> | defines a type of UI element [UiSettingsType](./kibana-plugin-core-server.uisettingstype.md) |
| [validation](./kibana-plugin-core-server.uisettingsparams.validation.md) | <code>ImageValidation &#124; StringValidation</code> | |
| [value](./kibana-plugin-core-server.uisettingsparams.value.md) | <code>SavedObjectAttribute</code> | default value to fall back to if a user doesn't provide any |
| [value](./kibana-plugin-core-server.uisettingsparams.value.md) | <code>T</code> | default value to fall back to if a user doesn't provide any |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) &gt; [schema](./kibana-plugin-core-server.uisettingsparams.schema.md)
## UiSettingsParams.schema property
<b>Signature:</b>
```typescript
schema: Type<T>;
```

View file

@ -9,5 +9,5 @@ default value to fall back to if a user doesn't provide any
<b>Signature:</b>
```typescript
value?: SavedObjectAttribute;
value?: T;
```

View file

@ -44,5 +44,8 @@ export class ValidationError extends SchemaError {
constructor(error: SchemaTypeError, namespace?: string) {
super(ValidationError.extractMessage(error, namespace), error);
// https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work
Object.setPrototypeOf(this, ValidationError.prototype);
}
}

View file

@ -67,7 +67,7 @@ export class KbnClientUiSettings {
* Replace all uiSettings with the `doc` values, `doc` is merged
* with some defaults
*/
async replace(doc: UiSettingValues) {
async replace(doc: UiSettingValues, { retries = 5 }: { retries?: number } = {}) {
this.log.debug('replacing kibana config doc: %j', doc);
const changes: Record<string, any> = {
@ -85,7 +85,7 @@ export class KbnClientUiSettings {
method: 'POST',
path: '/api/kibana/settings',
body: { changes },
retries: 5,
retries,
});
}

View file

@ -43920,7 +43920,7 @@ class KbnClientUiSettings {
* Replace all uiSettings with the `doc` values, `doc` is merged
* with some defaults
*/
async replace(doc) {
async replace(doc, { retries = 5 } = {}) {
this.log.debug('replacing kibana config doc: %j', doc);
const changes = {
...this.defaults,
@ -43935,7 +43935,7 @@ class KbnClientUiSettings {
method: 'POST',
path: '/api/kibana/settings',
body: { changes },
retries: 5,
retries,
});
}
/**

View file

@ -171,7 +171,7 @@ export {
ErrorToastOptions,
} from './notifications';
export { MountPoint, UnmountCallback } from './types';
export { MountPoint, UnmountCallback, PublicUiSettingsParams } from './types';
/**
* Core services exposed to the `Plugin` setup lifecycle

View file

@ -16,10 +16,11 @@ import { Location } from 'history';
import { LocationDescriptorObject } from 'history';
import { MaybePromise } from '@kbn/utility-types';
import { Observable } from 'rxjs';
import { PublicUiSettingsParams as PublicUiSettingsParams_2 } from 'src/core/server/types';
import React from 'react';
import * as Rx from 'rxjs';
import { ShallowPromise } from '@kbn/utility-types';
import { UiSettingsParams as UiSettingsParams_2 } from 'src/core/server/types';
import { Type } from '@kbn/config-schema';
import { UnregisterCallback } from 'history';
import { UserProvidedValues as UserProvidedValues_2 } from 'src/core/server/types';
@ -784,7 +785,7 @@ export type IToasts = Pick<ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' |
export interface IUiSettingsClient {
get$: <T = any>(key: string, defaultOverride?: T) => Observable<T>;
get: <T = any>(key: string, defaultOverride?: T) => T;
getAll: () => Readonly<Record<string, UiSettingsParams_2 & UserProvidedValues_2>>;
getAll: () => Readonly<Record<string, PublicUiSettingsParams_2 & UserProvidedValues_2>>;
getSaved$: <T = any>() => Observable<{
key: string;
newValue: T;
@ -933,6 +934,9 @@ export interface PluginInitializerContext<ConfigSchema extends object = object>
// @public (undocumented)
export type PluginOpaqueId = symbol;
// @public
export type PublicUiSettingsParams = Omit<UiSettingsParams, 'schema'>;
// Warning: (ae-forgotten-export) The symbol "RecursiveReadonlyArray" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
@ -1291,7 +1295,7 @@ export type ToastsSetup = IToasts;
export type ToastsStart = IToasts;
// @public
export interface UiSettingsParams {
export interface UiSettingsParams<T = unknown> {
category?: string[];
// Warning: (ae-forgotten-export) The symbol "DeprecationSettings" needs to be exported by the entry point index.d.ts
deprecation?: DeprecationSettings;
@ -1301,16 +1305,18 @@ export interface UiSettingsParams {
options?: string[];
readonly?: boolean;
requiresPageReload?: boolean;
// (undocumented)
schema: Type<T>;
type?: UiSettingsType;
// (undocumented)
validation?: ImageValidation | StringValidation;
value?: SavedObjectAttribute;
value?: T;
}
// @public (undocumented)
export interface UiSettingsState {
// (undocumented)
[key: string]: UiSettingsParams_2 & UserProvidedValues_2;
[key: string]: PublicUiSettingsParams_2 & UserProvidedValues_2;
}
// @public

View file

@ -19,6 +19,7 @@
export {
UiSettingsParams,
PublicUiSettingsParams,
UserProvidedValues,
UiSettingsType,
ImageValidation,

View file

@ -84,21 +84,21 @@ Array [
exports[`#batchSet rejects all promises for batched requests that fail: promise rejections 1`] = `
Array [
Object {
"error": [Error: Request failed with status code: 400],
"error": [Error: invalid],
"isRejected": true,
},
Object {
"error": [Error: Request failed with status code: 400],
"error": [Error: invalid],
"isRejected": true,
},
Object {
"error": [Error: Request failed with status code: 400],
"error": [Error: invalid],
"isRejected": true,
},
]
`;
exports[`#batchSet rejects on 301 1`] = `"Request failed with status code: 301"`;
exports[`#batchSet rejects on 301 1`] = `"Moved Permanently"`;
exports[`#batchSet rejects on 404 response 1`] = `"Request failed with status code: 404"`;

View file

@ -18,11 +18,11 @@
*/
import { Observable } from 'rxjs';
import { UiSettingsParams, UserProvidedValues } from 'src/core/server/types';
import { PublicUiSettingsParams, UserProvidedValues } from 'src/core/server/types';
/** @public */
export interface UiSettingsState {
[key: string]: UiSettingsParams & UserProvidedValues;
[key: string]: PublicUiSettingsParams & UserProvidedValues;
}
/**
@ -53,7 +53,7 @@ export interface IUiSettingsClient {
* Gets the metadata about all uiSettings, including the type, default value, and user value
* for each key.
*/
getAll: () => Readonly<Record<string, UiSettingsParams & UserProvidedValues>>;
getAll: () => Readonly<Record<string, PublicUiSettingsParams & UserProvidedValues>>;
/**
* Sets the value for a uiSetting. If the setting is not registered by any plugin

View file

@ -148,7 +148,7 @@ describe('#batchSet', () => {
'*',
{
status: 400,
body: 'invalid',
body: { message: 'invalid' },
},
{
overwriteRoutes: false,

View file

@ -152,10 +152,14 @@ export class UiSettingsApi {
},
});
} catch (err) {
if (err.response && err.response.status >= 300) {
throw new Error(`Request failed with status code: ${err.response.status}`);
if (err.response) {
if (err.response.status === 400) {
throw new Error(err.body.message);
}
if (err.response.status > 400) {
throw new Error(`Request failed with status code: ${err.response.status}`);
}
}
throw err;
} finally {
this.loadingCount$.next(this.loadingCount$.getValue() - 1);

View file

@ -21,14 +21,14 @@ import { cloneDeep, defaultsDeep } from 'lodash';
import { Observable, Subject, concat, defer, of } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { UiSettingsParams, UserProvidedValues } from 'src/core/server/types';
import { UserProvidedValues, PublicUiSettingsParams } from 'src/core/server/types';
import { IUiSettingsClient, UiSettingsState } from './types';
import { UiSettingsApi } from './ui_settings_api';
interface UiSettingsClientParams {
api: UiSettingsApi;
defaults: Record<string, UiSettingsParams>;
defaults: Record<string, PublicUiSettingsParams>;
initialSettings?: UiSettingsState;
done$: Observable<unknown>;
}
@ -39,8 +39,8 @@ export class UiSettingsClient implements IUiSettingsClient {
private readonly updateErrors$ = new Subject<Error>();
private readonly api: UiSettingsApi;
private readonly defaults: Record<string, UiSettingsParams>;
private cache: Record<string, UiSettingsParams & UserProvidedValues>;
private readonly defaults: Record<string, PublicUiSettingsParams>;
private cache: Record<string, PublicUiSettingsParams & UserProvidedValues>;
constructor(params: UiSettingsClientParams) {
this.api = params.api;

View file

@ -0,0 +1,52 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { InternalCoreSetup } from '../internal_types';
import { CoreContext } from '../core_context';
import { Logger } from '../logging';
/** @internal */
export class CoreApp {
private readonly logger: Logger;
constructor(core: CoreContext) {
this.logger = core.logger.get('core-app');
}
setup(coreSetup: InternalCoreSetup) {
this.logger.debug('Setting up core app.');
this.registerDefaultRoutes(coreSetup);
}
private registerDefaultRoutes(coreSetup: InternalCoreSetup) {
const httpSetup = coreSetup.http;
const router = httpSetup.createRouter('/');
router.get({ path: '/', validate: false }, async (context, req, res) => {
const defaultRoute = await context.core.uiSettings.client.get<string>('defaultRoute');
const basePath = httpSetup.basePath.get(req);
const url = `${basePath}${defaultRoute}`;
return res.redirected({
headers: {
location: url,
},
});
});
router.get({ path: '/core', validate: false }, async (context, req, res) =>
res.ok({ body: { version: '0.0.1' } })
);
}
}

View file

@ -0,0 +1,20 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { CoreApp } from './core_app';

View file

@ -0,0 +1,90 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as kbnTestServer from '../../../../test_utils/kbn_server';
import { Root } from '../../root';
const { startES } = kbnTestServer.createTestServers({
adjustTimeout: (t: number) => jest.setTimeout(t),
});
let esServer: kbnTestServer.TestElasticsearchUtils;
describe('default route provider', () => {
let root: Root;
beforeAll(async () => {
esServer = await startES();
root = kbnTestServer.createRootWithCorePlugins({
server: {
basePath: '/hello',
},
});
await root.setup();
await root.start();
});
afterAll(async () => {
await esServer.stop();
await root.shutdown();
});
it('redirects to the configured default route respecting basePath', async function() {
const { status, header } = await kbnTestServer.request.get(root, '/');
expect(status).toEqual(302);
expect(header).toMatchObject({
location: '/hello/app/kibana',
});
});
it('ignores invalid values', async function() {
const invalidRoutes = [
'http://not-your-kibana.com',
'///example.com',
'//example.com',
' //example.com',
];
for (const url of invalidRoutes) {
await kbnTestServer.request
.post(root, '/api/kibana/settings/defaultRoute')
.send({ value: url })
.expect(400);
}
const { status, header } = await kbnTestServer.request.get(root, '/');
expect(status).toEqual(302);
expect(header).toMatchObject({
location: '/hello/app/kibana',
});
});
it('consumes valid values', async function() {
await kbnTestServer.request
.post(root, '/api/kibana/settings/defaultRoute')
.send({ value: '/valid' })
.expect(200);
const { status, header } = await kbnTestServer.request.get(root, '/');
expect(status).toEqual(302);
expect(header).toMatchObject({
location: '/hello/valid',
});
});
});

View file

@ -250,6 +250,7 @@ export {
export {
IUiSettingsClient,
UiSettingsParams,
PublicUiSettingsParams,
UiSettingsType,
UiSettingsServiceSetup,
UiSettingsServiceStart,

View file

@ -998,7 +998,7 @@ export interface IScopedRenderingClient {
export interface IUiSettingsClient {
get: <T = any>(key: string) => Promise<T>;
getAll: <T = any>() => Promise<Record<string, T>>;
getRegistered: () => Readonly<Record<string, UiSettingsParams>>;
getRegistered: () => Readonly<Record<string, PublicUiSettingsParams>>;
getUserProvided: <T = any>() => Promise<Record<string, UserProvidedValues<T>>>;
isOverridden: (key: string) => boolean;
remove: (key: string) => Promise<void>;
@ -1443,6 +1443,9 @@ export interface PluginsServiceStart {
contracts: Map<PluginName, unknown>;
}
// @public
export type PublicUiSettingsParams = Omit<UiSettingsParams, 'schema'>;
// Warning: (ae-forgotten-export) The symbol "RecursiveReadonlyArray" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
@ -2284,7 +2287,7 @@ export interface StringValidationRegexString {
}
// @public
export interface UiSettingsParams {
export interface UiSettingsParams<T = unknown> {
category?: string[];
deprecation?: DeprecationSettings;
description?: string;
@ -2293,10 +2296,12 @@ export interface UiSettingsParams {
options?: string[];
readonly?: boolean;
requiresPageReload?: boolean;
// (undocumented)
schema: Type<T>;
type?: UiSettingsType;
// (undocumented)
validation?: ImageValidation | StringValidation;
value?: SavedObjectAttribute;
value?: T;
}
// @public (undocumented)

View file

@ -26,8 +26,9 @@ import {
RawConfigurationProvider,
coreDeprecationProvider,
} from './config';
import { CoreApp } from './core_app';
import { ElasticsearchService } from './elasticsearch';
import { HttpService, InternalHttpServiceSetup } from './http';
import { HttpService } from './http';
import { RenderingService, RenderingServiceSetup } from './rendering';
import { LegacyService, ensureValidConfiguration } from './legacy';
import { Logger, LoggerFactory } from './logging';
@ -69,6 +70,7 @@ export class Server {
private readonly uiSettings: UiSettingsService;
private readonly uuid: UuidService;
private readonly metrics: MetricsService;
private readonly coreApp: CoreApp;
private coreStart?: InternalCoreStart;
@ -92,6 +94,7 @@ export class Server {
this.capabilities = new CapabilitiesService(core);
this.uuid = new UuidService(core);
this.metrics = new MetricsService(core);
this.coreApp = new CoreApp(core);
}
public async setup() {
@ -122,8 +125,6 @@ export class Server {
context: contextServiceSetup,
});
this.registerDefaultRoute(httpSetup);
const capabilitiesSetup = this.capabilities.setup({ http: httpSetup });
const elasticsearchServiceSetup = await this.elasticsearch.setup({
@ -168,6 +169,7 @@ export class Server {
});
this.registerCoreContext(coreSetup, renderingSetup);
this.coreApp.setup(coreSetup);
return coreSetup;
}
@ -216,13 +218,6 @@ export class Server {
await this.metrics.stop();
}
private registerDefaultRoute(httpSetup: InternalHttpServiceSetup) {
const router = httpSetup.createRouter('/core');
router.get({ path: '/', validate: false }, async (context, req, res) =>
res.ok({ body: { version: '0.0.1' } })
);
}
private registerCoreContext(coreSetup: InternalCoreSetup, rendering: RenderingServiceSetup) {
coreSetup.http.registerRouteHandlerContext(
coreId,

View file

@ -27,6 +27,7 @@ export {
UiSettingsServiceStart,
IUiSettingsClient,
UiSettingsParams,
PublicUiSettingsParams,
InternalUiSettingsServiceSetup,
InternalUiSettingsServiceStart,
UiSettingsType,

View file

@ -0,0 +1,66 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { schema } from '@kbn/config-schema';
import * as kbnTestServer from '../../../../test_utils/kbn_server';
describe('ui settings service', () => {
describe('routes', () => {
let root: ReturnType<typeof kbnTestServer.createRoot>;
beforeAll(async () => {
root = kbnTestServer.createRoot();
const { uiSettings } = await root.setup();
uiSettings.register({
custom: {
value: '42',
schema: schema.string(),
},
});
await root.start();
}, 30000);
afterAll(async () => await root.shutdown());
describe('set', () => {
it('validates value', async () => {
const response = await kbnTestServer.request
.post(root, '/api/kibana/settings/custom')
.send({ value: 100 })
.expect(400);
expect(response.body.message).toBe(
'[validation [custom]]: expected value of type [string] but got [number]'
);
});
});
describe('set many', () => {
it('validates value', async () => {
const response = await kbnTestServer.request
.post(root, '/api/kibana/settings')
.send({ changes: { custom: 100, foo: 'bar' } })
.expect(400);
expect(response.body.message).toBe(
'[validation [custom]]: expected value of type [string] but got [number]'
);
});
});
});
});

View file

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { schema } from '@kbn/config-schema';
import { schema, ValidationError } from '@kbn/config-schema';
import { IRouter } from '../../http';
import { SavedObjectsErrorHelpers } from '../../saved_objects';
@ -56,7 +56,7 @@ export function registerSetRoute(router: IRouter) {
});
}
if (error instanceof CannotOverrideError) {
if (error instanceof CannotOverrideError || error instanceof ValidationError) {
return response.badRequest({ body: error });
}

View file

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { schema } from '@kbn/config-schema';
import { schema, ValidationError } from '@kbn/config-schema';
import { IRouter } from '../../http';
import { SavedObjectsErrorHelpers } from '../../saved_objects';
@ -50,7 +50,7 @@ export function registerSetManyRoute(router: IRouter) {
});
}
if (error instanceof CannotOverrideError) {
if (error instanceof CannotOverrideError || error instanceof ValidationError) {
return response.badRequest({ body: error });
}

View file

@ -17,9 +17,10 @@
* under the License.
*/
import { SavedObjectsClientContract } from '../saved_objects/types';
import { UiSettingsParams, UserProvidedValues } from '../../types';
import { UiSettingsParams, UserProvidedValues, PublicUiSettingsParams } from '../../types';
export {
UiSettingsParams,
PublicUiSettingsParams,
StringValidationRegexString,
StringValidationRegex,
StringValidation,
@ -41,7 +42,7 @@ export interface IUiSettingsClient {
/**
* Returns registered uiSettings values {@link UiSettingsParams}
*/
getRegistered: () => Readonly<Record<string, UiSettingsParams>>;
getRegistered: () => Readonly<Record<string, PublicUiSettingsParams>>;
/**
* Retrieves uiSettings values set by the user with fallbacks to default values if not specified.
*/

View file

@ -18,6 +18,7 @@
*/
import Chance from 'chance';
import { schema } from '@kbn/config-schema';
import { loggingServiceMock } from '../logging/logging_service.mock';
import { createOrUpgradeSavedConfigMock } from './create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.mock';
@ -145,6 +146,22 @@ describe('ui settings', () => {
expect(error.message).toBe('Unable to update "foo" because it is overridden');
}
});
it('validates value if a schema presents', async () => {
const defaults = { foo: { schema: schema.string() } };
const { uiSettings, savedObjectsClient } = setup({ defaults });
await expect(
uiSettings.setMany({
bar: 2,
foo: 1,
})
).rejects.toMatchInlineSnapshot(
`[Error: [validation [foo]]: expected value of type [string] but got [number]]`
);
expect(savedObjectsClient.update).toHaveBeenCalledTimes(0);
});
});
describe('#set()', () => {
@ -163,6 +180,17 @@ describe('ui settings', () => {
});
});
it('validates value if a schema presents', async () => {
const defaults = { foo: { schema: schema.string() } };
const { uiSettings, savedObjectsClient } = setup({ defaults });
await expect(uiSettings.set('foo', 1)).rejects.toMatchInlineSnapshot(
`[Error: [validation [foo]]: expected value of type [string] but got [number]]`
);
expect(savedObjectsClient.update).toHaveBeenCalledTimes(0);
});
it('throws CannotOverrideError if the key is overridden', async () => {
const { uiSettings } = setup({
overrides: {
@ -193,6 +221,20 @@ describe('ui settings', () => {
expect(savedObjectsClient.update).toHaveBeenCalledWith(TYPE, ID, { one: null });
});
it('does not fail validation', async () => {
const defaults = {
foo: {
schema: schema.string(),
value: '1',
},
};
const { uiSettings, savedObjectsClient } = setup({ defaults });
await uiSettings.remove('foo');
expect(savedObjectsClient.update).toHaveBeenCalledTimes(1);
});
it('throws CannotOverrideError if the key is overridden', async () => {
const { uiSettings } = setup({
overrides: {
@ -235,6 +277,20 @@ describe('ui settings', () => {
});
});
it('does not fail validation', async () => {
const defaults = {
foo: {
schema: schema.string(),
value: '1',
},
};
const { uiSettings, savedObjectsClient } = setup({ defaults });
await uiSettings.removeMany(['foo', 'bar']);
expect(savedObjectsClient.update).toHaveBeenCalledTimes(1);
});
it('throws CannotOverrideError if any key is overridden', async () => {
const { uiSettings } = setup({
overrides: {
@ -256,7 +312,13 @@ describe('ui settings', () => {
const value = chance.word();
const defaults = { key: { value } };
const { uiSettings } = setup({ defaults });
expect(uiSettings.getRegistered()).toBe(defaults);
expect(uiSettings.getRegistered()).toEqual(defaults);
});
it('does not leak validation schema outside', () => {
const value = chance.word();
const defaults = { key: { value, schema: schema.string() } };
const { uiSettings } = setup({ defaults });
expect(uiSettings.getRegistered()).toStrictEqual({ key: { value } });
});
});
@ -274,7 +336,7 @@ describe('ui settings', () => {
const { uiSettings } = setup({ esDocSource });
const result = await uiSettings.getUserProvided();
expect(result).toEqual({
expect(result).toStrictEqual({
user: {
userValue: 'customized',
},
@ -286,7 +348,7 @@ describe('ui settings', () => {
const { uiSettings } = setup({ esDocSource });
const result = await uiSettings.getUserProvided();
expect(result).toEqual({
expect(result).toStrictEqual({
user: {
userValue: 'customized',
},
@ -296,6 +358,32 @@ describe('ui settings', () => {
});
});
it('ignores user-configured value if it fails validation', async () => {
const esDocSource = { user: 'foo', id: 'bar' };
const defaults = {
id: {
value: 42,
schema: schema.number(),
},
};
const { uiSettings } = setup({ esDocSource, defaults });
const result = await uiSettings.getUserProvided();
expect(result).toStrictEqual({
user: {
userValue: 'foo',
},
});
expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(`
Array [
Array [
"Ignore invalid UiSettings value. Error: [validation [id]]: expected value of type [number] but got [string].",
],
]
`);
});
it('automatically creates the savedConfig if it is missing and returns empty object', async () => {
const { uiSettings, savedObjectsClient, createOrUpgradeSavedConfig } = setup();
savedObjectsClient.get = jest
@ -303,7 +391,7 @@ describe('ui settings', () => {
.mockRejectedValueOnce(SavedObjectsClient.errors.createGenericNotFoundError())
.mockResolvedValueOnce({ attributes: {} });
expect(await uiSettings.getUserProvided()).toEqual({});
expect(await uiSettings.getUserProvided()).toStrictEqual({});
expect(savedObjectsClient.get).toHaveBeenCalledTimes(2);
@ -320,7 +408,7 @@ describe('ui settings', () => {
SavedObjectsClient.errors.createGenericNotFoundError()
);
expect(await uiSettings.getUserProvided()).toEqual({ foo: { userValue: 'bar ' } });
expect(await uiSettings.getUserProvided()).toStrictEqual({ foo: { userValue: 'bar ' } });
});
it('returns an empty object on Forbidden responses', async () => {
@ -329,7 +417,7 @@ describe('ui settings', () => {
const error = SavedObjectsClient.errors.decorateForbiddenError(new Error());
savedObjectsClient.get.mockRejectedValue(error);
expect(await uiSettings.getUserProvided()).toEqual({});
expect(await uiSettings.getUserProvided()).toStrictEqual({});
expect(createOrUpgradeSavedConfig).toHaveBeenCalledTimes(0);
});
@ -339,7 +427,7 @@ describe('ui settings', () => {
const error = SavedObjectsClient.errors.decorateEsUnavailableError(new Error());
savedObjectsClient.get.mockRejectedValue(error);
expect(await uiSettings.getUserProvided()).toEqual({});
expect(await uiSettings.getUserProvided()).toStrictEqual({});
expect(createOrUpgradeSavedConfig).toHaveBeenCalledTimes(0);
});
@ -382,7 +470,7 @@ describe('ui settings', () => {
};
const { uiSettings } = setup({ esDocSource, overrides });
expect(await uiSettings.getUserProvided()).toEqual({
expect(await uiSettings.getUserProvided()).toStrictEqual({
user: {
userValue: 'customized',
},
@ -404,15 +492,40 @@ describe('ui settings', () => {
expect(savedObjectsClient.get).toHaveBeenCalledWith(TYPE, ID);
});
it(`returns defaults when es doc is empty`, async () => {
it('returns defaults when es doc is empty', async () => {
const esDocSource = {};
const defaults = { foo: { value: 'bar' } };
const { uiSettings } = setup({ esDocSource, defaults });
expect(await uiSettings.getAll()).toEqual({
expect(await uiSettings.getAll()).toStrictEqual({
foo: 'bar',
});
});
it('ignores user-configured value if it fails validation', async () => {
const esDocSource = { user: 'foo', id: 'bar' };
const defaults = {
id: {
value: 42,
schema: schema.number(),
},
};
const { uiSettings } = setup({ esDocSource, defaults });
const result = await uiSettings.getAll();
expect(result).toStrictEqual({
id: 42,
user: 'foo',
});
expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(`
Array [
Array [
"Ignore invalid UiSettings value. Error: [validation [id]]: expected value of type [number] but got [string].",
],
]
`);
});
it(`merges user values, including ones without defaults, into key value pairs`, async () => {
const esDocSource = {
foo: 'user-override',
@ -427,7 +540,7 @@ describe('ui settings', () => {
const { uiSettings } = setup({ esDocSource, defaults });
expect(await uiSettings.getAll()).toEqual({
expect(await uiSettings.getAll()).toStrictEqual({
foo: 'user-override',
bar: 'user-provided',
});
@ -451,7 +564,7 @@ describe('ui settings', () => {
const { uiSettings } = setup({ esDocSource, defaults, overrides });
expect(await uiSettings.getAll()).toEqual({
expect(await uiSettings.getAll()).toStrictEqual({
foo: 'bax',
bar: 'user-provided',
});
@ -518,6 +631,28 @@ describe('ui settings', () => {
expect(await uiSettings.get('dateFormat')).toBe('foo');
});
it('returns the default value if user-configured value fails validation', async () => {
const esDocSource = { id: 'bar' };
const defaults = {
id: {
value: 42,
schema: schema.number(),
},
};
const { uiSettings } = setup({ esDocSource, defaults });
expect(await uiSettings.get('id')).toBe(42);
expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(`
Array [
Array [
"Ignore invalid UiSettings value. Error: [validation [id]]: expected value of type [number] but got [string].",
],
]
`);
});
});
describe('#isOverridden()', () => {

View file

@ -16,13 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { defaultsDeep } from 'lodash';
import { defaultsDeep, omit } from 'lodash';
import { SavedObjectsErrorHelpers } from '../saved_objects';
import { SavedObjectsClientContract } from '../saved_objects/types';
import { Logger } from '../logging';
import { createOrUpgradeSavedConfig } from './create_or_upgrade_saved_config';
import { IUiSettingsClient, UiSettingsParams } from './types';
import { IUiSettingsClient, UiSettingsParams, PublicUiSettingsParams } from './types';
import { CannotOverrideError } from './ui_settings_errors';
export interface UiSettingsServiceOptions {
@ -40,14 +40,14 @@ interface ReadOptions {
autoCreateOrUpgradeIfMissing?: boolean;
}
interface UserProvidedValue<T = any> {
interface UserProvidedValue<T = unknown> {
userValue?: T;
isOverridden?: boolean;
}
type UiSettingsRawValue = UiSettingsParams & UserProvidedValue;
type UserProvided<T = any> = Record<string, UserProvidedValue<T>>;
type UserProvided<T = unknown> = Record<string, UserProvidedValue<T>>;
type UiSettingsRaw = Record<string, UiSettingsRawValue>;
export class UiSettingsClient implements IUiSettingsClient {
@ -72,7 +72,11 @@ export class UiSettingsClient implements IUiSettingsClient {
}
getRegistered() {
return this.defaults;
const copiedDefaults: Record<string, PublicUiSettingsParams> = {};
for (const [key, value] of Object.entries(this.defaults)) {
copiedDefaults[key] = omit(value, 'schema');
}
return copiedDefaults;
}
async get<T = any>(key: string): Promise<T> {
@ -90,29 +94,21 @@ export class UiSettingsClient implements IUiSettingsClient {
}, {} as Record<string, T>);
}
async getUserProvided<T = any>(): Promise<UserProvided<T>> {
const userProvided: UserProvided = {};
// write the userValue for each key stored in the saved object that is not overridden
for (const [key, userValue] of Object.entries(await this.read())) {
if (userValue !== null && !this.isOverridden(key)) {
userProvided[key] = {
userValue,
};
}
}
async getUserProvided<T = unknown>(): Promise<UserProvided<T>> {
const userProvided: UserProvided<T> = this.onReadHook<T>(await this.read());
// write all overridden keys, dropping the userValue is override is null and
// adding keys for overrides that are not in saved object
for (const [key, userValue] of Object.entries(this.overrides)) {
for (const [key, value] of Object.entries(this.overrides)) {
userProvided[key] =
userValue === null ? { isOverridden: true } : { isOverridden: true, userValue };
value === null ? { isOverridden: true } : { isOverridden: true, userValue: value };
}
return userProvided;
}
async setMany(changes: Record<string, any>) {
this.onWriteHook(changes);
await this.write({ changes });
}
@ -147,6 +143,43 @@ export class UiSettingsClient implements IUiSettingsClient {
return defaultsDeep(userProvided, this.defaults);
}
private validateKey(key: string, value: unknown) {
const definition = this.defaults[key];
if (value === null || definition === undefined) return;
if (definition.schema) {
definition.schema.validate(value, {}, `validation [${key}]`);
}
}
private onWriteHook(changes: Record<string, unknown>) {
for (const key of Object.keys(changes)) {
this.assertUpdateAllowed(key);
}
for (const [key, value] of Object.entries(changes)) {
this.validateKey(key, value);
}
}
private onReadHook<T = unknown>(values: Record<string, unknown>) {
// write the userValue for each key stored in the saved object that is not overridden
// validate value read from saved objects as it can be changed via SO API
const filteredValues: UserProvided<T> = {};
for (const [key, userValue] of Object.entries(values)) {
if (userValue === null || this.isOverridden(key)) continue;
try {
this.validateKey(key, userValue);
filteredValues[key] = {
userValue: userValue as T,
};
} catch (error) {
this.log.warn(`Ignore invalid UiSettings value. ${error}.`);
}
}
return filteredValues;
}
private async write({
changes,
autoCreateOrUpgradeIfMissing = true,
@ -154,10 +187,6 @@ export class UiSettingsClient implements IUiSettingsClient {
changes: Record<string, any>;
autoCreateOrUpgradeIfMissing?: boolean;
}) {
for (const key of Object.keys(changes)) {
this.assertUpdateAllowed(key);
}
try {
await this.savedObjectsClient.update(this.type, this.id, changes);
} catch (error) {

View file

@ -17,6 +17,8 @@
* under the License.
*/
import { BehaviorSubject } from 'rxjs';
import { schema } from '@kbn/config-schema';
import { MockUiSettingsClientConstructor } from './ui_settings_service.test.mock';
import { UiSettingsService, SetupDeps } from './ui_settings_service';
import { httpServiceMock } from '../http/http_service.mock';
@ -35,6 +37,7 @@ const defaults = {
value: 'bar',
category: [],
description: '',
schema: schema.string(),
},
};
@ -104,6 +107,45 @@ describe('uiSettings', () => {
});
describe('#start', () => {
describe('validation', () => {
it('validates registered definitions', async () => {
const { register } = await service.setup(setupDeps);
register({
custom: {
value: 42,
schema: schema.string(),
},
});
await expect(service.start()).rejects.toMatchInlineSnapshot(
`[Error: [ui settings defaults [custom]]: expected value of type [string] but got [number]]`
);
});
it('validates overrides', async () => {
const coreContext = mockCoreContext.create();
coreContext.configService.atPath.mockReturnValueOnce(
new BehaviorSubject({
overrides: {
custom: 42,
},
})
);
const customizedService = new UiSettingsService(coreContext);
const { register } = await customizedService.setup(setupDeps);
register({
custom: {
value: '42',
schema: schema.string(),
},
});
await expect(customizedService.start()).rejects.toMatchInlineSnapshot(
`[Error: [ui settings overrides [custom]]: expected value of type [string] but got [number]]`
);
});
});
describe('#asScopedToClient', () => {
it('passes saved object type "config" to UiSettingsClient', async () => {
await service.setup(setupDeps);

View file

@ -70,6 +70,9 @@ export class UiSettingsService
}
public async start(): Promise<InternalUiSettingsServiceStart> {
this.validatesDefinitions();
this.validatesOverrides();
return {
asScopedToClient: this.getScopedClientFactory(),
};
@ -101,4 +104,21 @@ export class UiSettingsService
this.uiSettingsDefaults.set(key, value);
});
}
private validatesDefinitions() {
for (const [key, definition] of this.uiSettingsDefaults) {
if (definition.schema) {
definition.schema.validate(definition.value, {}, `ui settings defaults [${key}]`);
}
}
}
private validatesOverrides() {
for (const [key, value] of Object.entries(this.overrides)) {
const definition = this.uiSettingsDefaults.get(key);
if (definition?.schema) {
definition.schema.validate(value, {}, `ui settings overrides [${key}]`);
}
}
}
}

View file

@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { SavedObjectAttribute } from './saved_objects';
import { Type } from '@kbn/config-schema';
/**
* UI element type to represent the settings.
@ -49,11 +48,11 @@ export interface DeprecationSettings {
* UiSettings parameters defined by the plugins.
* @public
* */
export interface UiSettingsParams {
export interface UiSettingsParams<T = unknown> {
/** title in the UI */
name?: string;
/** default value to fall back to if a user doesn't provide any */
value?: SavedObjectAttribute;
value?: T;
/** description provided to a user in UI */
description?: string;
/** used to group the configured setting in the UI */
@ -73,10 +72,22 @@ export interface UiSettingsParams {
/*
* Allows defining a custom validation applicable to value change on the client.
* @deprecated
* Use schema instead.
*/
validation?: ImageValidation | StringValidation;
/*
* Value validation schema
* Used to validate value on write and read.
*/
schema: Type<T>;
}
/**
* A sub-set of {@link UiSettingsParams} exposed to the client-side.
* @public
* */
export type PublicUiSettingsParams = Omit<UiSettingsParams, 'schema'>;
/**
* Allows regex objects or a regex string
* @public

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { modifyUrl } from './url';
import { modifyUrl, isRelativeUrl } from './url';
describe('modifyUrl()', () => {
test('throws an error with invalid input', () => {
@ -69,3 +69,17 @@ describe('modifyUrl()', () => {
).toEqual('mail:localhost');
});
});
describe('isRelativeUrl()', () => {
test('returns "true" for a relative URL', () => {
expect(isRelativeUrl('good')).toBe(true);
expect(isRelativeUrl('/good')).toBe(true);
expect(isRelativeUrl('/good/even/better')).toBe(true);
});
test('returns "false" for a non-relative URL', () => {
expect(isRelativeUrl('http://evil.com')).toBe(false);
expect(isRelativeUrl('//evil.com')).toBe(false);
expect(isRelativeUrl('///evil.com')).toBe(false);
expect(isRelativeUrl(' //evil.com')).toBe(false);
});
});

View file

@ -99,3 +99,19 @@ export function modifyUrl(
slashes: modifiedParts.slashes,
} as UrlObject);
}
export function isRelativeUrl(candidatePath: string) {
// validate that `candidatePath` is not attempting a redirect to somewhere
// outside of this Kibana install
const all = parseUrl(candidatePath, false /* parseQueryString */, true /* slashesDenoteHost */);
const { protocol, hostname, port } = all;
// We should explicitly compare `protocol`, `port` and `hostname` to null to make sure these are not
// detected in the URL at all. For example `hostname` can be empty string for Node URL parser, but
// browser (because of various bwc reasons) processes URL differently (e.g. `///abc.com` - for browser
// hostname is `abc.com`, but for Node hostname is an empty string i.e. everything between schema (`//`)
// and the first slash that belongs to path.
if (protocol !== null || hostname !== null || port !== null) {
return false;
}
return true;
}

View file

@ -16,11 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import moment from 'moment-timezone';
import numeralLanguages from '@elastic/numeral/languages';
import { i18n } from '@kbn/i18n';
import { schema } from '@kbn/config-schema';
import { DEFAULT_QUERY_LANGUAGE } from '../../../plugins/data/common';
import { isRelativeUrl } from '../../../core/utils';
export function getUiSettingDefaults() {
const weekdays = moment.weekdays().slice();
@ -67,17 +69,23 @@ export function getUiSettingDefaults() {
defaultMessage: 'Default route',
}),
value: '/app/kibana',
validation: {
regexString: '^/',
message: i18n.translate('kbn.advancedSettings.defaultRoute.defaultRouteValidationMessage', {
defaultMessage: 'The route must start with a slash ("/")',
}),
},
schema: schema.string({
validate(value) {
if (!value.startsWith('/') || !isRelativeUrl(value)) {
return i18n.translate(
'kbn.advancedSettings.defaultRoute.defaultRouteIsRelativeValidationMessage',
{
defaultMessage: 'Must be a relative URL.',
}
);
}
},
}),
description: i18n.translate('kbn.advancedSettings.defaultRoute.defaultRouteText', {
defaultMessage:
'This setting specifies the default route when opening Kibana. ' +
'You can use this setting to modify the landing page when opening Kibana. ' +
'The route must start with a slash ("/").',
'The route must be a relative URL.',
}),
},
'query:queryString:options': {

View file

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Type } from '@kbn/config-schema';
import pkg from '../../../../package.json';
export const createTestEntryTemplate = defaultUiSettings => bundle => `
@ -87,7 +87,14 @@ const coreSystem = new CoreSystem({
buildNum: 1234,
devMode: true,
uiSettings: {
defaults: ${JSON.stringify(defaultUiSettings, null, 2)
defaults: ${JSON.stringify(
defaultUiSettings,
(key, value) => {
if (value instanceof Type) return null;
return value;
},
2
)
.split('\n')
.join('\n ')},
user: {}

View file

@ -24,15 +24,12 @@ import Boom from 'boom';
import { registerHapiPlugins } from './register_hapi_plugins';
import { setupBasePathProvider } from './setup_base_path_provider';
import { setupDefaultRouteProvider } from './setup_default_route_provider';
export default async function(kbnServer, server, config) {
server = kbnServer.server;
setupBasePathProvider(kbnServer);
setupDefaultRouteProvider(server);
await registerHapiPlugins(server);
// provide a simple way to expose static directories
@ -60,14 +57,6 @@ export default async function(kbnServer, server, config) {
});
});
server.route({
path: '/',
method: 'GET',
async handler(req, h) {
return h.redirect(await req.getDefaultRoute());
},
});
server.route({
method: 'GET',
path: '/{p*}',

View file

@ -1,87 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
jest.mock('../../../ui/ui_settings/ui_settings_mixin', () => {
return jest.fn();
});
import * as kbnTestServer from '../../../../test_utils/kbn_server';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { Root } from '../../../../core/server/root';
let mockDefaultRouteSetting: any = '';
describe('default route provider', () => {
let root: Root;
beforeAll(async () => {
root = kbnTestServer.createRoot({ migrations: { skip: true } });
await root.setup();
await root.start();
const kbnServer = kbnTestServer.getKbnServer(root);
kbnServer.server.decorate('request', 'getUiSettingsService', function() {
return {
get: (key: string) => {
if (key === 'defaultRoute') {
return Promise.resolve(mockDefaultRouteSetting);
}
throw Error(`unsupported ui setting: ${key}`);
},
getRegistered: () => {
return {
defaultRoute: {
value: '/app/kibana',
},
};
},
};
});
}, 30000);
afterAll(async () => await root.shutdown());
it('redirects to the configured default route', async function() {
mockDefaultRouteSetting = '/app/some/default/route';
const { status, header } = await kbnTestServer.request.get(root, '/');
expect(status).toEqual(302);
expect(header).toMatchObject({
location: '/app/some/default/route',
});
});
const invalidRoutes = [
'http://not-your-kibana.com',
'///example.com',
'//example.com',
' //example.com',
];
for (const route of invalidRoutes) {
it(`falls back to /app/kibana when the configured route (${route}) is not a valid relative path`, async function() {
mockDefaultRouteSetting = route;
const { status, header } = await kbnTestServer.request.get(root, '/');
expect(status).toEqual(302);
expect(header).toMatchObject({
location: '/app/kibana',
});
});
}
});

View file

@ -1,54 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as kbnTestServer from '../../../../test_utils/kbn_server';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { Root } from '../../../../core/server/root';
describe('default route provider', () => {
let root: Root;
afterEach(async () => await root.shutdown());
it('redirects to the configured default route', async function() {
root = kbnTestServer.createRoot({
server: {
defaultRoute: '/app/some/default/route',
},
migrations: { skip: true },
});
await root.setup();
await root.start();
const kbnServer = kbnTestServer.getKbnServer(root);
kbnServer.server.decorate('request', 'getSavedObjectsClient', function() {
return {
get: (type: string, id: string) => ({ attributes: {} }),
};
});
const { status, header } = await kbnTestServer.request.get(root, '/');
expect(status).toEqual(302);
expect(header).toMatchObject({
location: '/app/some/default/route',
});
});
});

View file

@ -1,74 +0,0 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Legacy } from 'kibana';
import { parse } from 'url';
export function setupDefaultRouteProvider(server: Legacy.Server) {
server.decorate('request', 'getDefaultRoute', async function() {
// @ts-ignore
const request: Legacy.Request = this;
const serverBasePath: string = server.config().get('server.basePath');
const uiSettings = request.getUiSettingsService();
const defaultRoute = await uiSettings.get<string>('defaultRoute');
const qualifiedDefaultRoute = `${request.getBasePath()}${defaultRoute}`;
if (isRelativePath(qualifiedDefaultRoute, serverBasePath)) {
return qualifiedDefaultRoute;
} else {
server.log(
['http', 'warn'],
`Ignoring configured default route of '${defaultRoute}', as it is malformed.`
);
const fallbackRoute = uiSettings.getRegistered().defaultRoute.value;
const qualifiedFallbackRoute = `${request.getBasePath()}${fallbackRoute}`;
return qualifiedFallbackRoute;
}
});
function isRelativePath(candidatePath: string, basePath = '') {
// validate that `candidatePath` is not attempting a redirect to somewhere
// outside of this Kibana install
const { protocol, hostname, port, pathname } = parse(
candidatePath,
false /* parseQueryString */,
true /* slashesDenoteHost */
);
// We should explicitly compare `protocol`, `port` and `hostname` to null to make sure these are not
// detected in the URL at all. For example `hostname` can be empty string for Node URL parser, but
// browser (because of various bwc reasons) processes URL differently (e.g. `///abc.com` - for browser
// hostname is `abc.com`, but for Node hostname is an empty string i.e. everything between schema (`//`)
// and the first slash that belongs to path.
if (protocol !== null || hostname !== null || port !== null) {
return false;
}
if (!String(pathname).startsWith(basePath)) {
return false;
}
return true;
}
}

View file

@ -92,7 +92,6 @@ declare module 'hapi' {
interface Request {
getSavedObjectsClient(options?: SavedObjectsClientProviderOptions): SavedObjectsClientContract;
getBasePath(): string;
getDefaultRoute(): Promise<string>;
getUiSettingsService(): IUiSettingsClient;
}

View file

@ -22,7 +22,11 @@ import { Observable } from 'rxjs';
import { ReactWrapper } from 'enzyme';
import { mountWithI18nProvider } from 'test_utils/enzyme_helpers';
import dedent from 'dedent';
import { UiSettingsParams, UserProvidedValues, UiSettingsType } from '../../../../core/public';
import {
PublicUiSettingsParams,
UserProvidedValues,
UiSettingsType,
} from '../../../../core/public';
import { FieldSetting } from './types';
import { AdvancedSettingsComponent } from './advanced_settings';
import { notificationServiceMock, docLinksServiceMock } from '../../../../core/public/mocks';
@ -68,7 +72,7 @@ function mockConfig() {
remove: (key: string) => Promise.resolve(true),
isCustom: (key: string) => false,
isOverridden: (key: string) => Boolean(config.getAll()[key].isOverridden),
getRegistered: () => ({} as Readonly<Record<string, UiSettingsParams>>),
getRegistered: () => ({} as Readonly<Record<string, PublicUiSettingsParams>>),
overrideLocalDefault: (key: string, value: any) => {},
getUpdate$: () =>
new Observable<{
@ -89,7 +93,7 @@ function mockConfig() {
getUpdateErrors$: () => new Observable<Error>(),
get: (key: string, defaultOverride?: any): any => config.getAll()[key] || defaultOverride,
get$: (key: string) => new Observable<any>(config.get(key)),
getAll: (): Readonly<Record<string, UiSettingsParams & UserProvidedValues>> => {
getAll: (): Readonly<Record<string, PublicUiSettingsParams & UserProvidedValues>> => {
return {
'test:array:setting': {
...defaultConfig,

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { UiSettingsParams, StringValidationRegex } from 'src/core/public';
import { PublicUiSettingsParams, StringValidationRegex } from 'src/core/public';
import expect from '@kbn/expect';
import { toEditableConfig } from './to_editable_config';
@ -30,7 +30,7 @@ function invoke({
name = 'woah',
value = 'forreal',
}: {
def?: UiSettingsParams & { isOverridden?: boolean };
def?: PublicUiSettingsParams & { isOverridden?: boolean };
name?: string;
value?: any;
}) {
@ -55,7 +55,7 @@ describe('Settings', function() {
});
describe('when given a setting definition object', function() {
let def: UiSettingsParams & { isOverridden?: boolean };
let def: PublicUiSettingsParams & { isOverridden?: boolean };
beforeEach(function() {
def = {
value: 'the original',

View file

@ -18,7 +18,7 @@
*/
import {
UiSettingsParams,
PublicUiSettingsParams,
UserProvidedValues,
StringValidationRegexString,
SavedObjectAttribute,
@ -40,7 +40,7 @@ export function toEditableConfig({
isCustom,
isOverridden,
}: {
def: UiSettingsParams & UserProvidedValues<any>;
def: PublicUiSettingsParams & UserProvidedValues<any>;
name: string;
value: SavedObjectAttribute;
isCustom: boolean;

View file

@ -17,17 +17,12 @@
* under the License.
*/
import {
UiSettingsType,
StringValidation,
ImageValidation,
SavedObjectAttribute,
} from '../../../../core/public';
import { UiSettingsType, StringValidation, ImageValidation } from '../../../../core/public';
export interface FieldSetting {
displayName: string;
name: string;
value: SavedObjectAttribute;
value: unknown;
description?: string;
options?: string[];
optionLabels?: Record<string, string>;
@ -36,7 +31,7 @@ export interface FieldSetting {
category: string[];
ariaName: string;
isOverridden: boolean;
defVal: SavedObjectAttribute;
defVal: unknown;
isCustom: boolean;
validation?: StringValidation | ImageValidation;
readOnly?: boolean;

View file

@ -38,6 +38,7 @@ import { Observable } from 'rxjs';
import { Plugin as Plugin_2 } from 'src/core/public';
import { PluginInitializerContext as PluginInitializerContext_2 } from 'src/core/public';
import { PopoverAnchorPosition } from '@elastic/eui';
import { PublicUiSettingsParams } from 'src/core/server/types';
import React from 'react';
import * as React_2 from 'react';
import { Required } from '@kbn/utility-types';
@ -49,7 +50,6 @@ import { SearchResponse as SearchResponse_2 } from 'elasticsearch';
import { SimpleSavedObject } from 'src/core/public';
import { UiActionsSetup } from 'src/plugins/ui_actions/public';
import { UiActionsStart } from 'src/plugins/ui_actions/public';
import { UiSettingsParams } from 'src/core/server/types';
import { Unit } from '@elastic/datemath';
import { UnregisterCallback } from 'history';
import { UserProvidedValues } from 'src/core/server/types';

View file

@ -50,6 +50,7 @@ const DEFAULTS_SETTINGS = {
logging: { silent: true },
plugins: {},
optimize: { enabled: false },
migrations: { skip: true },
};
const DEFAULT_SETTINGS_WITH_CORE_PLUGINS = {

View file

@ -33,6 +33,5 @@ export default function({ loadTestFile }) {
loadTestFile(require.resolve('./status'));
loadTestFile(require.resolve('./stats'));
loadTestFile(require.resolve('./ui_metric'));
loadTestFile(require.resolve('./core'));
});
}

View file

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { schema } from '@kbn/config-schema';
import { Plugin, CoreSetup } from 'kibana/server';
export class UiSettingsPlugin implements Plugin {
@ -27,6 +27,7 @@ export class UiSettingsPlugin implements Plugin {
description: 'just for testing',
value: '2',
category: ['any'],
schema: schema.string(),
},
});

View file

@ -14,7 +14,13 @@ export function initEnterSpaceView(server: Legacy.Server) {
path: ENTER_SPACE_PATH,
async handler(request, h) {
try {
return h.redirect(await request.getDefaultRoute());
const uiSettings = request.getUiSettingsService();
const defaultRoute = await uiSettings.get<string>('defaultRoute');
const basePath = server.newPlatform.setup.core.http.basePath.get(request);
const url = `${basePath}${defaultRoute}`;
return h.redirect(url);
} catch (e) {
server.log(['spaces', 'error'], `Error navigating to space: ${e}`);
return wrapError(e);

View file

@ -833,7 +833,6 @@
"kbn.advancedSettings.defaultIndexTitle": "デフォルトのインデックス",
"kbn.advancedSettings.defaultRoute.defaultRouteText": "この設定は、Kibana 起動時のデフォルトのルートを設定します。この設定で、Kibana 起動時のランディングページを変更できます。ルートはスラッシュ (\"/\") で始まる必要があります。",
"kbn.advancedSettings.defaultRoute.defaultRouteTitle": "デフォルトのルート",
"kbn.advancedSettings.defaultRoute.defaultRouteValidationMessage": "ルートはスラッシュ (\"/\") で始まる必要があります。",
"kbn.advancedSettings.disableAnimationsText": "Kibana UI の不要なアニメーションをオフにします。変更を適用するにはページを更新してください。",
"kbn.advancedSettings.disableAnimationsTitle": "アニメーションを無効にする",
"kbn.advancedSettings.discover.aggsTermsSizeText": "「可視化」ボタンをクリックした際に、フィールドドロップダウンやディスカバリサイドバーに可視化される用語の数を設定します。",

View file

@ -833,7 +833,6 @@
"kbn.advancedSettings.defaultIndexTitle": "默认索引",
"kbn.advancedSettings.defaultRoute.defaultRouteText": "此设置指定打开 Kibana 时的默认路由。您可以使用此设置修改打开 Kibana 时的登陆页面。路由必须以正斜杠(“/”)开头。",
"kbn.advancedSettings.defaultRoute.defaultRouteTitle": "默认路由",
"kbn.advancedSettings.defaultRoute.defaultRouteValidationMessage": "路由必须以正斜杠(“/”)开头",
"kbn.advancedSettings.disableAnimationsText": "在 Kibana UI 中关闭所有没有必要的动画。刷新页面以应用更改。",
"kbn.advancedSettings.disableAnimationsTitle": "禁用动画",
"kbn.advancedSettings.discover.aggsTermsSizeText": "确定在单击“可视化”按钮时将在发现侧边栏的字段下拉列表中可视化多少个词。",

View file

@ -10,7 +10,6 @@ export default function enterSpaceFunctonalTests({
getPageObjects,
}: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const PageObjects = getPageObjects(['security', 'spaceSelector']);
describe('Enter Space', function() {
@ -25,30 +24,7 @@ export default function enterSpaceFunctonalTests({
await PageObjects.security.forceLogout();
});
it('allows user to navigate to different spaces, respecting the configured default route', async () => {
const spaceId = 'another-space';
await PageObjects.security.login(null, null, {
expectSpaceSelector: true,
});
await PageObjects.spaceSelector.clickSpaceCard(spaceId);
await PageObjects.spaceSelector.expectRoute(spaceId, '/app/kibana/#/dashboard');
await PageObjects.spaceSelector.openSpacesNav();
// change spaces
await PageObjects.spaceSelector.clickSpaceAvatar('default');
await PageObjects.spaceSelector.expectRoute('default', '/app/canvas');
});
it('falls back to the default home page when the configured default route is malformed', async () => {
await kibanaServer.uiSettings.replace({ defaultRoute: 'http://example.com/evil' });
// This test only works with the default space, as other spaces have an enforced relative url of `${serverBasePath}/s/space-id/${defaultRoute}`
const spaceId = 'default';
await PageObjects.security.login(null, null, {
@ -59,5 +35,25 @@ export default function enterSpaceFunctonalTests({
await PageObjects.spaceSelector.expectHomePage(spaceId);
});
it('allows user to navigate to different spaces, respecting the configured default route', async () => {
const spaceId = 'another-space';
await PageObjects.security.login(null, null, {
expectSpaceSelector: true,
});
await PageObjects.spaceSelector.clickSpaceCard(spaceId);
await PageObjects.spaceSelector.expectRoute(spaceId, '/app/canvas');
await PageObjects.spaceSelector.openSpacesNav();
// change spaces
const newSpaceId = 'default';
await PageObjects.spaceSelector.clickSpaceAvatar(newSpaceId);
await PageObjects.spaceSelector.expectHomePage(newSpaceId);
});
});
}

View file

@ -7,7 +7,7 @@
"config": {
"buildNum": 8467,
"dateFormat:tz": "UTC",
"defaultRoute": "/app/canvas"
"defaultRoute": "http://example.com/evil"
},
"type": "config"
}
@ -24,7 +24,7 @@
"config": {
"buildNum": 8467,
"dateFormat:tz": "UTC",
"defaultRoute": "/app/kibana/#dashboard"
"defaultRoute": "/app/canvas"
},
"type": "config"
}