From 643f15c5d975eca1df4be948c01a78a749101296 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Tue, 19 Nov 2019 13:35:27 +0100 Subject: [PATCH] Licensing plugin (#49345) * Add x-pack plugin for new platform browser licensing information * Address next round of reviews * Remove poller functionality in favor of inline observables * More observable changes from review comments * Fix outstanding tests * More changes from review, adding additional testing * Add additional tests for license comparisons and sessions * Update test snapshot due to sessionstorage mock * Next round of review feedback from restrry * Fix more review requests from restrry, add additional tests * Pass correct sign mock to license info changed test * Improve doc comments, switch to I-interface pattern * Test error polling sanity, do not expose signature, do not poll on client * Fix type check issues from rebase * Fix build error from rebase * minimize config * move all types to server with consistency with other code * implement License * implement license update & refactor has License changed check * update tests for licensing extending route handler context * implement client side side license plugin * implement server side licensing plugin * remove old code * update testing harness * update types for license status * remove jest-localstorage-mock * fix tests * update license in security * address comments. first pass * error is a part of signature. pass error message to License * move common license types under common folder * rename feature props for BWC and unify name with ILicense * test should work in any timezone * make prettier happy * remove obsolete comment * address Pierre comments * use sha256 for security reasons * use stable stringify to avoid churn --- src/core/public/mocks.ts | 13 + src/core/utils/poller.test.ts | 71 ---- src/core/utils/poller.ts | 55 --- .../common/has_license_info_changed.test.ts | 93 +++++ .../common/has_license_info_changed.ts | 24 ++ .../plugins/licensing/common/license.mock.ts | 44 +++ .../plugins/licensing/common/license.test.ts | 117 ++++++ x-pack/plugins/licensing/common/license.ts | 155 ++++++++ .../licensing/common/license_update.test.ts | 152 ++++++++ .../licensing/common/license_update.ts | 38 ++ x-pack/plugins/licensing/common/types.ts | 187 ++++++++++ x-pack/plugins/licensing/kibana.json | 4 +- x-pack/plugins/licensing/public/index.ts | 11 + .../plugins/licensing/public/plugin.test.ts | 295 +++++++++++++++ x-pack/plugins/licensing/public/plugin.ts | 141 ++++++++ .../licensing/server/__fixtures__/setup.ts | 110 ------ x-pack/plugins/licensing/server/constants.ts | 21 -- x-pack/plugins/licensing/server/index.ts | 10 +- .../plugins/licensing/server/license.test.ts | 180 ---------- x-pack/plugins/licensing/server/license.ts | 178 ---------- .../licensing/server/license_feature.test.ts | 42 --- .../licensing/server/license_feature.ts | 34 -- .../licensing/server/licensing_config.ts | 23 +- .../licensing_route_handler_context.test.ts | 35 +- .../server/licensing_route_handler_context.ts | 11 +- .../plugins/licensing/server/plugin.test.ts | 336 ++++++++++++++---- x-pack/plugins/licensing/server/plugin.ts | 237 +++++++----- x-pack/plugins/licensing/server/schema.ts | 14 - x-pack/plugins/licensing/server/types.ts | 140 ++------ .../server/licensing/license_service.test.ts | 6 +- .../authorization/privileges/get.test.ts | 14 +- .../routes/authorization/roles/delete.test.ts | 9 +- .../routes/authorization/roles/get.test.ts | 9 +- .../authorization/roles/get_all.test.ts | 9 +- .../routes/authorization/roles/put.test.ts | 9 +- .../server/routes/licensed_route_handler.ts | 6 +- .../routes/api/__fixtures__/route_contexts.ts | 6 +- .../routes/lib/licensed_route_handler.ts | 6 +- x-pack/typings/index.d.ts | 6 +- 39 files changed, 1783 insertions(+), 1068 deletions(-) delete mode 100644 src/core/utils/poller.test.ts delete mode 100644 src/core/utils/poller.ts create mode 100644 x-pack/plugins/licensing/common/has_license_info_changed.test.ts create mode 100644 x-pack/plugins/licensing/common/has_license_info_changed.ts create mode 100644 x-pack/plugins/licensing/common/license.mock.ts create mode 100644 x-pack/plugins/licensing/common/license.test.ts create mode 100644 x-pack/plugins/licensing/common/license.ts create mode 100644 x-pack/plugins/licensing/common/license_update.test.ts create mode 100644 x-pack/plugins/licensing/common/license_update.ts create mode 100644 x-pack/plugins/licensing/common/types.ts create mode 100644 x-pack/plugins/licensing/public/index.ts create mode 100644 x-pack/plugins/licensing/public/plugin.test.ts create mode 100644 x-pack/plugins/licensing/public/plugin.ts delete mode 100644 x-pack/plugins/licensing/server/__fixtures__/setup.ts delete mode 100644 x-pack/plugins/licensing/server/constants.ts delete mode 100644 x-pack/plugins/licensing/server/license.test.ts delete mode 100644 x-pack/plugins/licensing/server/license.ts delete mode 100644 x-pack/plugins/licensing/server/license_feature.test.ts delete mode 100644 x-pack/plugins/licensing/server/license_feature.ts delete mode 100644 x-pack/plugins/licensing/server/schema.ts diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index b9cd2577c2217..afd0825ec986c 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -117,9 +117,22 @@ function createCoreContext(): CoreContext { }; } +function createStorageMock() { + const storageMock: jest.Mocked = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + key: jest.fn(), + length: 10, + }; + return storageMock; +} + export const coreMock = { createCoreContext, createSetup: createCoreSetupMock, createStart: createCoreStartMock, createPluginInitializerContext: pluginInitializerContextMock, + createStorage: createStorageMock, }; diff --git a/src/core/utils/poller.test.ts b/src/core/utils/poller.test.ts deleted file mode 100644 index df89f7341c956..0000000000000 --- a/src/core/utils/poller.test.ts +++ /dev/null @@ -1,71 +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 { Poller } from './poller'; - -const delay = (duration: number) => new Promise(r => setTimeout(r, duration)); - -// FLAKY: https://github.com/elastic/kibana/issues/44560 -describe.skip('Poller', () => { - let handler: jest.Mock; - let poller: Poller; - - beforeEach(() => { - handler = jest.fn().mockImplementation((iteration: number) => `polling-${iteration}`); - poller = new Poller(100, 'polling', handler); - }); - - afterEach(() => { - poller.unsubscribe(); - }); - - it('returns an observable of subject', async () => { - await delay(300); - expect(poller.subject$.getValue()).toBe('polling-2'); - }); - - it('executes a function on an interval', async () => { - await delay(300); - expect(handler).toBeCalledTimes(3); - }); - - it('no longer polls after unsubscribing', async () => { - await delay(300); - poller.unsubscribe(); - await delay(300); - expect(handler).toBeCalledTimes(3); - }); - - it('does not add next value if returns undefined', async () => { - const values: any[] = []; - const polling = new Poller(100, 'polling', iteration => { - if (iteration % 2 === 0) { - return `polling-${iteration}`; - } - }); - - polling.subject$.subscribe(value => { - values.push(value); - }); - await delay(300); - polling.unsubscribe(); - - expect(values).toEqual(['polling', 'polling-0', 'polling-2']); - }); -}); diff --git a/src/core/utils/poller.ts b/src/core/utils/poller.ts deleted file mode 100644 index 7c50db74bcefb..0000000000000 --- a/src/core/utils/poller.ts +++ /dev/null @@ -1,55 +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 { BehaviorSubject, timer } from 'rxjs'; - -/** - * Create an Observable BehaviorSubject to invoke a function on an interval - * which returns the next value for the observable. - * @public - */ -export class Poller { - /** - * The observable to observe for changes to the poller value. - */ - public readonly subject$ = new BehaviorSubject(this.initialValue); - private poller$ = timer(0, this.frequency); - private subscription = this.poller$.subscribe(async iteration => { - const next = await this.handler(iteration); - - if (next !== undefined) { - this.subject$.next(next); - } - - return iteration; - }); - - constructor( - private frequency: number, - private initialValue: T, - private handler: (iteration: number) => Promise | T | undefined - ) {} - - /** - * Permanently end the polling operation. - */ - unsubscribe() { - return this.subscription.unsubscribe(); - } -} diff --git a/x-pack/plugins/licensing/common/has_license_info_changed.test.ts b/x-pack/plugins/licensing/common/has_license_info_changed.test.ts new file mode 100644 index 0000000000000..08657826a5567 --- /dev/null +++ b/x-pack/plugins/licensing/common/has_license_info_changed.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { License } from './license'; +import { PublicLicense } from './types'; +import { hasLicenseInfoChanged } from './has_license_info_changed'; + +function license({ error, ...customLicense }: { error?: string; [key: string]: any } = {}) { + const defaultLicense: PublicLicense = { + uid: 'uid-000000001234', + status: 'active', + type: 'basic', + expiryDateInMillis: 1000, + }; + + return new License({ + error, + license: Object.assign(defaultLicense, customLicense), + signature: 'aaaaaaa', + }); +} + +// Each test should ensure that left-to-right and right-to-left comparisons are captured. +describe('has license info changed', () => { + describe('License', () => { + test('undefined <-> License', async () => { + expect(hasLicenseInfoChanged(undefined, license())).toBe(true); + }); + + test('the same License', async () => { + const licenseInstance = license(); + expect(hasLicenseInfoChanged(licenseInstance, licenseInstance)).toBe(false); + }); + + test('type License <-> type License | mismatched type', async () => { + expect(hasLicenseInfoChanged(license({ type: 'basic' }), license({ type: 'gold' }))).toBe( + true + ); + expect(hasLicenseInfoChanged(license({ type: 'gold' }), license({ type: 'basic' }))).toBe( + true + ); + }); + + test('status License <-> status License | mismatched status', async () => { + expect( + hasLicenseInfoChanged(license({ status: 'active' }), license({ status: 'inactive' })) + ).toBe(true); + expect( + hasLicenseInfoChanged(license({ status: 'inactive' }), license({ status: 'active' })) + ).toBe(true); + }); + + test('expiry License <-> expiry License | mismatched expiry', async () => { + expect( + hasLicenseInfoChanged( + license({ expiryDateInMillis: 100 }), + license({ expiryDateInMillis: 200 }) + ) + ).toBe(true); + expect( + hasLicenseInfoChanged( + license({ expiryDateInMillis: 200 }), + license({ expiryDateInMillis: 100 }) + ) + ).toBe(true); + }); + }); + + describe('error License', () => { + test('License <-> error License', async () => { + expect(hasLicenseInfoChanged(license({ error: 'reason' }), license())).toBe(true); + expect(hasLicenseInfoChanged(license(), license({ error: 'reason' }))).toBe(true); + }); + + test('error License <-> error License | matched messages', async () => { + expect( + hasLicenseInfoChanged(license({ error: 'reason-1' }), license({ error: 'reason-1' })) + ).toBe(false); + }); + + test('error License <-> error License | mismatched messages', async () => { + expect( + hasLicenseInfoChanged(license({ error: 'reason-1' }), license({ error: 'reason-2' })) + ).toBe(true); + expect( + hasLicenseInfoChanged(license({ error: 'reason-2' }), license({ error: 'reason-1' })) + ).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/licensing/common/has_license_info_changed.ts b/x-pack/plugins/licensing/common/has_license_info_changed.ts new file mode 100644 index 0000000000000..2b64aa41f16bc --- /dev/null +++ b/x-pack/plugins/licensing/common/has_license_info_changed.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ILicense } from './types'; + +/** + * Check if 2 potential license instances have changes between them + * @internal + */ +export function hasLicenseInfoChanged(currentLicense: ILicense | undefined, newLicense: ILicense) { + if (currentLicense === newLicense) return false; + if (!currentLicense) return true; + + return ( + newLicense.error !== currentLicense.error || + newLicense.type !== currentLicense.type || + newLicense.status !== currentLicense.status || + newLicense.expiryDateInMillis !== currentLicense.expiryDateInMillis || + newLicense.isAvailable !== currentLicense.isAvailable + ); +} diff --git a/x-pack/plugins/licensing/common/license.mock.ts b/x-pack/plugins/licensing/common/license.mock.ts new file mode 100644 index 0000000000000..f04ebeec81bdf --- /dev/null +++ b/x-pack/plugins/licensing/common/license.mock.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { PublicLicense, PublicFeatures } from './types'; +import { License } from './license'; + +function createLicense({ + license = {}, + features = {}, + signature = 'xxxxxxxxx', +}: { + license?: Partial; + features?: PublicFeatures; + signature?: string; +} = {}) { + const defaultLicense = { + uid: 'uid-000000001234', + status: 'active', + type: 'basic', + expiryDateInMillis: 5000, + }; + + const defaultFeatures = { + ccr: { + isEnabled: true, + isAvailable: true, + }, + ml: { + isEnabled: false, + isAvailable: true, + }, + }; + return new License({ + license: Object.assign(defaultLicense, license), + features: Object.assign(defaultFeatures, features), + signature, + }); +} + +export const licenseMock = { + create: createLicense, +}; diff --git a/x-pack/plugins/licensing/common/license.test.ts b/x-pack/plugins/licensing/common/license.test.ts new file mode 100644 index 0000000000000..6dbf009deabb7 --- /dev/null +++ b/x-pack/plugins/licensing/common/license.test.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { License } from './license'; +import { LICENSE_CHECK_STATE } from './types'; +import { licenseMock } from './license.mock'; + +describe('License', () => { + const basicLicense = licenseMock.create(); + const basicExpiredLicense = licenseMock.create({ license: { status: 'expired' } }); + const goldLicense = licenseMock.create({ license: { type: 'gold' } }); + + const errorMessage = 'unavailable'; + const errorLicense = new License({ error: errorMessage, signature: '' }); + const unavailableLicense = new License({ signature: '' }); + + it('uid', () => { + expect(basicLicense.uid).toBe('uid-000000001234'); + expect(errorLicense.uid).toBeUndefined(); + expect(unavailableLicense.uid).toBeUndefined(); + }); + + it('status', () => { + expect(basicLicense.status).toBe('active'); + expect(errorLicense.status).toBeUndefined(); + expect(unavailableLicense.status).toBeUndefined(); + }); + + it('expiryDateInMillis', () => { + expect(basicLicense.expiryDateInMillis).toBe(5000); + expect(errorLicense.expiryDateInMillis).toBeUndefined(); + expect(unavailableLicense.expiryDateInMillis).toBeUndefined(); + }); + + it('type', () => { + expect(basicLicense.type).toBe('basic'); + expect(goldLicense.type).toBe('gold'); + expect(errorLicense.type).toBeUndefined(); + expect(unavailableLicense.type).toBeUndefined(); + }); + + it('isActive', () => { + expect(basicLicense.isActive).toBe(true); + expect(basicExpiredLicense.isActive).toBe(false); + expect(errorLicense.isActive).toBe(false); + expect(unavailableLicense.isActive).toBe(false); + }); + + it('isBasic', () => { + expect(basicLicense.isBasic).toBe(true); + expect(goldLicense.isBasic).toBe(false); + expect(errorLicense.isBasic).toBe(false); + expect(unavailableLicense.isBasic).toBe(false); + }); + + it('isNotBasic', () => { + expect(basicLicense.isNotBasic).toBe(false); + expect(goldLicense.isNotBasic).toBe(true); + expect(errorLicense.isNotBasic).toBe(false); + expect(unavailableLicense.isNotBasic).toBe(false); + }); + + it('isOneOf', () => { + expect(basicLicense.isOneOf('platinum')).toBe(false); + expect(basicLicense.isOneOf(['platinum'])).toBe(false); + expect(basicLicense.isOneOf(['gold', 'platinum'])).toBe(false); + expect(basicLicense.isOneOf(['platinum', 'gold'])).toBe(false); + expect(basicLicense.isOneOf(['basic', 'gold'])).toBe(true); + expect(basicLicense.isOneOf(['basic'])).toBe(true); + expect(basicLicense.isOneOf('basic')).toBe(true); + + expect(errorLicense.isOneOf(['basic', 'gold', 'platinum'])).toBe(false); + + expect(unavailableLicense.isOneOf(['basic', 'gold', 'platinum'])).toBe(false); + }); + + it('getUnavailableReason', () => { + expect(basicLicense.getUnavailableReason()).toBe(undefined); + expect(errorLicense.getUnavailableReason()).toBe(errorMessage); + expect(unavailableLicense.getUnavailableReason()).toBe( + 'X-Pack plugin is not installed on the Elasticsearch cluster.' + ); + }); + + it('getFeature provides feature info', () => { + expect(basicLicense.getFeature('ml')).toEqual({ isEnabled: false, isAvailable: true }); + expect(basicLicense.getFeature('unknown')).toEqual({ isEnabled: false, isAvailable: false }); + expect(errorLicense.getFeature('ml')).toEqual({ isEnabled: false, isAvailable: false }); + expect(unavailableLicense.getFeature('ml')).toEqual({ isEnabled: false, isAvailable: false }); + }); + + describe('check', () => { + it('provides availability status', () => { + expect(basicLicense.check('ccr', 'gold').state).toBe(LICENSE_CHECK_STATE.Invalid); + + expect(goldLicense.check('ccr', 'gold').state).toBe(LICENSE_CHECK_STATE.Valid); + expect(goldLicense.check('ccr', 'basic').state).toBe(LICENSE_CHECK_STATE.Valid); + + expect(basicExpiredLicense.check('ccr', 'gold').state).toBe(LICENSE_CHECK_STATE.Expired); + + expect(errorLicense.check('ccr', 'basic').state).toBe(LICENSE_CHECK_STATE.Unavailable); + expect(errorLicense.check('ccr', 'gold').state).toBe(LICENSE_CHECK_STATE.Unavailable); + + expect(unavailableLicense.check('ccr', 'basic').state).toBe(LICENSE_CHECK_STATE.Unavailable); + expect(unavailableLicense.check('ccr', 'gold').state).toBe(LICENSE_CHECK_STATE.Unavailable); + }); + + it('throws in case of unknown license type', () => { + expect( + () => basicLicense.check('ccr', 'any' as any).state + ).toThrowErrorMatchingInlineSnapshot(`"\\"any\\" is not a valid license type"`); + }); + }); +}); diff --git a/x-pack/plugins/licensing/common/license.ts b/x-pack/plugins/licensing/common/license.ts new file mode 100644 index 0000000000000..b8327ac554107 --- /dev/null +++ b/x-pack/plugins/licensing/common/license.ts @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { + LicenseType, + ILicense, + LicenseStatus, + LICENSE_CHECK_STATE, + LICENSE_TYPE, + PublicLicenseJSON, + PublicLicense, + PublicFeatures, +} from './types'; + +/** + * @public + */ +export class License implements ILicense { + private readonly license?: PublicLicense; + private readonly features?: PublicFeatures; + + public readonly error?: string; + public readonly isActive: boolean; + public readonly isAvailable: boolean; + public readonly isBasic: boolean; + public readonly isNotBasic: boolean; + + public readonly uid?: string; + public readonly status?: LicenseStatus; + public readonly expiryDateInMillis?: number; + public readonly type?: LicenseType; + public readonly signature: string; + + /** + * @internal + * Generate a License instance from json representation. + */ + static fromJSON(json: PublicLicenseJSON) { + return new License(json); + } + + constructor({ + license, + features, + error, + signature, + }: { + license?: PublicLicense; + features?: PublicFeatures; + error?: string; + signature: string; + }) { + this.isAvailable = Boolean(license); + this.license = license; + this.features = features; + this.error = error; + this.signature = signature; + + if (license) { + this.uid = license.uid; + this.status = license.status; + this.expiryDateInMillis = license.expiryDateInMillis; + this.type = license.type; + } + + this.isActive = this.status === 'active'; + this.isBasic = this.isActive && this.type === 'basic'; + this.isNotBasic = this.isActive && this.type !== 'basic'; + } + + toJSON() { + return { + license: this.license, + features: this.features, + signature: this.signature, + }; + } + + getUnavailableReason() { + if (this.error) return this.error; + if (!this.isAvailable) { + return 'X-Pack plugin is not installed on the Elasticsearch cluster.'; + } + } + + isOneOf(candidateLicenses: LicenseType | LicenseType[]) { + if (!this.type) { + return false; + } + + if (!Array.isArray(candidateLicenses)) { + candidateLicenses = [candidateLicenses]; + } + + return candidateLicenses.includes(this.type); + } + + check(pluginName: string, minimumLicenseRequired: LicenseType) { + if (!(minimumLicenseRequired in LICENSE_TYPE)) { + throw new Error(`"${minimumLicenseRequired}" is not a valid license type`); + } + + if (!this.isAvailable) { + return { + state: LICENSE_CHECK_STATE.Unavailable, + message: i18n.translate('xpack.licensing.check.errorUnavailableMessage', { + defaultMessage: + 'You cannot use {pluginName} because license information is not available at this time.', + values: { pluginName }, + }), + }; + } + + const type = this.type!; + + if (!this.isActive) { + return { + state: LICENSE_CHECK_STATE.Expired, + message: i18n.translate('xpack.licensing.check.errorExpiredMessage', { + defaultMessage: + 'You cannot use {pluginName} because your {licenseType} license has expired.', + values: { licenseType: type, pluginName }, + }), + }; + } + + if (LICENSE_TYPE[type] < LICENSE_TYPE[minimumLicenseRequired]) { + return { + state: LICENSE_CHECK_STATE.Invalid, + message: i18n.translate('xpack.licensing.check.errorUnsupportedMessage', { + defaultMessage: + 'Your {licenseType} license does not support {pluginName}. Please upgrade your license.', + values: { licenseType: type, pluginName }, + }), + }; + } + + return { state: LICENSE_CHECK_STATE.Valid }; + } + + getFeature(name: string) { + if (this.isAvailable && this.features && this.features.hasOwnProperty(name)) { + return { ...this.features[name] }; + } + + return { + isAvailable: false, + isEnabled: false, + }; + } +} diff --git a/x-pack/plugins/licensing/common/license_update.test.ts b/x-pack/plugins/licensing/common/license_update.test.ts new file mode 100644 index 0000000000000..345085d3e3a8f --- /dev/null +++ b/x-pack/plugins/licensing/common/license_update.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Subject } from 'rxjs'; +import { take, toArray } from 'rxjs/operators'; + +import { ILicense, LicenseType } from './types'; +import { createLicenseUpdate } from './license_update'; +import { licenseMock } from './license.mock'; + +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +describe('licensing update', () => { + it('loads updates when triggered', async () => { + const types: LicenseType[] = ['basic', 'gold']; + + const trigger$ = new Subject(); + const fetcher = jest + .fn() + .mockImplementation(() => + Promise.resolve(licenseMock.create({ license: { type: types.shift() } })) + ); + + const { update$ } = createLicenseUpdate(trigger$, fetcher); + + expect(fetcher).toHaveBeenCalledTimes(0); + + trigger$.next(); + const first = await update$.pipe(take(1)).toPromise(); + expect(first.type).toBe('basic'); + + trigger$.next(); + const [, second] = await update$.pipe(take(2), toArray()).toPromise(); + expect(second.type).toBe('gold'); + }); + + it('starts with initial value if presents', async () => { + const initialLicense = licenseMock.create({ license: { type: 'platinum' } }); + const fetchedLicense = licenseMock.create({ license: { type: 'gold' } }); + const trigger$ = new Subject(); + + const fetcher = jest.fn().mockResolvedValue(fetchedLicense); + const { update$ } = createLicenseUpdate(trigger$, fetcher, initialLicense); + trigger$.next(); + const [first, second] = await update$.pipe(take(2), toArray()).toPromise(); + + expect(first.type).toBe('platinum'); + expect(second.type).toBe('gold'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('does not emit if license has not changed', async () => { + const trigger$ = new Subject(); + + let i = 0; + const fetcher = jest + .fn() + .mockImplementation(() => + Promise.resolve( + ++i < 3 ? licenseMock.create() : licenseMock.create({ license: { type: 'gold' } }) + ) + ); + + const { update$ } = createLicenseUpdate(trigger$, fetcher); + trigger$.next(); + + const [first] = await update$.pipe(take(1), toArray()).toPromise(); + + expect(first.type).toBe('basic'); + + trigger$.next(); + trigger$.next(); + + const [, second] = await update$.pipe(take(2), toArray()).toPromise(); + + expect(second.type).toBe('gold'); + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it('new subscriptions does not force re-fetch', async () => { + const trigger$ = new Subject(); + + const fetcher = jest.fn().mockResolvedValue(licenseMock.create()); + + const { update$ } = createLicenseUpdate(trigger$, fetcher); + + update$.subscribe(() => {}); + update$.subscribe(() => {}); + update$.subscribe(() => {}); + trigger$.next(); + + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('handles fetcher race condition', async () => { + const delayMs = 100; + let firstCall = true; + const fetcher = jest.fn().mockImplementation( + () => + new Promise(resolve => { + if (firstCall) { + firstCall = false; + setTimeout(() => resolve(licenseMock.create()), delayMs); + } else { + resolve(licenseMock.create({ license: { type: 'gold' } })); + } + }) + ); + const trigger$ = new Subject(); + const { update$ } = createLicenseUpdate(trigger$, fetcher); + const values: ILicense[] = []; + update$.subscribe(license => values.push(license)); + + trigger$.next(); + trigger$.next(); + + await delay(delayMs * 2); + + await expect(fetcher).toHaveBeenCalledTimes(2); + await expect(values).toHaveLength(1); + await expect(values[0].type).toBe('gold'); + }); + + it('completes update$ stream when trigger is completed', () => { + const trigger$ = new Subject(); + const fetcher = jest.fn().mockResolvedValue(licenseMock.create()); + + const { update$ } = createLicenseUpdate(trigger$, fetcher); + let completed = false; + update$.subscribe({ complete: () => (completed = true) }); + + trigger$.complete(); + expect(completed).toBe(true); + }); + + it('stops fetching when fetch subscription unsubscribed', () => { + const trigger$ = new Subject(); + const fetcher = jest.fn().mockResolvedValue(licenseMock.create()); + + const { update$, fetchSubscription } = createLicenseUpdate(trigger$, fetcher); + const values: ILicense[] = []; + update$.subscribe(license => values.push(license)); + + fetchSubscription.unsubscribe(); + trigger$.next(); + + expect(fetcher).toHaveBeenCalledTimes(0); + }); +}); diff --git a/x-pack/plugins/licensing/common/license_update.ts b/x-pack/plugins/licensing/common/license_update.ts new file mode 100644 index 0000000000000..254ea680460ee --- /dev/null +++ b/x-pack/plugins/licensing/common/license_update.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ConnectableObservable, Observable, from, merge } from 'rxjs'; + +import { filter, map, pairwise, switchMap, publishReplay } from 'rxjs/operators'; +import { hasLicenseInfoChanged } from './has_license_info_changed'; +import { ILicense } from './types'; + +export function createLicenseUpdate( + trigger$: Observable, + fetcher: () => Promise, + initialValues?: ILicense +) { + const fetched$ = trigger$.pipe( + switchMap(fetcher), + publishReplay(1) + // have to cast manually as pipe operator cannot return ConnectableObservable + // https://github.com/ReactiveX/rxjs/issues/2972 + ) as ConnectableObservable; + + const fetchSubscription = fetched$.connect(); + + const initialValues$ = initialValues ? from([undefined, initialValues]) : from([undefined]); + + const update$: Observable = merge(initialValues$, fetched$).pipe( + pairwise(), + filter(([previous, next]) => hasLicenseInfoChanged(previous, next!)), + map(([, next]) => next!) + ); + + return { + update$, + fetchSubscription, + }; +} diff --git a/x-pack/plugins/licensing/common/types.ts b/x-pack/plugins/licensing/common/types.ts new file mode 100644 index 0000000000000..c8edd8fd0cca8 --- /dev/null +++ b/x-pack/plugins/licensing/common/types.ts @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Observable } from 'rxjs'; + +export enum LICENSE_CHECK_STATE { + Unavailable = 'UNAVAILABLE', + Invalid = 'INVALID', + Expired = 'EXPIRED', + Valid = 'VALID', +} + +export enum LICENSE_TYPE { + basic = 10, + standard = 20, + gold = 30, + platinum = 40, + trial = 50, +} + +/** @public */ +export type LicenseType = keyof typeof LICENSE_TYPE; + +/** @public */ +export type LicenseStatus = 'active' | 'invalid' | 'expired'; + +/** @public */ +export interface LicenseFeature { + isAvailable: boolean; + isEnabled: boolean; +} + +/** + * Subset of license data considered as non-sensitive information. + * Can be passed to the client. + * @public + * */ +export interface PublicLicense { + /** + * UID for license. + */ + uid: string; + + /** + * The validity status of the license. + */ + status: LicenseStatus; + + /** + * Unix epoch of the expiration date of the license. + */ + expiryDateInMillis: number; + + /** + * The license type, being usually one of basic, standard, gold, platinum, or trial. + */ + type: LicenseType; +} + +/** + * Provides information about feature availability for the current license. + * @public + * */ +export type PublicFeatures = Record; + +/** + * Subset of license & features data considered as non-sensitive information. + * Structured as json to be passed to the client. + * @public + * */ +export interface PublicLicenseJSON { + license?: PublicLicense; + features?: PublicFeatures; + signature: string; +} + +/** + * @public + * Results from checking if a particular license type meets the minimum + * requirements of the license type. + */ +export interface LicenseCheck { + /** + * The state of checking the results of a license type meeting the license minimum. + */ + state: LICENSE_CHECK_STATE; + /** + * A message containing the reason for a license type not being valid. + */ + message?: string; +} + +/** @public */ +export interface ILicense { + /** + * UID for license. + */ + uid?: string; + + /** + * The validity status of the license. + */ + status?: LicenseStatus; + + /** + * Determine if the status of the license is active. + */ + isActive: boolean; + + /** + * Unix epoch of the expiration date of the license. + */ + expiryDateInMillis?: number; + + /** + * The license type, being usually one of basic, standard, gold, platinum, or trial. + */ + type?: LicenseType; + + /** + * Signature of the license content. + */ + signature: string; + + /** + * Determine if the license container has information. + */ + isAvailable: boolean; + + /** + * Determine if the type of the license is basic, and also active. + */ + isBasic: boolean; + + /** + * Determine if the type of the license is not basic, and also active. + */ + isNotBasic: boolean; + + /** + * Returns + */ + toJSON: () => PublicLicenseJSON; + + /** + * A potential error denoting the failure of the license from being retrieved. + */ + error?: string; + + /** + * If the license is not available, provides a string or Error containing the reason. + */ + getUnavailableReason: () => string | undefined; + + /** + * Determine if the provided license types match against the license type. + * @param candidateLicenses license types to intersect against the license. + */ + isOneOf(candidateLicenses: LicenseType | LicenseType[]): boolean; + + /** + * For a given plugin and license type, receive information about the status of the license. + * @param pluginName the name of the plugin + * @param minimumLicenseRequired the minimum valid license for operating the given plugin + */ + check(pluginName: string, minimumLicenseRequired: LicenseType): LicenseCheck; + + /** + * A specific API for interacting with the specific features of the license. + * @param name the name of the feature to interact with + */ + getFeature(name: string): LicenseFeature; +} + +/** @public */ +export interface LicensingPluginSetup { + /** + * Steam of licensing information {@link ILicense}. + */ + license$: Observable; + /** + * Triggers licensing information re-fetch. + */ + refresh(): void; +} diff --git a/x-pack/plugins/licensing/kibana.json b/x-pack/plugins/licensing/kibana.json index a76ce1ef6a23c..9edaa726c6ba9 100644 --- a/x-pack/plugins/licensing/kibana.json +++ b/x-pack/plugins/licensing/kibana.json @@ -2,7 +2,7 @@ "id": "licensing", "version": "0.0.1", "kibanaVersion": "kibana", - "configPath": ["x-pack", "licensing"], + "configPath": ["xpack", "licensing"], "server": true, - "ui": false + "ui": true } diff --git a/x-pack/plugins/licensing/public/index.ts b/x-pack/plugins/licensing/public/index.ts new file mode 100644 index 0000000000000..32e911bb2cdd2 --- /dev/null +++ b/x-pack/plugins/licensing/public/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { LicensingPlugin } from './plugin'; + +export * from '../common/types'; +export const plugin = (context: PluginInitializerContext) => new LicensingPlugin(context); diff --git a/x-pack/plugins/licensing/public/plugin.test.ts b/x-pack/plugins/licensing/public/plugin.test.ts new file mode 100644 index 0000000000000..8ede881cad47e --- /dev/null +++ b/x-pack/plugins/licensing/public/plugin.test.ts @@ -0,0 +1,295 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { take } from 'rxjs/operators'; + +import { LicenseType } from '../common/types'; +import { LicensingPlugin, licensingSessionStorageKey } from './plugin'; + +import { License } from '../common/license'; +import { licenseMock } from '../common/license.mock'; +import { coreMock } from '../../../../src/core/public/mocks'; +import { HttpInterceptor } from 'src/core/public'; + +describe('licensing plugin', () => { + let plugin: LicensingPlugin; + + afterEach(async () => { + await plugin.stop(); + }); + + describe('#setup', () => { + describe('#refresh', () => { + it('forces data re-fetch', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + const fetchedLicense = licenseMock.create({ license: { uid: 'fetched' } }); + coreSetup.http.get.mockResolvedValue(fetchedLicense); + + const { license$, refresh } = await plugin.setup(coreSetup); + + refresh(); + const license = await license$.pipe(take(1)).toPromise(); + + expect(license.uid).toBe('fetched'); + }); + }); + + describe('#license$', () => { + it('starts with license saved in sessionStorage if available', async () => { + const sessionStorage = coreMock.createStorage(); + const savedLicense = licenseMock.create({ license: { uid: 'saved' } }); + sessionStorage.getItem.mockReturnValue(JSON.stringify(savedLicense)); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + const { license$ } = await plugin.setup(coreSetup); + + const license = await license$.pipe(take(1)).toPromise(); + expect(license.isAvailable).toBe(true); + expect(license.uid).toBe('saved'); + + expect(sessionStorage.getItem).toBeCalledTimes(1); + expect(sessionStorage.getItem).toHaveBeenCalledWith(licensingSessionStorageKey); + }); + + it('observable receives updated licenses', async done => { + const types: LicenseType[] = ['gold', 'platinum']; + + const sessionStorage = coreMock.createStorage(); + sessionStorage.getItem.mockReturnValue(JSON.stringify(licenseMock.create())); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + coreSetup.http.get.mockImplementation(() => + Promise.resolve(licenseMock.create({ license: { type: types.shift() } })) + ); + const { license$, refresh } = await plugin.setup(coreSetup); + + let i = 0; + license$.subscribe(value => { + i++; + if (i === 1) { + expect(value.type).toBe('basic'); + refresh(); + } else if (i === 2) { + expect(value.type).toBe('gold'); + refresh(); + } else if (i === 3) { + expect(value.type).toBe('platinum'); + done(); + } else { + throw new Error('unreachable'); + } + }); + }); + + it('saved fetched license & signature in session storage', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + + const fetchedLicense = licenseMock.create({ license: { uid: 'fresh' } }); + coreSetup.http.get.mockResolvedValue(fetchedLicense); + + const { license$, refresh } = await plugin.setup(coreSetup); + + refresh(); + const license = await license$.pipe(take(1)).toPromise(); + + expect(license.uid).toBe('fresh'); + + expect(sessionStorage.setItem).toBeCalledTimes(1); + + expect(sessionStorage.setItem.mock.calls[0][0]).toBe(licensingSessionStorageKey); + expect(sessionStorage.setItem.mock.calls[0][1]).toMatchInlineSnapshot( + `"{\\"license\\":{\\"uid\\":\\"fresh\\",\\"status\\":\\"active\\",\\"type\\":\\"basic\\",\\"expiryDateInMillis\\":5000},\\"features\\":{\\"ccr\\":{\\"isEnabled\\":true,\\"isAvailable\\":true},\\"ml\\":{\\"isEnabled\\":false,\\"isAvailable\\":true}},\\"signature\\":\\"xxxxxxxxx\\"}"` + ); + + const saved = JSON.parse(sessionStorage.setItem.mock.calls[0][1]); + expect(License.fromJSON(saved).toJSON()).toEqual(fetchedLicense.toJSON()); + }); + + it('returns a license with error when request fails', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + coreSetup.http.get.mockRejectedValue(new Error('reason')); + + const { license$, refresh } = await plugin.setup(coreSetup); + refresh(); + + const license = await license$.pipe(take(1)).toPromise(); + + expect(license.isAvailable).toBe(false); + expect(license.error).toBe('reason'); + }); + + it('remove license saved in session storage when request failed', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + coreSetup.http.get.mockRejectedValue(new Error('sorry')); + + const { license$, refresh } = await plugin.setup(coreSetup); + expect(sessionStorage.removeItem).toHaveBeenCalledTimes(0); + + refresh(); + await license$.pipe(take(1)).toPromise(); + + expect(sessionStorage.removeItem).toHaveBeenCalledTimes(1); + expect(sessionStorage.removeItem).toHaveBeenCalledWith(licensingSessionStorageKey); + }); + }); + }); + describe('interceptor', () => { + it('register http interceptor checking signature header', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + + await plugin.setup(coreSetup); + expect(coreSetup.http.intercept).toHaveBeenCalledTimes(1); + }); + + it('http interceptor triggers re-fetch if signature header has changed', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + + coreSetup.http.get.mockResolvedValue(licenseMock.create({ signature: 'signature-1' })); + + let registeredInterceptor: HttpInterceptor; + coreSetup.http.intercept.mockImplementation((interceptor: HttpInterceptor) => { + registeredInterceptor = interceptor; + return () => undefined; + }); + + const { license$ } = await plugin.setup(coreSetup); + expect(registeredInterceptor!.response).toBeDefined(); + + const httpResponse = { + response: { + headers: { + get(name: string) { + if (name === 'kbn-xpack-sig') { + return 'signature-1'; + } + throw new Error('unexpected header'); + }, + }, + }, + request: { + url: 'http://10.10.10.10:5601/api/hello', + }, + }; + expect(coreSetup.http.get).toHaveBeenCalledTimes(0); + + await registeredInterceptor!.response!(httpResponse as any, null as any); + + expect(coreSetup.http.get).toHaveBeenCalledTimes(1); + + const license = await license$.pipe(take(1)).toPromise(); + expect(license.isAvailable).toBe(true); + + await registeredInterceptor!.response!(httpResponse as any, null as any); + + expect(coreSetup.http.get).toHaveBeenCalledTimes(1); + }); + + it('http interceptor does not trigger re-fetch if requested x-pack/info endpoint', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + + let registeredInterceptor: HttpInterceptor; + coreSetup.http.intercept.mockImplementation((interceptor: HttpInterceptor) => { + registeredInterceptor = interceptor; + return () => undefined; + }); + + const { license$ } = await plugin.setup(coreSetup); + + let updated = false; + license$.subscribe(() => (updated = true)); + + expect(registeredInterceptor!.response).toBeDefined(); + + const httpResponse = { + response: { + headers: { + get(name: string) { + if (name === 'kbn-xpack-sig') { + return 'signature-1'; + } + throw new Error('unexpected header'); + }, + }, + }, + request: { + url: 'http://10.10.10.10:5601/api/xpack/v1/info', + }, + }; + expect(coreSetup.http.get).toHaveBeenCalledTimes(0); + + await registeredInterceptor!.response!(httpResponse as any, null as any); + + expect(coreSetup.http.get).toHaveBeenCalledTimes(0); + + expect(updated).toBe(false); + }); + }); + describe('#stop', () => { + it('stops polling', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + const coreSetup = coreMock.createSetup(); + const { license$ } = await plugin.setup(coreSetup); + + let completed = false; + license$.subscribe({ complete: () => (completed = true) }); + + await plugin.stop(); + expect(completed).toBe(true); + }); + + it('refresh does not trigger data re-fetch', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + const coreSetup = coreMock.createSetup(); + const { refresh } = await plugin.setup(coreSetup); + + await plugin.stop(); + + refresh(); + + expect(coreSetup.http.get).toHaveBeenCalledTimes(0); + }); + + it('removes http interceptor', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + + const removeInterceptorMock = jest.fn(); + coreSetup.http.intercept.mockReturnValue(removeInterceptorMock); + + await plugin.setup(coreSetup); + await plugin.stop(); + + expect(removeInterceptorMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/licensing/public/plugin.ts b/x-pack/plugins/licensing/public/plugin.ts new file mode 100644 index 0000000000000..c1b13418aa3e7 --- /dev/null +++ b/x-pack/plugins/licensing/public/plugin.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Subject, Subscription, merge } from 'rxjs'; +import { takeUntil, tap } from 'rxjs/operators'; + +import { CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; + +import { ILicense, LicensingPluginSetup } from '../common/types'; +import { createLicenseUpdate } from '../common/license_update'; +import { License } from '../common/license'; + +export const licensingSessionStorageKey = 'xpack.licensing'; + +/** + * @public + * A plugin for fetching, refreshing, and receiving information about the license for the + * current Kibana instance. + */ +export class LicensingPlugin implements Plugin { + /** + * Used as a flag to halt all other plugin observables. + */ + private stop$ = new Subject(); + + /** + * A function to execute once the plugin's HTTP interceptor needs to stop listening. + */ + private removeInterceptor?: () => void; + private licenseFetchSubscription?: Subscription; + + private infoEndpoint = '/api/xpack/v1/info'; + private prevSignature?: string; + + constructor( + context: PluginInitializerContext, + private readonly storage: Storage = sessionStorage + ) {} + + /** + * Fetch the objectified license and signature from storage. + */ + private getSaved(): ILicense | undefined { + const raw = this.storage.getItem(licensingSessionStorageKey); + if (!raw) return; + return License.fromJSON(JSON.parse(raw)); + } + + /** + * Store the given license and signature in storage. + */ + private save(license: ILicense) { + this.storage.setItem(licensingSessionStorageKey, JSON.stringify(license)); + } + + /** + * Clear license and signature information from storage. + */ + private removeSaved() { + this.storage.removeItem(licensingSessionStorageKey); + } + + public setup(core: CoreSetup) { + const manualRefresh$ = new Subject(); + const signatureUpdated$ = new Subject(); + const refresh$ = merge(signatureUpdated$, manualRefresh$).pipe(takeUntil(this.stop$)); + + const savedLicense = this.getSaved(); + const { update$, fetchSubscription } = createLicenseUpdate( + refresh$, + () => this.fetchLicense(core), + savedLicense + ); + this.licenseFetchSubscription = fetchSubscription; + + const license$ = update$.pipe( + tap(license => { + if (license.error) { + this.prevSignature = undefined; + // Prevent reusing stale license if the fetch operation fails + this.removeSaved(); + } else { + this.prevSignature = license.signature; + this.save(license); + } + }) + ); + + this.removeInterceptor = core.http.intercept({ + response: async httpResponse => { + if (httpResponse.response) { + const signatureHeader = httpResponse.response.headers.get('kbn-xpack-sig'); + if (this.prevSignature !== signatureHeader) { + if (!httpResponse.request!.url.includes(this.infoEndpoint)) { + signatureUpdated$.next(); + } + } + } + return httpResponse; + }, + }); + + return { + refresh: () => { + manualRefresh$.next(); + }, + license$, + }; + } + + public async start() {} + + public stop() { + this.stop$.next(); + this.stop$.complete(); + + if (this.removeInterceptor !== undefined) { + this.removeInterceptor(); + } + if (this.licenseFetchSubscription !== undefined) { + this.licenseFetchSubscription.unsubscribe(); + this.licenseFetchSubscription = undefined; + } + } + + private fetchLicense = async (core: CoreSetup): Promise => { + try { + const response = await core.http.get(this.infoEndpoint); + return new License({ + license: response.license, + features: response.features, + signature: response.signature, + }); + } catch (error) { + return new License({ error: error.message, signature: '' }); + } + }; +} diff --git a/x-pack/plugins/licensing/server/__fixtures__/setup.ts b/x-pack/plugins/licensing/server/__fixtures__/setup.ts deleted file mode 100644 index 02574d0851ba0..0000000000000 --- a/x-pack/plugins/licensing/server/__fixtures__/setup.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { take, skip } from 'rxjs/operators'; -import { merge } from 'lodash'; -import { ClusterClient } from 'src/core/server'; -import { coreMock } from '../../../../../src/core/server/mocks'; -import { Plugin } from '../plugin'; -import { schema } from '../schema'; - -export async function licenseMerge(xpackInfo = {}) { - return merge( - { - license: { - uid: '00000000-0000-0000-0000-000000000000', - type: 'basic', - mode: 'basic', - status: 'active', - }, - features: { - ccr: { - available: false, - enabled: true, - }, - data_frame: { - available: true, - enabled: true, - }, - graph: { - available: false, - enabled: true, - }, - ilm: { - available: true, - enabled: true, - }, - logstash: { - available: false, - enabled: true, - }, - ml: { - available: false, - enabled: true, - }, - monitoring: { - available: true, - enabled: true, - }, - rollup: { - available: true, - enabled: true, - }, - security: { - available: true, - enabled: true, - }, - sql: { - available: true, - enabled: true, - }, - vectors: { - available: true, - enabled: true, - }, - voting_only: { - available: true, - enabled: true, - }, - watcher: { - available: false, - enabled: true, - }, - }, - }, - xpackInfo - ); -} - -export async function setupOnly(pluginInitializerContext: any = {}) { - const coreSetup = coreMock.createSetup(); - const clusterClient = ((await coreSetup.elasticsearch.dataClient$ - .pipe(take(1)) - .toPromise()) as unknown) as jest.Mocked>; - const plugin = new Plugin( - coreMock.createPluginInitializerContext({ - config: schema.validate(pluginInitializerContext.config || {}), - }) - ); - - return { coreSetup, plugin, clusterClient }; -} - -export async function setup(xpackInfo = {}, pluginInitializerContext: any = {}) { - const { coreSetup, clusterClient, plugin } = await setupOnly(pluginInitializerContext); - - clusterClient.callAsInternalUser.mockResolvedValueOnce(licenseMerge(xpackInfo)); - - const { license$ } = await plugin.setup(coreSetup); - const license = await license$.pipe(skip(1), take(1)).toPromise(); - - return { - plugin, - license$, - license, - clusterClient, - }; -} diff --git a/x-pack/plugins/licensing/server/constants.ts b/x-pack/plugins/licensing/server/constants.ts deleted file mode 100644 index f2823ea00933c..0000000000000 --- a/x-pack/plugins/licensing/server/constants.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export const SERVICE_NAME = 'licensing'; -export const DEFAULT_POLLING_FREQUENCY = 30001; // 30 seconds -export enum LICENSE_STATUS { - Unavailable = 'UNAVAILABLE', - Invalid = 'INVALID', - Expired = 'EXPIRED', - Valid = 'VALID', -} -export enum LICENSE_TYPE { - basic = 10, - standard = 20, - gold = 30, - platinum = 40, - trial = 50, -} diff --git a/x-pack/plugins/licensing/server/index.ts b/x-pack/plugins/licensing/server/index.ts index 49415b63bc3b7..fff9ccc296ce3 100644 --- a/x-pack/plugins/licensing/server/index.ts +++ b/x-pack/plugins/licensing/server/index.ts @@ -5,9 +5,9 @@ */ import { PluginInitializerContext } from 'src/core/server'; -import { schema } from './schema'; -import { Plugin } from './plugin'; +import { LicensingPlugin } from './plugin'; -export * from './types'; -export const config = { schema }; -export const plugin = (context: PluginInitializerContext) => new Plugin(context); +export const plugin = (context: PluginInitializerContext) => new LicensingPlugin(context); + +export * from '../common/types'; +export { config } from './licensing_config'; diff --git a/x-pack/plugins/licensing/server/license.test.ts b/x-pack/plugins/licensing/server/license.test.ts deleted file mode 100644 index 1c308a6280449..0000000000000 --- a/x-pack/plugins/licensing/server/license.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ILicense } from './types'; -import { Plugin } from './plugin'; -import { LICENSE_STATUS } from './constants'; -import { LicenseFeature } from './license_feature'; -import { setup } from './__fixtures__/setup'; - -describe('license', () => { - let plugin: Plugin; - let license: ILicense; - - afterEach(async () => { - await plugin.stop(); - }); - - test('uid returns a UID field', async () => { - ({ plugin, license } = await setup()); - - expect(license.uid).toBe('00000000-0000-0000-0000-000000000000'); - }); - - test('isActive returns true if status is active', async () => { - ({ plugin, license } = await setup()); - - expect(license.isActive).toBe(true); - }); - - test('isActive returns false if status is not active', async () => { - ({ plugin, license } = await setup({ - license: { - status: 'aCtIvE', // needs to match exactly - }, - })); - - expect(license.isActive).toBe(false); - }); - - test('expiryDateInMillis returns expiry_date_in_millis', async () => { - const expiry = Date.now(); - - ({ plugin, license } = await setup({ - license: { - expiry_date_in_millis: expiry, - }, - })); - - expect(license.expiryDateInMillis).toBe(expiry); - }); - - test('isOneOf returns true if the type includes one of the license types', async () => { - ({ plugin, license } = await setup({ - license: { - type: 'platinum', - }, - })); - - expect(license.isOneOf('platinum')).toBe(true); - expect(license.isOneOf(['platinum'])).toBe(true); - expect(license.isOneOf(['gold', 'platinum'])).toBe(true); - expect(license.isOneOf(['platinum', 'gold'])).toBe(true); - expect(license.isOneOf(['basic', 'gold'])).toBe(false); - expect(license.isOneOf(['basic'])).toBe(false); - }); - - test('type returns the license type', async () => { - ({ plugin, license } = await setup()); - - expect(license.type).toBe('basic'); - }); - - test('returns feature API with getFeature', async () => { - ({ plugin, license } = await setup()); - - const security = license.getFeature('security'); - const fake = license.getFeature('fake'); - - expect(security).toBeInstanceOf(LicenseFeature); - expect(fake).toBeInstanceOf(LicenseFeature); - }); - - describe('isActive', () => { - test('should return Valid if active and check matches', async () => { - ({ plugin, license } = await setup({ - license: { - type: 'gold', - }, - })); - - expect(license.check('test', 'basic').check).toBe(LICENSE_STATUS.Valid); - expect(license.check('test', 'gold').check).toBe(LICENSE_STATUS.Valid); - }); - - test('should return Invalid if active and check does not match', async () => { - ({ plugin, license } = await setup()); - - const { check } = license.check('test', 'gold'); - - expect(check).toBe(LICENSE_STATUS.Invalid); - }); - - test('should return Unavailable if missing license', async () => { - ({ plugin, license } = await setup({ license: null })); - - const { check } = license.check('test', 'gold'); - - expect(check).toBe(LICENSE_STATUS.Unavailable); - }); - - test('should return Expired if not active', async () => { - ({ plugin, license } = await setup({ - license: { - status: 'not-active', - }, - })); - - const { check } = license.check('test', 'basic'); - - expect(check).toBe(LICENSE_STATUS.Expired); - }); - }); - - describe('basic', () => { - test('isBasic is true if active and basic', async () => { - ({ plugin, license } = await setup()); - - expect(license.isBasic).toBe(true); - }); - - test('isBasic is false if active and not basic', async () => { - ({ plugin, license } = await setup({ - license: { - type: 'gold', - }, - })); - - expect(license.isBasic).toBe(false); - }); - - test('isBasic is false if not active and basic', async () => { - ({ plugin, license } = await setup({ - license: { - status: 'not-active', - }, - })); - - expect(license.isBasic).toBe(false); - }); - - test('isNotBasic is false if not active', async () => { - ({ plugin, license } = await setup({ - license: { - status: 'not-active', - }, - })); - - expect(license.isNotBasic).toBe(false); - }); - - test('isNotBasic is true if active and not basic', async () => { - ({ plugin, license } = await setup({ - license: { - type: 'gold', - }, - })); - - expect(license.isNotBasic).toBe(true); - }); - - test('isNotBasic is false if active and basic', async () => { - ({ plugin, license } = await setup()); - - expect(license.isNotBasic).toBe(false); - }); - }); -}); diff --git a/x-pack/plugins/licensing/server/license.ts b/x-pack/plugins/licensing/server/license.ts deleted file mode 100644 index 4d2d1d3fb41ba..0000000000000 --- a/x-pack/plugins/licensing/server/license.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { createHash } from 'crypto'; -import { LicenseFeature } from './license_feature'; -import { LICENSE_STATUS, LICENSE_TYPE } from './constants'; -import { LicenseType, ILicense } from './types'; - -function toLicenseType(minimumLicenseRequired: LICENSE_TYPE | string) { - if (typeof minimumLicenseRequired !== 'string') { - return minimumLicenseRequired; - } - - if (!(minimumLicenseRequired in LICENSE_TYPE)) { - throw new Error(`${minimumLicenseRequired} is not a valid license type`); - } - - return LICENSE_TYPE[minimumLicenseRequired as LicenseType]; -} - -export class License implements ILicense { - private readonly hasLicense: boolean; - private readonly license: any; - private readonly features: any; - private _signature!: string; - private objectified!: any; - private readonly featuresMap: Map; - - constructor( - license: any, - features: any, - private error: Error | null, - private clusterSource: string - ) { - this.hasLicense = Boolean(license); - this.license = license || {}; - this.features = features; - this.featuresMap = new Map(); - } - - public get uid() { - return this.license.uid; - } - - public get status() { - return this.license.status; - } - - public get isActive() { - return this.status === 'active'; - } - - public get expiryDateInMillis() { - return this.license.expiry_date_in_millis; - } - - public get type() { - return this.license.type; - } - - public get isAvailable() { - return this.hasLicense; - } - - public get isBasic() { - return this.isActive && this.type === 'basic'; - } - - public get isNotBasic() { - return this.isActive && this.type !== 'basic'; - } - - public get reasonUnavailable() { - if (!this.isAvailable) { - return `[${this.clusterSource}] Elasticsearch cluster did not respond with license information.`; - } - - if (this.error instanceof Error && (this.error as any).status === 400) { - return `X-Pack plugin is not installed on the [${this.clusterSource}] Elasticsearch cluster.`; - } - - return this.error; - } - - public get signature() { - if (this._signature !== undefined) { - return this._signature; - } - - this._signature = createHash('md5') - .update(JSON.stringify(this.toObject())) - .digest('hex'); - - return this._signature; - } - - isOneOf(candidateLicenses: string | string[]) { - if (!Array.isArray(candidateLicenses)) { - candidateLicenses = [candidateLicenses]; - } - - return candidateLicenses.includes(this.type); - } - - meetsMinimumOf(minimum: LICENSE_TYPE) { - return LICENSE_TYPE[this.type as LicenseType] >= minimum; - } - - check(pluginName: string, minimumLicenseRequired: LICENSE_TYPE | string) { - const minimum = toLicenseType(minimumLicenseRequired); - - if (!this.isAvailable) { - return { - check: LICENSE_STATUS.Unavailable, - message: i18n.translate('xpack.licensing.check.errorUnavailableMessage', { - defaultMessage: - 'You cannot use {pluginName} because license information is not available at this time.', - values: { pluginName }, - }), - }; - } - - const { type: licenseType } = this.license; - - if (!this.meetsMinimumOf(minimum)) { - return { - check: LICENSE_STATUS.Invalid, - message: i18n.translate('xpack.licensing.check.errorUnsupportedMessage', { - defaultMessage: - 'Your {licenseType} license does not support {pluginName}. Please upgrade your license.', - values: { licenseType, pluginName }, - }), - }; - } - - if (!this.isActive) { - return { - check: LICENSE_STATUS.Expired, - message: i18n.translate('xpack.licensing.check.errorExpiredMessage', { - defaultMessage: - 'You cannot use {pluginName} because your {licenseType} license has expired.', - values: { licenseType, pluginName }, - }), - }; - } - - return { check: LICENSE_STATUS.Valid }; - } - - toObject() { - if (this.objectified) { - return this.objectified; - } - - this.objectified = { - license: { - type: this.type, - isActive: this.isActive, - expiryDateInMillis: this.expiryDateInMillis, - }, - features: [...this.featuresMap].map(([, feature]) => feature.toObject()), - }; - - return this.objectified; - } - - getFeature(name: string) { - if (!this.featuresMap.has(name)) { - this.featuresMap.set(name, new LicenseFeature(name, this.features[name], this)); - } - - return this.featuresMap.get(name); - } -} diff --git a/x-pack/plugins/licensing/server/license_feature.test.ts b/x-pack/plugins/licensing/server/license_feature.test.ts deleted file mode 100644 index d36fa2cca48ba..0000000000000 --- a/x-pack/plugins/licensing/server/license_feature.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ILicense } from './types'; -import { Plugin } from './plugin'; -import { setup } from './__fixtures__/setup'; - -describe('licensing feature', () => { - let plugin: Plugin; - let license: ILicense; - - afterEach(async () => { - await plugin.stop(); - }); - - test('isAvailable', async () => { - ({ plugin, license } = await setup()); - - const security = license.getFeature('security'); - - expect(security!.isAvailable).toBe(true); - }); - - test('isEnabled', async () => { - ({ plugin, license } = await setup()); - - const security = license.getFeature('security'); - - expect(security!.isEnabled).toBe(true); - }); - - test('name', async () => { - ({ plugin, license } = await setup()); - - const security = license.getFeature('security'); - - expect(security!.name).toBe('security'); - }); -}); diff --git a/x-pack/plugins/licensing/server/license_feature.ts b/x-pack/plugins/licensing/server/license_feature.ts deleted file mode 100644 index 58c5b81e7af74..0000000000000 --- a/x-pack/plugins/licensing/server/license_feature.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { License } from './license'; -import { LicenseFeatureSerializer } from './types'; - -export class LicenseFeature { - private serializable: LicenseFeatureSerializer = license => ({ - name: this.name, - isAvailable: this.isAvailable, - isEnabled: this.isEnabled, - }); - - constructor(public name: string, private feature: any = {}, private license: License) {} - - public get isAvailable() { - return !!this.feature.available; - } - - public get isEnabled() { - return !!this.feature.enabled; - } - - public onObject(serializable: LicenseFeatureSerializer) { - this.serializable = serializable; - } - - public toObject() { - return this.serializable(this.license); - } -} diff --git a/x-pack/plugins/licensing/server/licensing_config.ts b/x-pack/plugins/licensing/server/licensing_config.ts index a5fd3d0a7b046..7be19398828e9 100644 --- a/x-pack/plugins/licensing/server/licensing_config.ts +++ b/x-pack/plugins/licensing/server/licensing_config.ts @@ -4,20 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext } from 'src/core/server'; -import { LicensingConfigType } from './types'; +import { schema, TypeOf } from '@kbn/config-schema'; -export class LicensingConfig { - public isEnabled: boolean; - public clusterSource: string; - public pollingFrequency: number; +const SECOND = 1000; +export const config = { + schema: schema.object({ + pollingFrequency: schema.number({ defaultValue: 30 * SECOND }), + }), +}; - /** - * @internal - */ - constructor(rawConfig: LicensingConfigType, env: PluginInitializerContext['env']) { - this.isEnabled = rawConfig.isEnabled; - this.clusterSource = rawConfig.clusterSource; - this.pollingFrequency = rawConfig.pollingFrequency; - } -} +export type LicenseConfigType = TypeOf; diff --git a/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts b/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts index 81ad9715da784..82af786482d58 100644 --- a/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts +++ b/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts @@ -5,32 +5,23 @@ */ import { BehaviorSubject } from 'rxjs'; -import { ILicense } from './types'; -import { setup } from './__fixtures__/setup'; -import { createRouteHandlerContext } from './licensing_route_handler_context'; - -describe('licensingRouteHandlerContext', () => { - it('provides the initial license value', async () => { - const { license$, license } = await setup(); - - const context = createRouteHandlerContext(license$); - - const { license: contextResult } = await context({}, {} as any, {} as any); +import { licenseMock } from '../common/license.mock'; - expect(contextResult).toBe(license); - }); - - it('provides the latest license value', async () => { - const { license } = await setup(); - const license$ = new BehaviorSubject(license); +import { createRouteHandlerContext } from './licensing_route_handler_context'; - const context = createRouteHandlerContext(license$); +describe('createRouteHandlerContext', () => { + it('returns a function providing the last license value', async () => { + const firstLicense = licenseMock.create(); + const secondLicense = licenseMock.create(); + const license$ = new BehaviorSubject(firstLicense); - const latestLicense = (Symbol() as unknown) as ILicense; - license$.next(latestLicense); + const routeHandler = createRouteHandlerContext(license$); - const { license: contextResult } = await context({}, {} as any, {} as any); + const firstCtx = await routeHandler({}, {} as any, {} as any); + license$.next(secondLicense); + const secondCtx = await routeHandler({}, {} as any, {} as any); - expect(contextResult).toBe(latestLicense); + expect(firstCtx.license).toBe(firstLicense); + expect(secondCtx.license).toBe(secondLicense); }); }); diff --git a/x-pack/plugins/licensing/server/licensing_route_handler_context.ts b/x-pack/plugins/licensing/server/licensing_route_handler_context.ts index 8ee49e9aa084f..42cb0959fc373 100644 --- a/x-pack/plugins/licensing/server/licensing_route_handler_context.ts +++ b/x-pack/plugins/licensing/server/licensing_route_handler_context.ts @@ -7,13 +7,18 @@ import { IContextProvider, RequestHandler } from 'src/core/server'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; -import { ILicense } from './types'; +import { ILicense } from '../common/types'; + +/** + * Create a route handler context for access to Kibana license information. + * @param license$ An observable of a License instance. + * @public + */ export function createRouteHandlerContext( license$: Observable ): IContextProvider, 'licensing'> { return async function licensingRouteHandlerContext() { - const license = await license$.pipe(take(1)).toPromise(); - return { license }; + return { license: await license$.pipe(take(1)).toPromise() }; }; } diff --git a/x-pack/plugins/licensing/server/plugin.test.ts b/x-pack/plugins/licensing/server/plugin.test.ts index a85e1fb0e8f8f..2af3637a2aaf0 100644 --- a/x-pack/plugins/licensing/server/plugin.test.ts +++ b/x-pack/plugins/licensing/server/plugin.test.ts @@ -4,92 +4,296 @@ * you may not use this file except in compliance with the Elastic License. */ -import { take, skip } from 'rxjs/operators'; -import { ILicense } from './types'; -import { Plugin } from './plugin'; -import { License } from './license'; -import { setup, setupOnly, licenseMerge } from './__fixtures__/setup'; +import { BehaviorSubject } from 'rxjs'; +import { take, toArray } from 'rxjs/operators'; +import { LicenseType } from '../common/types'; +import { ElasticsearchError, RawLicense } from './types'; +import { LicensingPlugin } from './plugin'; +import { + coreMock, + elasticsearchServiceMock, + loggingServiceMock, +} from '../../../../src/core/server/mocks'; + +function buildRawLicense(options: Partial = {}): RawLicense { + const defaultRawLicense: RawLicense = { + uid: 'uid-000000001234', + status: 'active', + type: 'basic', + expiry_date_in_millis: 1000, + }; + return Object.assign(defaultRawLicense, options); +} +const pollingFrequency = 100; + +const flushPromises = (ms = 50) => new Promise(res => setTimeout(res, ms)); describe('licensing plugin', () => { - let plugin: Plugin; - let license: ILicense; + describe('#setup', () => { + describe('#license$', () => { + let plugin: LicensingPlugin; + let pluginInitContextMock: ReturnType; - afterEach(async () => { - await plugin.stop(); - }); + beforeEach(() => { + pluginInitContextMock = coreMock.createPluginInitializerContext({ + pollingFrequency, + }); + plugin = new LicensingPlugin(pluginInitContextMock); + }); - test('returns instance of licensing setup', async () => { - ({ plugin, license } = await setup()); - expect(license).toBeInstanceOf(License); - }); + afterEach(async () => { + await plugin.stop(); + }); - test('still returns instance of licensing setup when request fails', async () => { - const { clusterClient, coreSetup, plugin: _plugin } = await setupOnly(); + it('returns license', async () => { + const dataClient = elasticsearchServiceMock.createClusterClient(); + dataClient.callAsInternalUser.mockResolvedValue({ + license: buildRawLicense(), + features: {}, + }); + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); - plugin = _plugin; - clusterClient.callAsInternalUser.mockRejectedValue(new Error('test')); + const { license$ } = await plugin.setup(coreSetup); + const license = await license$.pipe(take(1)).toPromise(); + expect(license.isAvailable).toBe(true); + }); - const { license$ } = await plugin.setup(coreSetup); - const finalLicense = await license$.pipe(skip(1), take(1)).toPromise(); + it('observable receives updated licenses', async () => { + const types: LicenseType[] = ['basic', 'gold', 'platinum']; - expect(finalLicense).toBeInstanceOf(License); - }); + const dataClient = elasticsearchServiceMock.createClusterClient(); + dataClient.callAsInternalUser.mockImplementation(() => + Promise.resolve({ + license: buildRawLicense({ type: types.shift() }), + features: {}, + }) + ); + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); - test('observable receives updated licenses', async () => { - const { clusterClient, coreSetup, plugin: _plugin } = await setupOnly({ - config: { - pollingFrequency: 100, - }, - }); - const types = ['basic', 'gold', 'platinum']; - let iterations = 0; - - plugin = _plugin; - clusterClient.callAsInternalUser.mockImplementation(() => { - return Promise.resolve( - licenseMerge({ - license: { - type: types[iterations++], - }, - }) - ); + const { license$ } = await plugin.setup(coreSetup); + const [first, second, third] = await license$.pipe(take(3), toArray()).toPromise(); + + expect(first.type).toBe('basic'); + expect(second.type).toBe('gold'); + expect(third.type).toBe('platinum'); + }); + + it('returns a license with error when request fails', async () => { + const dataClient = elasticsearchServiceMock.createClusterClient(); + dataClient.callAsInternalUser.mockRejectedValue(new Error('test')); + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + + const { license$ } = await plugin.setup(coreSetup); + const license = await license$.pipe(take(1)).toPromise(); + expect(license.isAvailable).toBe(false); + expect(license.error).toBeDefined(); + }); + + it('generate error message when x-pack plugin was not installed', async () => { + const dataClient = elasticsearchServiceMock.createClusterClient(); + const error: ElasticsearchError = new Error('reason'); + error.status = 400; + dataClient.callAsInternalUser.mockRejectedValue(error); + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + + const { license$ } = await plugin.setup(coreSetup); + const license = await license$.pipe(take(1)).toPromise(); + expect(license.isAvailable).toBe(false); + expect(license.error).toBe('X-Pack plugin is not installed on the Elasticsearch cluster.'); + }); + + it('polling continues even if there are errors', async () => { + const error1 = new Error('reason-1'); + const error2 = new Error('reason-2'); + + const dataClient = elasticsearchServiceMock.createClusterClient(); + + dataClient.callAsInternalUser + .mockRejectedValueOnce(error1) + .mockRejectedValueOnce(error2) + .mockResolvedValue({ license: buildRawLicense(), features: {} }); + + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + + const { license$ } = await plugin.setup(coreSetup); + const [first, second, third] = await license$.pipe(take(3), toArray()).toPromise(); + + expect(first.error).toBe(error1.message); + expect(second.error).toBe(error2.message); + expect(third.type).toBe('basic'); + }); + + it('fetch license immediately without subscriptions', async () => { + const dataClient = elasticsearchServiceMock.createClusterClient(); + dataClient.callAsInternalUser.mockResolvedValue({ + license: buildRawLicense(), + features: {}, + }); + + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + + await plugin.setup(coreSetup); + await flushPromises(); + expect(dataClient.callAsInternalUser).toHaveBeenCalledTimes(1); + }); + + it('logs license details without subscriptions', async () => { + const dataClient = elasticsearchServiceMock.createClusterClient(); + dataClient.callAsInternalUser.mockResolvedValue({ + license: buildRawLicense(), + features: {}, + }); + + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + + await plugin.setup(coreSetup); + await flushPromises(); + + const loggedMessages = loggingServiceMock.collect(pluginInitContextMock.logger).debug; + + expect( + loggedMessages.some(([message]) => + message.startsWith( + 'Imported license information from Elasticsearch:type: basic | status: active | expiry date:' + ) + ) + ).toBe(true); + }); + + it('generates signature based on fetched license content', async () => { + const types: LicenseType[] = ['basic', 'gold', 'basic']; + + const dataClient = elasticsearchServiceMock.createClusterClient(); + dataClient.callAsInternalUser.mockImplementation(() => + Promise.resolve({ + license: buildRawLicense({ type: types.shift() }), + features: {}, + }) + ); + + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + + const { license$ } = await plugin.setup(coreSetup); + const [first, second, third] = await license$.pipe(take(3), toArray()).toPromise(); + + expect(first.signature === third.signature).toBe(true); + expect(first.signature === second.signature).toBe(false); + }); }); - const { license$ } = await plugin.setup(coreSetup); - const licenseTypes: any[] = []; + describe('#refresh', () => { + let plugin: LicensingPlugin; + afterEach(async () => { + await plugin.stop(); + }); - await new Promise(resolve => { - const subscription = license$.subscribe(next => { - if (!next.type) { - return; - } + it('forces refresh immediately', async () => { + plugin = new LicensingPlugin( + coreMock.createPluginInitializerContext({ + // disable polling mechanism + pollingFrequency: 50000, + }) + ); + const dataClient = elasticsearchServiceMock.createClusterClient(); + dataClient.callAsInternalUser.mockResolvedValue({ + license: buildRawLicense(), + features: {}, + }); + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); + const { refresh } = await plugin.setup(coreSetup); - if (iterations > 3) { - subscription.unsubscribe(); - resolve(); - } else { - licenseTypes.push(next.type); - } + expect(dataClient.callAsInternalUser).toHaveBeenCalledTimes(0); + + refresh(); + expect(dataClient.callAsInternalUser).toHaveBeenCalledTimes(1); + + refresh(); + expect(dataClient.callAsInternalUser).toHaveBeenCalledTimes(2); }); }); - expect(licenseTypes).toEqual(['basic', 'gold', 'platinum']); + describe('extends core contexts', () => { + let plugin: LicensingPlugin; + + beforeEach(() => { + plugin = new LicensingPlugin( + coreMock.createPluginInitializerContext({ + pollingFrequency, + }) + ); + }); + + afterEach(async () => { + await plugin.stop(); + }); + + it('provides a licensing context to http routes', async () => { + const coreSetup = coreMock.createSetup(); + + await plugin.setup(coreSetup); + + expect(coreSetup.http.registerRouteHandlerContext.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "licensing", + [Function], + ], + ] + `); + }); + }); }); - test('provides a licensing context to http routes', async () => { - const { coreSetup, plugin: _plugin } = await setupOnly(); + describe('#stop', () => { + it('stops polling', async () => { + const plugin = new LicensingPlugin( + coreMock.createPluginInitializerContext({ + pollingFrequency, + }) + ); + const coreSetup = coreMock.createSetup(); + const { license$ } = await plugin.setup(coreSetup); + + let completed = false; + license$.subscribe({ complete: () => (completed = true) }); + + await plugin.stop(); + expect(completed).toBe(true); + }); + + it('refresh does not trigger data re-fetch', async () => { + const plugin = new LicensingPlugin( + coreMock.createPluginInitializerContext({ + pollingFrequency, + }) + ); + + const dataClient = elasticsearchServiceMock.createClusterClient(); + dataClient.callAsInternalUser.mockResolvedValue({ + license: buildRawLicense(), + features: {}, + }); + + const coreSetup = coreMock.createSetup(); + coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient); - plugin = _plugin; + const { refresh } = await plugin.setup(coreSetup); - await plugin.setup(coreSetup); + dataClient.callAsInternalUser.mockClear(); - expect(coreSetup.http.registerRouteHandlerContext.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "licensing", - [Function], - ], - ] - `); + await plugin.stop(); + refresh(); + + expect(dataClient.callAsInternalUser).toHaveBeenCalledTimes(0); + }); }); }); diff --git a/x-pack/plugins/licensing/server/plugin.ts b/x-pack/plugins/licensing/server/plugin.ts index 4cd40379b8592..3c93b55723787 100644 --- a/x-pack/plugins/licensing/server/plugin.ts +++ b/x-pack/plugins/licensing/server/plugin.ts @@ -4,145 +4,186 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Observable } from 'rxjs'; -import { first, map } from 'rxjs/operators'; +import { Observable, Subject, Subscription, merge, timer } from 'rxjs'; +import { take, takeUntil } from 'rxjs/operators'; import moment from 'moment'; +import { createHash } from 'crypto'; +import stringify from 'json-stable-stringify'; + import { CoreSetup, CoreStart, Logger, - Plugin as CorePlugin, + Plugin, PluginInitializerContext, + IClusterClient, } from 'src/core/server'; -import { Poller } from '../../../../src/core/utils/poller'; -import { LicensingConfigType, LicensingPluginSetup, ILicense } from './types'; -import { LicensingConfig } from './licensing_config'; -import { License } from './license'; + +import { ILicense, LicensingPluginSetup, PublicLicense, PublicFeatures } from '../common/types'; +import { License } from '../common/license'; +import { createLicenseUpdate } from '../common/license_update'; + +import { ElasticsearchError, RawLicense, RawFeatures } from './types'; +import { LicenseConfigType } from './licensing_config'; import { createRouteHandlerContext } from './licensing_route_handler_context'; -declare module 'src/core/server' { - interface RequestHandlerContext { - licensing: { - license: ILicense; +function normalizeServerLicense(license: RawLicense): PublicLicense { + return { + uid: license.uid, + type: license.type, + expiryDateInMillis: license.expiry_date_in_millis, + status: license.status, + }; +} + +function normalizeFeatures(rawFeatures: RawFeatures) { + const features: PublicFeatures = {}; + for (const [name, feature] of Object.entries(rawFeatures)) { + features[name] = { + isAvailable: feature.available, + isEnabled: feature.enabled, }; } + return features; } -export class Plugin implements CorePlugin { +function sign({ + license, + features, + error, +}: { + license?: PublicLicense; + features?: PublicFeatures; + error?: string; +}) { + return createHash('sha256') + .update( + stringify({ + license, + features, + error, + }) + ) + .digest('hex'); +} + +/** + * @public + * A plugin for fetching, refreshing, and receiving information about the license for the + * current Kibana instance. + */ +export class LicensingPlugin implements Plugin { + private stop$ = new Subject(); private readonly logger: Logger; - private readonly config$: Observable; - private poller!: Poller; + private readonly config$: Observable; + private licenseFetchSubscription?: Subscription; + private loggingSubscription?: Subscription; constructor(private readonly context: PluginInitializerContext) { this.logger = this.context.logger.get(); - this.config$ = this.context.config - .create() - .pipe( - map(config => - 'config' in config - ? new LicensingConfig(config.config, this.context.env) - : new LicensingConfig(config, this.context.env) - ) - ); + this.config$ = this.context.config.create(); } - private hasLicenseInfoChanged(newLicense: any) { - const currentLicense = this.poller.subject$.getValue(); + public async setup(core: CoreSetup) { + this.logger.debug('Setting up Licensing plugin'); + const config = await this.config$.pipe(take(1)).toPromise(); + const dataClient = await core.elasticsearch.dataClient$.pipe(take(1)).toPromise(); - if ((currentLicense && !newLicense) || (newLicense && !currentLicense)) { - return true; - } + const { refresh, license$ } = this.createLicensePoller(dataClient, config.pollingFrequency); - return ( - newLicense.type !== currentLicense.type || - newLicense.status !== currentLicense.status || - newLicense.expiry_date_in_millis !== currentLicense.expiryDateInMillis - ); + core.http.registerRouteHandlerContext('licensing', createRouteHandlerContext(license$)); + + return { + refresh, + license$, + }; } - private async fetchInfo(core: CoreSetup, clusterSource: string, pollingFrequency: number) { - this.logger.debug( - `Calling [${clusterSource}] Elasticsearch _xpack API. Polling frequency: ${pollingFrequency}` + private createLicensePoller(clusterClient: IClusterClient, pollingFrequency: number) { + const manualRefresh$ = new Subject(); + const intervalRefresh$ = timer(0, pollingFrequency); + const refresh$ = merge(intervalRefresh$, manualRefresh$).pipe(takeUntil(this.stop$)); + + const { update$, fetchSubscription } = createLicenseUpdate(refresh$, () => + this.fetchLicense(clusterClient) + ); + + this.licenseFetchSubscription = fetchSubscription; + this.loggingSubscription = update$.subscribe(license => + this.logger.debug( + 'Imported license information from Elasticsearch:' + + [ + `type: ${license.type}`, + `status: ${license.status}`, + `expiry date: ${moment(license.expiryDateInMillis, 'x').format()}`, + ].join(' | ') + ) ); - const cluster = await core.elasticsearch.dataClient$.pipe(first()).toPromise(); + return { + refresh: () => { + this.logger.debug('Requesting Elasticsearch licensing API'); + manualRefresh$.next(); + }, + license$: update$, + }; + } + private fetchLicense = async (clusterClient: IClusterClient): Promise => { try { - const response = await cluster.callAsInternalUser('transport.request', { + const response = await clusterClient.callAsInternalUser('transport.request', { method: 'GET', path: '/_xpack', }); - const rawLicense = response && response.license; - const features = (response && response.features) || {}; - const licenseInfoChanged = this.hasLicenseInfoChanged(rawLicense); - - if (!licenseInfoChanged) { - return { license: false, error: null, features: null }; - } - - const currentLicense = this.poller.subject$.getValue(); - const licenseInfo = [ - 'type' in rawLicense && `type: ${rawLicense.type}`, - 'status' in rawLicense && `status: ${rawLicense.status}`, - 'expiry_date_in_millis' in rawLicense && - `expiry date: ${moment(rawLicense.expiry_date_in_millis, 'x').format()}`, - ] - .filter(Boolean) - .join(' | '); - - this.logger.info( - `Imported ${currentLicense ? 'changed ' : ''}license information` + - ` from Elasticsearch for the [${clusterSource}] cluster: ${licenseInfo}` - ); - return { license: rawLicense, error: null, features }; - } catch (err) { + const normalizedLicense = normalizeServerLicense(response.license); + const normalizedFeatures = normalizeFeatures(response.features); + const signature = sign({ + license: normalizedLicense, + features: normalizedFeatures, + error: '', + }); + + return new License({ + license: normalizedLicense, + features: normalizedFeatures, + signature, + }); + } catch (error) { this.logger.warn( - `License information could not be obtained from Elasticsearch` + - ` for the [${clusterSource}] cluster. ${err}` + `License information could not be obtained from Elasticsearch due to ${error} error` ); + const errorMessage = this.getErrorMessage(error); + const signature = sign({ error: errorMessage }); - return { license: null, error: err, features: {} }; + return new License({ + error: this.getErrorMessage(error), + signature, + }); } - } - - private create({ clusterSource, pollingFrequency }: LicensingConfig, core: CoreSetup) { - this.poller = new Poller( - pollingFrequency, - new License(null, {}, null, clusterSource), - async () => { - const { license, features, error } = await this.fetchInfo( - core, - clusterSource, - pollingFrequency - ); - - if (license !== false) { - return new License(license, features, error, clusterSource); - } - } - ); - - return this.poller; - } + }; - public async setup(core: CoreSetup) { - const config = await this.config$.pipe(first()).toPromise(); - const poller = this.create(config, core); - const license$ = poller.subject$.asObservable(); - - core.http.registerRouteHandlerContext('licensing', createRouteHandlerContext(license$)); - - return { - license$, - }; + private getErrorMessage(error: ElasticsearchError): string { + if (error.status === 400) { + return 'X-Pack plugin is not installed on the Elasticsearch cluster.'; + } + return error.message; } public async start(core: CoreStart) {} public stop() { - if (this.poller) { - this.poller.unsubscribe(); + this.stop$.next(); + this.stop$.complete(); + + if (this.licenseFetchSubscription !== undefined) { + this.licenseFetchSubscription.unsubscribe(); + this.licenseFetchSubscription = undefined; + } + + if (this.loggingSubscription !== undefined) { + this.loggingSubscription.unsubscribe(); + this.loggingSubscription = undefined; } } } diff --git a/x-pack/plugins/licensing/server/schema.ts b/x-pack/plugins/licensing/server/schema.ts deleted file mode 100644 index cfc467677f7b6..0000000000000 --- a/x-pack/plugins/licensing/server/schema.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { schema as Schema } from '@kbn/config-schema'; -import { DEFAULT_POLLING_FREQUENCY } from './constants'; - -export const schema = Schema.object({ - isEnabled: Schema.boolean({ defaultValue: true }), - clusterSource: Schema.string({ defaultValue: 'data' }), - pollingFrequency: Schema.number({ defaultValue: DEFAULT_POLLING_FREQUENCY }), -}); diff --git a/x-pack/plugins/licensing/server/types.ts b/x-pack/plugins/licensing/server/types.ts index 27d3502b44779..d553f090fb648 100644 --- a/x-pack/plugins/licensing/server/types.ts +++ b/x-pack/plugins/licensing/server/types.ts @@ -3,129 +3,43 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { ILicense, LicenseStatus, LicenseType } from '../common/types'; -import { Observable } from 'rxjs'; -import { TypeOf } from '@kbn/config-schema'; -import { schema } from './schema'; -import { LICENSE_TYPE, LICENSE_STATUS } from './constants'; -import { LicenseFeature } from './license_feature'; - +export interface ElasticsearchError extends Error { + status?: number; +} /** - * @public - * Results from checking if a particular license type meets the minimum - * requirements of the license type. + * Result from remote request fetching raw feature set. + * @internal */ -export interface ILicenseCheck { - /** - * The status of checking the results of a license type meeting the license minimum. - */ - check: LICENSE_STATUS; - /** - * A message containing the reason for a license type not being valid. - */ - message?: string; +export interface RawFeature { + available: boolean; + enabled: boolean; } -/** @public */ -export interface ILicense { - /** - * UID for license. - */ - uid?: string; - - /** - * The validity status of the license. - */ - status?: string; - - /** - * Determine if the status of the license is active. - */ - isActive: boolean; - - /** - * Unix epoch of the expiration date of the license. - */ - expiryDateInMillis?: number; - - /** - * The license type, being usually one of basic, standard, gold, platinum, or trial. - */ - type?: string; - - /** - * Determine if the license container has information. - */ - isAvailable: boolean; - - /** - * Determine if the type of the license is basic, and also active. - */ - isBasic: boolean; - - /** - * Determine if the type of the license is not basic, and also active. - */ - isNotBasic: boolean; - - /** - * If the license is not available, provides a string or Error containing the reason. - */ - reasonUnavailable: string | Error | null; - - /** - * The MD5 hash of the serialized license. - */ - signature: string; - /** - * Determine if the provided license types match against the license type. - * @param candidateLicenses license types to intersect against the license. - */ - isOneOf(candidateLicenses: string | string[]): boolean; - - /** - * Determine if the provided license type is sufficient for the current license. - * @param minimum a license type to determine for sufficiency - */ - meetsMinimumOf(minimum: LICENSE_TYPE): boolean; - - /** - * For a given plugin and license type, receive information about the status of the license. - * @param pluginName the name of the plugin - * @param minimumLicenseRequired the minimum valid license for operating the given plugin - */ - check(pluginName: string, minimumLicenseRequired: LICENSE_TYPE | string): ILicenseCheck; - - /** - * Receive a serialized plain object of the license. - */ - toObject(): any; - - /** - * A specific API for interacting with the specific features of the license. - * @param name the name of the feature to interact with - */ - getFeature(name: string): LicenseFeature | undefined; -} - -/** @public */ -export interface LicensingPluginSetup { - license$: Observable; +/** + * Results from remote request fetching raw feature sets. + * @internal + */ +export interface RawFeatures { + [key: string]: RawFeature; } -/** @public */ -export type LicensingConfigType = TypeOf; -/** @public */ -export type LicenseType = keyof typeof LICENSE_TYPE; -/** @public */ -export type LicenseFeatureSerializer = (licensing: ILicense) => any; -/** @public */ -export interface LicensingRequestContext { - license: ILicense; +/** + * Results from remote request fetching a raw license. + * @internal + */ +export interface RawLicense { + uid: string; + status: LicenseStatus; + expiry_date_in_millis: number; + type: LicenseType; } declare module 'src/core/server' { interface RequestHandlerContext { - licensing: LicensingRequestContext; + licensing: { + license: ILicense; + }; } } diff --git a/x-pack/plugins/security/server/licensing/license_service.test.ts b/x-pack/plugins/security/server/licensing/license_service.test.ts index 16d7599ca4b1a..d5d33c07985fd 100644 --- a/x-pack/plugins/security/server/licensing/license_service.test.ts +++ b/x-pack/plugins/security/server/licensing/license_service.test.ts @@ -48,7 +48,7 @@ describe('license features', function() { mockRawLicense.isOneOf.mockImplementation(licenses => Array.isArray(licenses) ? licenses.includes('basic') : licenses === 'basic' ); - mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true } as any); + mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true }); const serviceSetup = new SecurityLicenseService().setup(); serviceSetup.update(mockRawLicense); @@ -67,7 +67,7 @@ describe('license features', function() { it('should not show login page or other security elements if security is disabled in Elasticsearch.', () => { const mockRawLicense = getMockRawLicense({ isAvailable: true }); mockRawLicense.isOneOf.mockReturnValue(false); - mockRawLicense.getFeature.mockReturnValue({ isEnabled: false, isAvailable: true } as any); + mockRawLicense.getFeature.mockReturnValue({ isEnabled: false, isAvailable: true }); const serviceSetup = new SecurityLicenseService().setup(); serviceSetup.update(mockRawLicense); @@ -88,7 +88,7 @@ describe('license features', function() { const licenseArray = [licenses].flat(); return licenseArray.includes('trial') || licenseArray.includes('platinum'); }); - mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true } as any); + mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true }); const serviceSetup = new SecurityLicenseService().setup(); serviceSetup.update(mockRawLicense); diff --git a/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts b/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts index 73adaba551875..10fe0cdd67811 100644 --- a/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts @@ -5,9 +5,7 @@ */ import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../../src/core/server'; -import { ILicenseCheck } from '../../../../../licensing/server'; -// TODO, require from licensing plugin root once https://github.com/elastic/kibana/pull/44922 is merged. -import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { LicenseCheck, LICENSE_CHECK_STATE } from '../../../../../licensing/server'; import { RawKibanaPrivileges } from '../../../../common/model'; import { defineGetPrivilegesRoutes } from './get'; @@ -40,7 +38,7 @@ const createRawKibanaPrivileges: () => RawKibanaPrivileges = () => { }; interface TestOptions { - licenseCheckResult?: ILicenseCheck; + licenseCheckResult?: LicenseCheck; includeActions?: boolean; asserts: { statusCode: number; result: Record }; } @@ -48,7 +46,11 @@ interface TestOptions { describe('GET privileges', () => { const getPrivilegesTest = ( description: string, - { licenseCheckResult = { check: LICENSE_STATUS.Valid }, includeActions, asserts }: TestOptions + { + licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid }, + includeActions, + asserts, + }: TestOptions ) => { test(description, async () => { const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); @@ -80,7 +82,7 @@ describe('GET privileges', () => { describe('failure', () => { getPrivilegesTest(`returns result of routePreCheckLicense`, { - licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, }); }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts index 5699b100e3ffd..61c5747550d75 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts @@ -6,8 +6,7 @@ import Boom from 'boom'; import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../../src/core/server'; -import { ILicenseCheck } from '../../../../../licensing/server'; -import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { LicenseCheck, LICENSE_CHECK_STATE } from '../../../../../licensing/server'; import { defineDeleteRolesRoutes } from './delete'; import { @@ -17,7 +16,7 @@ import { import { routeDefinitionParamsMock } from '../../index.mock'; interface TestOptions { - licenseCheckResult?: ILicenseCheck; + licenseCheckResult?: LicenseCheck; name: string; apiResponse?: () => Promise; asserts: { statusCode: number; result?: Record }; @@ -28,7 +27,7 @@ describe('DELETE role', () => { description: string, { name, - licenseCheckResult = { check: LICENSE_STATUS.Valid }, + licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid }, apiResponse, asserts, }: TestOptions @@ -76,7 +75,7 @@ describe('DELETE role', () => { describe('failure', () => { deleteRoleTest(`returns result of license checker`, { name: 'foo-role', - licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts index 619e6e67f683b..1cfc1ae416ae4 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts @@ -5,8 +5,7 @@ */ import Boom from 'boom'; import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../../src/core/server'; -import { ILicenseCheck } from '../../../../../licensing/server'; -import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { LicenseCheck, LICENSE_CHECK_STATE } from '../../../../../licensing/server'; import { defineGetRolesRoutes } from './get'; import { @@ -20,7 +19,7 @@ const reservedPrivilegesApplicationWildcard = 'kibana-*'; interface TestOptions { name?: string; - licenseCheckResult?: ILicenseCheck; + licenseCheckResult?: LicenseCheck; apiResponse?: () => Promise; asserts: { statusCode: number; result?: Record }; } @@ -30,7 +29,7 @@ describe('GET role', () => { description: string, { name, - licenseCheckResult = { check: LICENSE_STATUS.Valid }, + licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid }, apiResponse, asserts, }: TestOptions @@ -77,7 +76,7 @@ describe('GET role', () => { describe('failure', () => { getRoleTest(`returns result of license check`, { - licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts index d04513592f027..76ce6a272e285 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts @@ -5,8 +5,7 @@ */ import Boom from 'boom'; import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../../src/core/server'; -import { ILicenseCheck } from '../../../../../licensing/server'; -import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { LicenseCheck, LICENSE_CHECK_STATE } from '../../../../../licensing/server'; import { defineGetAllRolesRoutes } from './get_all'; import { @@ -20,7 +19,7 @@ const reservedPrivilegesApplicationWildcard = 'kibana-*'; interface TestOptions { name?: string; - licenseCheckResult?: ILicenseCheck; + licenseCheckResult?: LicenseCheck; apiResponse?: () => Promise; asserts: { statusCode: number; result?: Record }; } @@ -28,7 +27,7 @@ interface TestOptions { describe('GET all roles', () => { const getRolesTest = ( description: string, - { licenseCheckResult = { check: LICENSE_STATUS.Valid }, apiResponse, asserts }: TestOptions + { licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid }, apiResponse, asserts }: TestOptions ) => { test(description, async () => { const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); @@ -69,7 +68,7 @@ describe('GET all roles', () => { describe('failure', () => { getRolesTest(`returns result of license check`, { - licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts index fa4f2350bb7dd..31963987c2efb 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts @@ -6,8 +6,7 @@ import { Type } from '@kbn/config-schema'; import { kibanaResponseFactory, RequestHandlerContext } from '../../../../../../../src/core/server'; -import { ILicenseCheck } from '../../../../../licensing/server'; -import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { LicenseCheck, LICENSE_CHECK_STATE } from '../../../../../licensing/server'; import { GLOBAL_RESOURCE } from '../../../../common/constants'; import { definePutRolesRoutes } from './put'; @@ -45,7 +44,7 @@ const privilegeMap = { interface TestOptions { name: string; - licenseCheckResult?: ILicenseCheck; + licenseCheckResult?: LicenseCheck; apiResponses?: Array<() => Promise>; payload?: Record; asserts: { statusCode: number; result?: Record; apiArguments?: unknown[][] }; @@ -56,7 +55,7 @@ const putRoleTest = ( { name, payload, - licenseCheckResult = { check: LICENSE_STATUS.Valid }, + licenseCheckResult = { state: LICENSE_CHECK_STATE.Valid }, apiResponses = [], asserts, }: TestOptions @@ -141,7 +140,7 @@ describe('PUT role', () => { describe('failure', () => { putRoleTest(`returns result of license checker`, { name: 'foo-role', - licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + licenseCheckResult: { state: LICENSE_CHECK_STATE.Invalid, message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, }); }); diff --git a/x-pack/plugins/security/server/routes/licensed_route_handler.ts b/x-pack/plugins/security/server/routes/licensed_route_handler.ts index de5b842c7d292..1194e3d0a83cc 100644 --- a/x-pack/plugins/security/server/routes/licensed_route_handler.ts +++ b/x-pack/plugins/security/server/routes/licensed_route_handler.ts @@ -6,7 +6,7 @@ import { RequestHandler } from 'src/core/server'; import { ObjectType } from '@kbn/config-schema'; -import { LICENSE_STATUS } from '../../../licensing/server/constants'; +import { LICENSE_CHECK_STATE } from '../../../licensing/server'; export const createLicensedRouteHandler = < P extends ObjectType, @@ -19,8 +19,8 @@ export const createLicensedRouteHandler = < const { license } = context.licensing; const licenseCheck = license.check('security', 'basic'); if ( - licenseCheck.check === LICENSE_STATUS.Unavailable || - licenseCheck.check === LICENSE_STATUS.Invalid + licenseCheck.state === LICENSE_CHECK_STATE.Unavailable || + licenseCheck.state === LICENSE_CHECK_STATE.Invalid ) { return responseToolkit.forbidden({ body: { message: licenseCheck.message! } }); } diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/route_contexts.ts index 5bb811ef6be4c..0bc1685345857 100644 --- a/x-pack/plugins/spaces/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/route_contexts.ts @@ -5,13 +5,13 @@ */ import { RequestHandlerContext } from 'src/core/server'; -import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { LICENSE_CHECK_STATE } from '../../../../../licensing/server'; export const mockRouteContext = ({ licensing: { license: { check: jest.fn().mockReturnValue({ - check: LICENSE_STATUS.Valid, + state: LICENSE_CHECK_STATE.Valid, }), }, }, @@ -21,7 +21,7 @@ export const mockRouteContextWithInvalidLicense = ({ licensing: { license: { check: jest.fn().mockReturnValue({ - check: LICENSE_STATUS.Invalid, + state: LICENSE_CHECK_STATE.Invalid, message: 'License is invalid for spaces', }), }, diff --git a/x-pack/plugins/spaces/server/routes/lib/licensed_route_handler.ts b/x-pack/plugins/spaces/server/routes/lib/licensed_route_handler.ts index a3bc2fa71fefe..3838b1d134ea2 100644 --- a/x-pack/plugins/spaces/server/routes/lib/licensed_route_handler.ts +++ b/x-pack/plugins/spaces/server/routes/lib/licensed_route_handler.ts @@ -6,7 +6,7 @@ import { RequestHandler } from 'src/core/server'; import { ObjectType } from '@kbn/config-schema'; -import { LICENSE_STATUS } from '../../../../licensing/server/constants'; +import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; export const createLicensedRouteHandler = < P extends ObjectType, @@ -19,8 +19,8 @@ export const createLicensedRouteHandler = < const { license } = context.licensing; const licenseCheck = license.check('spaces', 'basic'); if ( - licenseCheck.check === LICENSE_STATUS.Unavailable || - licenseCheck.check === LICENSE_STATUS.Invalid + licenseCheck.state === LICENSE_CHECK_STATE.Unavailable || + licenseCheck.state === LICENSE_CHECK_STATE.Invalid ) { return responseToolkit.forbidden({ body: { message: licenseCheck.message! } }); } diff --git a/x-pack/typings/index.d.ts b/x-pack/typings/index.d.ts index 53724a72166e1..2413f986922ed 100644 --- a/x-pack/typings/index.d.ts +++ b/x-pack/typings/index.d.ts @@ -23,7 +23,11 @@ type PublicMethodsOf = Pick>; declare module 'axios/lib/adapters/xhr'; -type MockedKeys = { [P in keyof T]: jest.Mocked }; +type Writable = { + -readonly [K in keyof T]: T[K]; +}; + +type MockedKeys = { [P in keyof T]: jest.Mocked> }; type DeeplyMockedKeys = { [P in keyof T]: T[P] extends (...args: any[]) => any