diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicepreboot.config.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicepreboot.config.md index 897e1617486715..12a32b4544abaf 100644 --- a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicepreboot.config.md +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicepreboot.config.md @@ -9,7 +9,7 @@ A limited set of Elasticsearch configuration entries. Signature: ```typescript -readonly config: ElasticsearchConfigPreboot; +readonly config: Readonly; ``` ## Example diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicepreboot.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicepreboot.md index 0b5847ec889435..bf458004b488bd 100644 --- a/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicepreboot.md +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchservicepreboot.md @@ -15,6 +15,6 @@ export interface ElasticsearchServicePreboot | Property | Type | Description | | --- | --- | --- | -| [config](./kibana-plugin-core-server.elasticsearchservicepreboot.config.md) | ElasticsearchConfigPreboot | A limited set of Elasticsearch configuration entries. | +| [config](./kibana-plugin-core-server.elasticsearchservicepreboot.config.md) | Readonly<ElasticsearchConfigPreboot> | A limited set of Elasticsearch configuration entries. | | [createClient](./kibana-plugin-core-server.elasticsearchservicepreboot.createclient.md) | (type: string, clientConfig?: Partial<ElasticsearchClientConfig>) => ICustomClusterClient | Create application specific Elasticsearch cluster API client with customized config. See [IClusterClient](./kibana-plugin-core-server.iclusterclient.md). | diff --git a/src/core/server/elasticsearch/types.ts b/src/core/server/elasticsearch/types.ts index a9e360e733f0da..375c7015b16d79 100644 --- a/src/core/server/elasticsearch/types.ts +++ b/src/core/server/elasticsearch/types.ts @@ -31,7 +31,7 @@ export interface ElasticsearchServicePreboot { * const { hosts, credentialsSpecified } = core.elasticsearch.config; * ``` */ - readonly config: ElasticsearchConfigPreboot; + readonly config: Readonly; /** * Create application specific Elasticsearch cluster API client with customized config. See {@link IClusterClient}. diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 2b265948b6c210..52548c760e30bb 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1000,7 +1000,7 @@ export interface ElasticsearchConfigPreboot { // @public (undocumented) export interface ElasticsearchServicePreboot { - readonly config: ElasticsearchConfigPreboot; + readonly config: Readonly; readonly createClient: (type: string, clientConfig?: Partial) => ICustomClusterClient; } diff --git a/src/core/server/ui_settings/base_ui_settings_client.ts b/src/core/server/ui_settings/base_ui_settings_client.ts new file mode 100644 index 00000000000000..d16524ef4e3431 --- /dev/null +++ b/src/core/server/ui_settings/base_ui_settings_client.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { omit } from 'lodash'; + +import { + IUiSettingsClient, + UiSettingsParams, + PublicUiSettingsParams, + UserProvidedValues, +} from './types'; +import { Logger } from '../logging'; + +export interface BaseUiSettingsDefaultsClientOptions { + overrides?: Record; + defaults?: Record; + log: Logger; +} + +/** + * Base implementation of the {@link IUiSettingsClient}. + */ +export abstract class BaseUiSettingsClient implements IUiSettingsClient { + private readonly defaults: NonNullable; + private readonly defaultValues: Record; + protected readonly overrides: NonNullable; + protected readonly log: Logger; + + protected constructor(options: BaseUiSettingsDefaultsClientOptions) { + const { defaults = {}, overrides = {}, log } = options; + this.log = log; + this.overrides = overrides; + + this.defaults = defaults; + this.defaultValues = Object.fromEntries( + Object.entries(this.defaults).map(([key, { value }]) => [key, value]) + ); + } + + getRegistered() { + const copiedDefaults: Record = {}; + for (const [key, value] of Object.entries(this.defaults)) { + copiedDefaults[key] = omit(value, 'schema'); + } + return copiedDefaults; + } + + async get(key: string): Promise { + const all = await this.getAll(); + return all[key] as T; + } + + async getAll() { + const result = { ...this.defaultValues }; + + const userProvided = await this.getUserProvided(); + Object.keys(userProvided).forEach((key) => { + if (userProvided[key].userValue !== undefined) { + result[key] = userProvided[key].userValue; + } + }); + + return Object.freeze(result) as Record; + } + + isOverridden(key: string) { + return this.overrides.hasOwnProperty(key); + } + + isSensitive(key: string): boolean { + const definition = this.defaults[key]; + return !!definition?.sensitive; + } + + protected 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}]`); + } + } + + abstract getUserProvided(): Promise>>; + abstract setMany(changes: Record): Promise; + abstract set(key: string, value: any): Promise; + abstract remove(key: string): Promise; + abstract removeMany(keys: string[]): Promise; +} diff --git a/src/core/server/ui_settings/ui_settings_client.ts b/src/core/server/ui_settings/ui_settings_client.ts index 61a4966348e74f..53ed41a3e12be5 100644 --- a/src/core/server/ui_settings/ui_settings_client.ts +++ b/src/core/server/ui_settings/ui_settings_client.ts @@ -6,21 +6,20 @@ * Side Public License, v 1. */ -import { 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, PublicUiSettingsParams } from './types'; +import { UiSettingsParams } from './types'; import { CannotOverrideError } from './ui_settings_errors'; import { Cache } from './cache'; +import { BaseUiSettingsClient } from './base_ui_settings_client'; export interface UiSettingsServiceOptions { type: string; id: string; buildNum: number; - savedObjectsClient?: SavedObjectsClientContract; + savedObjectsClient: SavedObjectsClientContract; overrides?: Record; defaults?: Record; log: Logger; @@ -37,67 +36,22 @@ interface UserProvidedValue { type UserProvided = Record>; -function assertSavedObjectsClient( - savedObjectsClient?: SavedObjectsClientContract -): asserts savedObjectsClient is SavedObjectsClientContract { - if (!savedObjectsClient) { - throw new Error('Saved Objects Client is not available.'); - } -} - -export class UiSettingsClient implements IUiSettingsClient { +export class UiSettingsClient extends BaseUiSettingsClient { private readonly type: UiSettingsServiceOptions['type']; private readonly id: UiSettingsServiceOptions['id']; private readonly buildNum: UiSettingsServiceOptions['buildNum']; private readonly savedObjectsClient: UiSettingsServiceOptions['savedObjectsClient']; - private readonly overrides: NonNullable; - private readonly defaults: NonNullable; - private readonly defaultValues: Record; - private readonly log: Logger; private readonly cache: Cache; constructor(options: UiSettingsServiceOptions) { const { type, id, buildNum, savedObjectsClient, log, defaults = {}, overrides = {} } = options; + super({ overrides, defaults, log }); + this.type = type; this.id = id; this.buildNum = buildNum; this.savedObjectsClient = savedObjectsClient; - this.overrides = overrides; - this.log = log; this.cache = new Cache(); - this.defaults = defaults; - const defaultValues: Record = {}; - Object.keys(this.defaults).forEach((key) => { - defaultValues[key] = this.defaults[key].value; - }); - this.defaultValues = defaultValues; - } - - getRegistered() { - const copiedDefaults: Record = {}; - for (const [key, value] of Object.entries(this.defaults)) { - copiedDefaults[key] = omit(value, 'schema'); - } - return copiedDefaults; - } - - async get(key: string): Promise { - const all = await this.getAll(); - return all[key] as T; - } - - async getAll() { - const result = { ...this.defaultValues }; - - const userProvided = await this.getUserProvided(); - Object.keys(userProvided).forEach((key) => { - if (userProvided[key].userValue !== undefined) { - result[key] = userProvided[key].userValue; - } - }); - - Object.freeze(result); - return result as Record; } async getUserProvided(): Promise> { @@ -142,29 +96,12 @@ export class UiSettingsClient implements IUiSettingsClient { await this.setMany(changes); } - isOverridden(key: string) { - return this.overrides.hasOwnProperty(key); - } - - isSensitive(key: string): boolean { - const definition = this.defaults[key]; - return !!definition?.sensitive; - } - private assertUpdateAllowed(key: string) { if (this.isOverridden(key)) { throw new CannotOverrideError(`Unable to update "${key}" because it is overridden`); } } - 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) { for (const key of Object.keys(changes)) { this.assertUpdateAllowed(key); @@ -201,7 +138,6 @@ export class UiSettingsClient implements IUiSettingsClient { changes: Record; autoCreateOrUpgradeIfMissing?: boolean; }) { - assertSavedObjectsClient(this.savedObjectsClient); try { await this.savedObjectsClient.update(this.type, this.id, changes); } catch (error) { @@ -227,7 +163,6 @@ export class UiSettingsClient implements IUiSettingsClient { private async read({ autoCreateOrUpgradeIfMissing = true }: ReadOptions = {}): Promise< Record > { - assertSavedObjectsClient(this.savedObjectsClient); try { const resp = await this.savedObjectsClient.get>(this.type, this.id); return resp.attributes; @@ -257,7 +192,6 @@ export class UiSettingsClient implements IUiSettingsClient { } private isIgnorableError(error: Error) { - assertSavedObjectsClient(this.savedObjectsClient); const { isForbiddenError, isEsUnavailableError } = this.savedObjectsClient.errors; return isForbiddenError(error) || isEsUnavailableError(error); diff --git a/src/core/server/ui_settings/ui_settings_defaults_client.test.ts b/src/core/server/ui_settings/ui_settings_defaults_client.test.ts new file mode 100644 index 00000000000000..f3e779034ab4bc --- /dev/null +++ b/src/core/server/ui_settings/ui_settings_defaults_client.test.ts @@ -0,0 +1,188 @@ +/* + * 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 Chance from 'chance'; +import { schema } from '@kbn/config-schema'; + +import { loggingSystemMock } from '../logging/logging_system.mock'; + +import { UiSettingsDefaultsClient } from './ui_settings_defaults_client'; + +const logger = loggingSystemMock.create().get(); + +const chance = new Chance(); + +describe('ui settings defaults', () => { + afterEach(() => jest.clearAllMocks()); + + describe('#getUserProvided()', () => { + it('only returns overridden values', async () => { + const defaults = { foo: { schema: schema.string(), value: 'default foo' } }; + const overrides = { bar: 'overridden bar', baz: null }; + + const uiSettings = new UiSettingsDefaultsClient({ defaults, overrides, log: logger }); + await expect(uiSettings.getUserProvided()).resolves.toStrictEqual({ + bar: { userValue: 'overridden bar', isOverridden: true }, + baz: { isOverridden: true }, + }); + }); + }); + + describe('#getAll()', () => { + it('returns defaults and overridden values', async () => { + const defaults = { + foo: { schema: schema.string(), value: 'default foo' }, + bar: { schema: schema.string() }, + baz: { schema: schema.string(), value: 'default baz' }, + }; + const overrides = { foo: 'overridden foo', zab: 'overridden zab', baz: null }; + + const uiSettings = new UiSettingsDefaultsClient({ defaults, overrides, log: logger }); + + await expect(uiSettings.getAll()).resolves.toStrictEqual({ + foo: 'overridden foo', + bar: undefined, + baz: 'default baz', + zab: 'overridden zab', + }); + }); + + it('throws if mutates the result of getAll()', async () => { + const defaults = { + foo: { schema: schema.string(), value: 'default foo' }, + }; + const uiSettings = new UiSettingsDefaultsClient({ defaults, log: logger }); + const result = await uiSettings.getAll(); + + expect(() => { + result.foo = 'bar'; + }).toThrow(); + }); + }); + + describe('#get()', () => { + it('returns the overridden value for an overridden key', async () => { + const defaults = { + foo: { schema: schema.string(), value: 'default foo' }, + }; + const overrides = { foo: 'overridden foo' }; + const uiSettings = new UiSettingsDefaultsClient({ defaults, overrides, log: logger }); + + await expect(uiSettings.get('foo')).resolves.toBe('overridden foo'); + }); + + it('returns the default value for an override with value null', async () => { + const defaults = { + foo: { schema: schema.string(), value: 'default foo' }, + }; + const overrides = { foo: null }; + const uiSettings = new UiSettingsDefaultsClient({ defaults, overrides, log: logger }); + + await expect(uiSettings.get('foo')).resolves.toBe('default foo'); + }); + + it('returns the default value if there is no override', async () => { + const defaults = { + foo: { schema: schema.string(), value: 'default foo' }, + }; + const overrides = {}; + const uiSettings = new UiSettingsDefaultsClient({ defaults, overrides, log: logger }); + + await expect(uiSettings.get('foo')).resolves.toBe('default foo'); + }); + }); + + describe('#setMany()', () => { + it('does not throw', async () => { + const uiSettings = new UiSettingsDefaultsClient({ log: logger }); + await expect(uiSettings.setMany()).resolves.not.toThrow(); + }); + }); + + describe('#set()', () => { + it('does not throw', async () => { + const uiSettings = new UiSettingsDefaultsClient({ log: logger }); + await expect(uiSettings.set()).resolves.not.toThrow(); + }); + }); + + describe('#remove()', () => { + it('does not throw', async () => { + const uiSettings = new UiSettingsDefaultsClient({ log: logger }); + await expect(uiSettings.remove()).resolves.not.toThrow(); + }); + }); + + describe('#removeMany()', () => { + it('does not throw', async () => { + const uiSettings = new UiSettingsDefaultsClient({ log: logger }); + await expect(uiSettings.removeMany()).resolves.not.toThrow(); + }); + }); + + describe('#getRegistered()', () => { + it('returns the registered settings passed to the constructor and does not leak validation schema outside', () => { + const value = chance.word(); + const defaults = { key: { schema: schema.string(), value } }; + const uiSettings = new UiSettingsDefaultsClient({ defaults, log: logger }); + expect(uiSettings.getRegistered()).toStrictEqual({ key: { value } }); + }); + }); + + describe('#isSensitive()', () => { + it('returns false if sensitive config is not set', () => { + const defaults = { foo: { schema: schema.string(), value: '1' } }; + + const uiSettings = new UiSettingsDefaultsClient({ defaults, log: logger }); + expect(uiSettings.isSensitive('foo')).toBe(false); + }); + + it('returns false if key is not in the settings', () => { + const uiSettings = new UiSettingsDefaultsClient({ log: logger }); + expect(uiSettings.isSensitive('baz')).toBe(false); + }); + + it('returns true if `sensitive` is set', () => { + const defaults = { foo: { schema: schema.string(), sensitive: true, value: '1' } }; + + const uiSettings = new UiSettingsDefaultsClient({ defaults, log: logger }); + expect(uiSettings.isSensitive('foo')).toBe(true); + }); + }); + + describe('#isOverridden()', () => { + it('returns false if no overrides defined', () => { + const uiSettings = new UiSettingsDefaultsClient({ log: logger }); + expect(uiSettings.isOverridden('foo')).toBe(false); + }); + + it('returns false if overrides defined but key is not included', () => { + const uiSettings = new UiSettingsDefaultsClient({ + overrides: { foo: true, bar: true }, + log: logger, + }); + expect(uiSettings.isOverridden('baz')).toBe(false); + }); + + it('returns false for object prototype properties', () => { + const uiSettings = new UiSettingsDefaultsClient({ + overrides: { foo: true, bar: true }, + log: logger, + }); + expect(uiSettings.isOverridden('hasOwnProperty')).toBe(false); + }); + + it('returns true if overrides defined and key is overridden', () => { + const uiSettings = new UiSettingsDefaultsClient({ + overrides: { foo: true, bar: true }, + log: logger, + }); + expect(uiSettings.isOverridden('bar')).toBe(true); + }); + }); +}); diff --git a/src/core/server/ui_settings/ui_settings_defaults_client.ts b/src/core/server/ui_settings/ui_settings_defaults_client.ts new file mode 100644 index 00000000000000..fad90b9b0eaeed --- /dev/null +++ b/src/core/server/ui_settings/ui_settings_defaults_client.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { UiSettingsParams, UserProvidedValues } from './types'; +import { Logger } from '../logging'; +import { BaseUiSettingsClient } from './base_ui_settings_client'; + +export interface UiSettingsDefaultsClientOptions { + overrides?: Record; + defaults?: Record; + log: Logger; +} + +/** + * Implementation of the {@link IUiSettingsClient} that only gives a read-only access to the default UI Settings values and any overrides. + */ +export class UiSettingsDefaultsClient extends BaseUiSettingsClient { + private readonly userProvided: Record>; + + constructor(options: UiSettingsDefaultsClientOptions) { + super(options); + + // The only "userProvided" settings `UiSettingsDefaultsClient` is aware about are explicit overrides. + this.userProvided = Object.fromEntries( + Object.entries(this.overrides).map(([key, value]) => [ + key, + // Dropping the userValue if override is null + value === null ? { isOverridden: true } : { isOverridden: true, userValue: value }, + ]) + ); + } + + async getUserProvided(): Promise>> { + return this.userProvided as Record>; + } + + // Any mutating operations are not supported by default UI settings. + async setMany() { + this.log.warn('`setMany` operation is not supported.'); + } + + async set() { + this.log.warn('`set` operation is not supported.'); + } + + async remove() { + this.log.warn('`remove` operation is not supported.'); + } + + async removeMany() { + this.log.warn('`removeMany` operation is not supported.'); + } +} diff --git a/src/core/server/ui_settings/ui_settings_service.test.mock.ts b/src/core/server/ui_settings/ui_settings_service.test.mock.ts index afe8b401877bca..aae25563c2b887 100644 --- a/src/core/server/ui_settings/ui_settings_service.test.mock.ts +++ b/src/core/server/ui_settings/ui_settings_service.test.mock.ts @@ -11,6 +11,11 @@ jest.doMock('./ui_settings_client', () => ({ UiSettingsClient: MockUiSettingsClientConstructor, })); +export const MockUiSettingsDefaultsClientConstructor = jest.fn(); +jest.doMock('./ui_settings_defaults_client', () => ({ + UiSettingsDefaultsClient: MockUiSettingsDefaultsClientConstructor, +})); + export const getCoreSettingsMock = jest.fn(); jest.doMock('./settings', () => ({ getCoreSettings: getCoreSettingsMock, diff --git a/src/core/server/ui_settings/ui_settings_service.test.ts b/src/core/server/ui_settings/ui_settings_service.test.ts index 7f2423c9798a7e..b829f62d2263d7 100644 --- a/src/core/server/ui_settings/ui_settings_service.test.ts +++ b/src/core/server/ui_settings/ui_settings_service.test.ts @@ -11,6 +11,7 @@ import { schema } from '@kbn/config-schema'; import { MockUiSettingsClientConstructor, + MockUiSettingsDefaultsClientConstructor, getCoreSettingsMock, } from './ui_settings_service.test.mock'; import { UiSettingsService, SetupDeps } from './ui_settings_service'; @@ -19,7 +20,7 @@ import { savedObjectsClientMock } from '../mocks'; import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service.mock'; import { mockCoreContext } from '../core_context.mock'; import { uiSettingsType } from './saved_objects'; -import { UiSettingsClient } from './ui_settings_client'; +import { UiSettingsDefaultsClient } from './ui_settings_defaults_client'; const overrides = { overrideBaz: 'baz', @@ -65,11 +66,11 @@ describe('uiSettings', () => { const { createDefaultsClient } = await service.preboot(); const client = createDefaultsClient(); - expect(client).toBeInstanceOf(UiSettingsClient); + expect(client).toBeInstanceOf(UiSettingsDefaultsClient); - expect(MockUiSettingsClientConstructor).toBeCalledTimes(1); - const [[constructorArgs]] = MockUiSettingsClientConstructor.mock.calls; - expect(constructorArgs).toMatchObject({ type: 'config', overrides, defaults: {} }); + expect(MockUiSettingsDefaultsClientConstructor).toBeCalledTimes(1); + const [[constructorArgs]] = MockUiSettingsDefaultsClientConstructor.mock.calls; + expect(constructorArgs).toMatchObject({ overrides, defaults: {} }); expect(constructorArgs.overrides).toBe(overrides); }); }); diff --git a/src/core/server/ui_settings/ui_settings_service.ts b/src/core/server/ui_settings/ui_settings_service.ts index 5d7073487a72b5..d011b6e21907fe 100644 --- a/src/core/server/ui_settings/ui_settings_service.ts +++ b/src/core/server/ui_settings/ui_settings_service.ts @@ -27,6 +27,7 @@ import { import { uiSettingsType } from './saved_objects'; import { registerRoutes } from './routes'; import { getCoreSettings } from './settings'; +import { UiSettingsDefaultsClient } from './ui_settings_defaults_client'; export interface SetupDeps { http: InternalHttpServiceSetup; @@ -56,16 +57,12 @@ export class UiSettingsService this.register(getCoreSettings({ isDist: this.isDist })); - const { version, buildNum } = this.coreContext.env.packageInfo; return { createDefaultsClient: () => - new UiSettingsClient({ - type: 'config', - id: version, - buildNum, + new UiSettingsDefaultsClient({ defaults: mapToObject(this.uiSettingsDefaults), overrides: this.overrides, - log: this.log, + log: this.log.get('core defaults'), }), }; }