diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md index 8102968c988b4d..207720f1b36f79 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -1227,6 +1227,7 @@ In server code, `core` can be accessed from either `server.newPlatform` or `kbnS | `request.getBasePath()` | [`core.http.basePath.get`](/docs/development/core/server/kibana-plugin-server.httpservicesetup.basepath.md) | | | `server.plugins.elasticsearch.getCluster('data')` | [`core.elasticsearch.dataClient$`](/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.dataclient_.md) | Handlers will also include a pre-configured client | | `server.plugins.elasticsearch.getCluster('admin')` | [`core.elasticsearch.adminClient$`](/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.adminclient_.md) | Handlers will also include a pre-configured client | +| `xpackMainPlugin.info.feature(pluginID).registerLicenseCheckResultsGenerator` | [`x-pack licensing plugin`](/x-pack/plugins/licensing/README.md) | | _See also: [Server's CoreSetup API Docs](/docs/development/core/server/kibana-plugin-server.coresetup.md)_ diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts index fe7c749091b03a..5887e7b3e96d00 100644 --- a/src/core/public/http/http_service.mock.ts +++ b/src/core/public/http/http_service.mock.ts @@ -21,10 +21,10 @@ import { HttpService } from './http_service'; import { HttpSetup } from './types'; import { BehaviorSubject } from 'rxjs'; import { BasePath } from './base_path_service'; -import { AnonymousPaths } from './anonymous_paths'; export type HttpSetupMock = jest.Mocked & { basePath: BasePath; + anonymousPaths: jest.Mocked; }; const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ @@ -37,7 +37,10 @@ const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ delete: jest.fn(), options: jest.fn(), basePath: new BasePath(basePath), - anonymousPaths: new AnonymousPaths(new BasePath(basePath)), + anonymousPaths: { + register: jest.fn(), + isAnonymous: jest.fn(), + }, addLoadingCount: jest.fn(), getLoadingCount$: jest.fn().mockReturnValue(new BehaviorSubject(0)), stop: jest.fn(), diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index 695f0454f8b65b..644df259b8e242 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -18,7 +18,7 @@ */ import { applicationServiceMock } from './application/application_service.mock'; import { chromeServiceMock } from './chrome/chrome_service.mock'; -import { CoreContext, CoreSetup, CoreStart, PluginInitializerContext, NotificationsSetup } from '.'; +import { CoreContext, PluginInitializerContext } from '.'; import { docLinksServiceMock } from './doc_links/doc_links_service.mock'; import { fatalErrorsServiceMock } from './fatal_errors/fatal_errors_service.mock'; import { httpServiceMock } from './http/http_service.mock'; @@ -42,7 +42,7 @@ export { overlayServiceMock } from './overlays/overlay_service.mock'; export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; function createCoreSetupMock({ basePath = '' } = {}) { - const mock: MockedKeys & { notifications: MockedKeys } = { + const mock = { application: applicationServiceMock.createSetupContract(), context: contextServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), @@ -58,7 +58,7 @@ function createCoreSetupMock({ basePath = '' } = {}) { } function createCoreStartMock({ basePath = '' } = {}) { - const mock: MockedKeys & { notifications: MockedKeys } = { + const mock = { application: applicationServiceMock.createStartContract(), chrome: chromeServiceMock.createStartContract(), docLinks: docLinksServiceMock.createStartContract(), diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index fb3716c42b8314..444aa04171dbdd 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -25,6 +25,7 @@ import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; import { AuthToolkit } from './lifecycle/auth'; import { sessionStorageMock } from './cookie_session_storage.mocks'; import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; +import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; export type HttpServiceSetupMock = jest.Mocked & { basePath: jest.Mocked; @@ -93,12 +94,17 @@ const createAuthToolkitMock = (): jest.Mocked => ({ authenticated: jest.fn(), }); +const createOnPreResponseToolkitMock = (): jest.Mocked => ({ + next: jest.fn(), +}); + export const httpServiceMock = { create: createHttpServiceMock, createBasePath: createBasePathMock, createSetupContract: createSetupContractMock, createOnPreAuthToolkit: createOnPreAuthToolkitMock, createOnPostAuthToolkit: createOnPostAuthToolkitMock, + createOnPreResponseToolkit: createOnPreResponseToolkitMock, createAuthToolkit: createAuthToolkitMock, createRouter: mockRouter.create, }; diff --git a/src/core/server/http/integration_tests/lifecycle.test.ts b/src/core/server/http/integration_tests/lifecycle.test.ts index 0edbcf19d32092..b16352838fad11 100644 --- a/src/core/server/http/integration_tests/lifecycle.test.ts +++ b/src/core/server/http/integration_tests/lifecycle.test.ts @@ -964,7 +964,6 @@ describe('OnPreResponse', () => { headers: { 'x-kibana-header': 'value' }, }) ); - registerOnPreResponse((req, res, t) => t.next({ headers: { 'x-kibana-header': 'value' }, diff --git a/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap b/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap index 58658b6c839a25..f06fc4dd748ccd 100644 --- a/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap +++ b/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap @@ -305,14 +305,9 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA }, "http": Object { "addLoadingCount": [MockFunction], - "anonymousPaths": AnonymousPaths { - "basePath": BasePath { - "basePath": "", - "get": [Function], - "prepend": [Function], - "remove": [Function], - }, - "paths": Set {}, + "anonymousPaths": Object { + "isAnonymous": [MockFunction], + "register": [MockFunction], }, "basePath": BasePath { "basePath": "", @@ -936,14 +931,9 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA }, "http": Object { "addLoadingCount": [MockFunction], - "anonymousPaths": AnonymousPaths { - "basePath": BasePath { - "basePath": "", - "get": [Function], - "prepend": [Function], - "remove": [Function], - }, - "paths": Set {}, + "anonymousPaths": Object { + "isAnonymous": [MockFunction], + "register": [MockFunction], }, "basePath": BasePath { "basePath": "", @@ -1555,14 +1545,9 @@ exports[`QueryStringInput Should pass the query language to the language switche }, "http": Object { "addLoadingCount": [MockFunction], - "anonymousPaths": AnonymousPaths { - "basePath": BasePath { - "basePath": "", - "get": [Function], - "prepend": [Function], - "remove": [Function], - }, - "paths": Set {}, + "anonymousPaths": Object { + "isAnonymous": [MockFunction], + "register": [MockFunction], }, "basePath": BasePath { "basePath": "", @@ -2183,14 +2168,9 @@ exports[`QueryStringInput Should pass the query language to the language switche }, "http": Object { "addLoadingCount": [MockFunction], - "anonymousPaths": AnonymousPaths { - "basePath": BasePath { - "basePath": "", - "get": [Function], - "prepend": [Function], - "remove": [Function], - }, - "paths": Set {}, + "anonymousPaths": Object { + "isAnonymous": [MockFunction], + "register": [MockFunction], }, "basePath": BasePath { "basePath": "", @@ -2802,14 +2782,9 @@ exports[`QueryStringInput Should render the given query 1`] = ` }, "http": Object { "addLoadingCount": [MockFunction], - "anonymousPaths": AnonymousPaths { - "basePath": BasePath { - "basePath": "", - "get": [Function], - "prepend": [Function], - "remove": [Function], - }, - "paths": Set {}, + "anonymousPaths": Object { + "isAnonymous": [MockFunction], + "register": [MockFunction], }, "basePath": BasePath { "basePath": "", @@ -3430,14 +3405,9 @@ exports[`QueryStringInput Should render the given query 1`] = ` }, "http": Object { "addLoadingCount": [MockFunction], - "anonymousPaths": AnonymousPaths { - "basePath": BasePath { - "basePath": "", - "get": [Function], - "prepend": [Function], - "remove": [Function], - }, - "paths": Set {}, + "anonymousPaths": Object { + "isAnonymous": [MockFunction], + "register": [MockFunction], }, "basePath": BasePath { "basePath": "", diff --git a/x-pack/legacy/plugins/xpack_main/index.js b/x-pack/legacy/plugins/xpack_main/index.js index 1d931ebaede890..82e6b3174700a7 100644 --- a/x-pack/legacy/plugins/xpack_main/index.js +++ b/x-pack/legacy/plugins/xpack_main/index.js @@ -63,9 +63,6 @@ export const xpackMain = (kibana) => { value: null } }, - hacks: [ - 'plugins/xpack_main/hacks/check_xpack_info_change', - ], replaceInjectedVars, injectDefaultVars(server) { const config = server.config(); diff --git a/x-pack/legacy/plugins/xpack_main/public/hacks/__tests__/check_xpack_info_change.js b/x-pack/legacy/plugins/xpack_main/public/hacks/__tests__/check_xpack_info_change.js deleted file mode 100644 index 8f3ae33017b39f..00000000000000 --- a/x-pack/legacy/plugins/xpack_main/public/hacks/__tests__/check_xpack_info_change.js +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import ngMock from 'ng_mock'; -import sinon from 'sinon'; -import { banners } from 'ui/notify'; - -const XPACK_INFO_SIG_KEY = 'xpackMain.infoSignature'; -const XPACK_INFO_KEY = 'xpackMain.info'; - -describe('CheckXPackInfoChange Factory', () => { - const sandbox = sinon.createSandbox(); - - let mockSessionStorage; - beforeEach(ngMock.module('kibana', ($provide) => { - mockSessionStorage = sinon.stub({ - setItem() {}, - getItem() {}, - removeItem() {} - }); - - mockSessionStorage.getItem.withArgs(XPACK_INFO_SIG_KEY).returns('foo'); - - $provide.service('$window', () => ({ - sessionStorage: mockSessionStorage, - location: { pathname: '' } - })); - })); - - let $http; - let $httpBackend; - let $timeout; - beforeEach(ngMock.inject(($injector) => { - $http = $injector.get('$http'); - $httpBackend = $injector.get('$httpBackend'); - $timeout = $injector.get('$timeout'); - - // We set 'kbn-system-api' to not trigger other unrelated toast notifications - // like the one related to the session expiration. - $http.defaults.headers.common['kbn-system-api'] = 'x'; - - sandbox.stub(banners, 'add'); - })); - - afterEach(function () { - $httpBackend.verifyNoOutstandingRequest(); - $timeout.verifyNoPendingTasks(); - - sandbox.restore(); - }); - - it('does not show "license expired" banner if license is not expired.', () => { - const license = { license: { isActive: true, type: 'x-license' } }; - mockSessionStorage.getItem.withArgs(XPACK_INFO_KEY).returns(JSON.stringify(license)); - - $httpBackend - .when('POST', '/api/test') - .respond('ok', { 'kbn-xpack-sig': 'foo' }); - - $httpBackend - .when('GET', '/api/xpack/v1/info') - .respond(license, { 'kbn-xpack-sig': 'foo' }); - - $http.post('/api/test'); - $httpBackend.flush(); - $timeout.flush(); - - sinon.assert.notCalled(banners.add); - }); - - it('shows "license expired" banner if license is expired only once.', async () => { - const license = { license: { isActive: false, type: 'diamond' } }; - mockSessionStorage.getItem.withArgs(XPACK_INFO_KEY).returns(JSON.stringify(license)); - - $httpBackend - .when('POST', '/api/test') - .respond('ok', { 'kbn-xpack-sig': 'bar' }); - - $httpBackend - .when('GET', '/api/xpack/v1/info') - .respond(license, { 'kbn-xpack-sig': 'bar' }); - - $http.post('/api/test'); - $httpBackend.flush(); - $timeout.flush(); - - sinon.assert.calledOnce(banners.add); - - // If license didn't change banner shouldn't be displayed. - banners.add.resetHistory(); - mockSessionStorage.getItem.withArgs(XPACK_INFO_SIG_KEY).returns('bar'); - - $http.post('/api/test'); - $httpBackend.flush(); - $timeout.flush(); - - sinon.assert.notCalled(banners.add); - }); -}); diff --git a/x-pack/legacy/plugins/xpack_main/public/hacks/check_xpack_info_change.js b/x-pack/legacy/plugins/xpack_main/public/hacks/check_xpack_info_change.js deleted file mode 100644 index 587dd8cc11f550..00000000000000 --- a/x-pack/legacy/plugins/xpack_main/public/hacks/check_xpack_info_change.js +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { identity } from 'lodash'; -import { EuiCallOut } from '@elastic/eui'; -import { uiModules } from 'ui/modules'; -import chrome from 'ui/chrome'; -import { banners } from 'ui/notify'; -import { DebounceProvider } from 'ui/directives/debounce'; -import { Path } from 'plugins/xpack_main/services/path'; -import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; -import { xpackInfoSignature } from 'plugins/xpack_main/services/xpack_info_signature'; -import { FormattedMessage } from '@kbn/i18n/react'; - -const module = uiModules.get('xpack_main', []); - -module.factory('checkXPackInfoChange', ($q, Private, $injector) => { - const debounce = Private(DebounceProvider); - const isUnauthenticated = Path.isUnauthenticated(); - let isLicenseExpirationBannerShown = false; - - const notifyIfLicenseIsExpired = debounce(() => { - const license = xpackInfo.get('license'); - if (license.isActive) { - return; - } - - const uploadLicensePath = `${chrome.getBasePath()}/app/kibana#/management/elasticsearch/license_management/upload_license`; - - if (!isLicenseExpirationBannerShown) { - isLicenseExpirationBannerShown = true; - banners.add({ - component: ( - } - > - - - - ) - }} - /> - - ), - }); - } - }); - - /** - * Intercept each network response to look for the kbn-xpack-sig header. - * When that header is detected, compare its value with the value cached - * in the browser storage. When the value is new, call `xpackInfo.refresh()` - * so that it will pull down the latest x-pack info - * - * @param {object} response - the angular $http response object - * @param {function} handleResponse - callback, expects to receive the response - * @return - */ - function interceptor(response, handleResponse) { - if (isUnauthenticated) { - return handleResponse(response); - } - - const currentSignature = response.headers('kbn-xpack-sig'); - const cachedSignature = xpackInfoSignature.get(); - - if (currentSignature && cachedSignature !== currentSignature) { - // Signature from the server differ from the signature of our - // cached info, so we need to refresh it. - // Intentionally swallowing this error - // because nothing catches it and it's an ugly console error. - xpackInfo.refresh($injector).then( - () => notifyIfLicenseIsExpired(), - () => {} - ); - } - - return handleResponse(response); - } - - return { - response: (response) => interceptor(response, identity), - responseError: (response) => interceptor(response, $q.reject) - }; -}); - -module.config(($httpProvider) => { - $httpProvider.interceptors.push('checkXPackInfoChange'); -}); diff --git a/x-pack/plugins/licensing/README.md b/x-pack/plugins/licensing/README.md new file mode 100644 index 00000000000000..339abb77ced880 --- /dev/null +++ b/x-pack/plugins/licensing/README.md @@ -0,0 +1,94 @@ +# Licensing plugin + +Retrieves license data from Elasticsearch and becomes a source of license data for all Kibana plugins on server-side and client-side. + +## API: +### Server-side + The licensing plugin retrieves license data from **Elasticsearch** at regular configurable intervals. +- `license$: Observable` Provides a steam of license data [ILicense](./common/types.ts). Plugin emits new value whenever it detects changes in license info. If the plugin cannot retrieve a license from **Elasticsearch**, it will emit `an empty license` object. +- `refresh: () => Promise` allows a plugin to enforce license retrieval. + +### Client-side + The licensing plugin retrieves license data from **licensing Kibana plugin** and does not communicate with Elasticsearch directly. +- `license$: Observable` Provides a steam of license data [ILicense](./common/types.ts). Plugin emits new value whenever it detects changes in license info. If the plugin cannot retrieve a license from **Kibana**, it will emit `an empty license` object. +- `refresh: () => Promise` allows a plugin to enforce license retrieval. + +## Migration path +The new platform licensing plugin became stateless now. It means that instead of storing all your data from `checkLicense` within the plugin, you should react on license data change on both the client and server sides. + +### Before +```ts +// my_plugin/server/plugin.ts +function checkLicense(xpackLicenseInfo: XPackInfo){ + if (!xpackLicenseInfo || !xpackLicenseInfo.isAvailable()) { + return { + isAvailable: false, + showLinks: true, + } + } + if (!xpackLicenseInfo.feature('name').isEnabled()) { + return { + isAvailable: false, + showLinks: false, + } + } + const hasRequiredLicense = xPackInfo.license.isOneOf([ + 'gold', + 'platinum', + 'trial', + ]); + return { + isAvailable: hasRequiredLicense, + showLinks: hasRequiredLicense, + } +} +xpackMainPlugin.info.feature(pluginId).registerLicenseCheckResultsGenerator(checkLicense); + +// my_plugin/client/plugin.ts +chrome.navLinks.update('myPlugin', { + hidden: !xpackInfo.get('features.myPlugin.showLinks', false) +}); +``` + +### After +```ts +// kibana.json +"requiredPlugins": ["licensing"], + +// my_plugin/server/plugin.ts +import { LicensingPluginSetup, LICENSE_CHECK_STATE } from '../licensing' + +interface SetupDeps { + licensing: LicensingPluginSetup; +} + +class MyPlugin { + setup(core: CoreSetup, deps: SetupDeps) { + deps.licensing.license$.subscribe(license => { + const { state, message } = license.check('myPlugin', 'gold') + const hasRequiredLicense = state === LICENSE_CHECK_STATE.Valid; + if (hasRequiredLicense && license.getFeature('name').isAvailable) { + // enable some server side logic + } else { + log(message); + // disable some server side logic + } + }) + } +} + +// my_plugin/client/plugin.ts +class MyPlugin { + setup(core: CoreSetup, deps: SetupDeps) { + deps.licensing.license$.subscribe(license => { + const { state, message } = license.check('myPlugin', 'gold') + const hasRequiredLicense = state === LICENSE_CHECK_STATE.Valid; + const showLinks = hasRequiredLicense && license.getFeature('name').isAvailable; + + chrome.navLinks.update('myPlugin', { + hidden: !showLinks + }); + }) + } +} +``` diff --git a/x-pack/plugins/licensing/common/license_update.test.ts b/x-pack/plugins/licensing/common/license_update.test.ts index 345085d3e3a8f1..68660eaf2d713e 100644 --- a/x-pack/plugins/licensing/common/license_update.test.ts +++ b/x-pack/plugins/licensing/common/license_update.test.ts @@ -12,7 +12,7 @@ import { createLicenseUpdate } from './license_update'; import { licenseMock } from './license.mock'; const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - +const stop$ = new Subject(); describe('licensing update', () => { it('loads updates when triggered', async () => { const types: LicenseType[] = ['basic', 'gold']; @@ -24,16 +24,16 @@ describe('licensing update', () => { Promise.resolve(licenseMock.create({ license: { type: types.shift() } })) ); - const { update$ } = createLicenseUpdate(trigger$, fetcher); + const { license$ } = createLicenseUpdate(trigger$, stop$, fetcher); expect(fetcher).toHaveBeenCalledTimes(0); trigger$.next(); - const first = await update$.pipe(take(1)).toPromise(); + const first = await license$.pipe(take(1)).toPromise(); expect(first.type).toBe('basic'); trigger$.next(); - const [, second] = await update$.pipe(take(2), toArray()).toPromise(); + const [, second] = await license$.pipe(take(2), toArray()).toPromise(); expect(second.type).toBe('gold'); }); @@ -43,9 +43,9 @@ describe('licensing update', () => { const trigger$ = new Subject(); const fetcher = jest.fn().mockResolvedValue(fetchedLicense); - const { update$ } = createLicenseUpdate(trigger$, fetcher, initialLicense); + const { license$ } = createLicenseUpdate(trigger$, stop$, fetcher, initialLicense); trigger$.next(); - const [first, second] = await update$.pipe(take(2), toArray()).toPromise(); + const [first, second] = await license$.pipe(take(2), toArray()).toPromise(); expect(first.type).toBe('platinum'); expect(second.type).toBe('gold'); @@ -64,17 +64,17 @@ describe('licensing update', () => { ) ); - const { update$ } = createLicenseUpdate(trigger$, fetcher); + const { license$ } = createLicenseUpdate(trigger$, stop$, fetcher); trigger$.next(); - const [first] = await update$.pipe(take(1), toArray()).toPromise(); + const [first] = await license$.pipe(take(1), toArray()).toPromise(); expect(first.type).toBe('basic'); trigger$.next(); trigger$.next(); - const [, second] = await update$.pipe(take(2), toArray()).toPromise(); + const [, second] = await license$.pipe(take(2), toArray()).toPromise(); expect(second.type).toBe('gold'); expect(fetcher).toHaveBeenCalledTimes(3); @@ -85,11 +85,11 @@ describe('licensing update', () => { const fetcher = jest.fn().mockResolvedValue(licenseMock.create()); - const { update$ } = createLicenseUpdate(trigger$, fetcher); + const { license$ } = createLicenseUpdate(trigger$, stop$, fetcher); - update$.subscribe(() => {}); - update$.subscribe(() => {}); - update$.subscribe(() => {}); + license$.subscribe(() => {}); + license$.subscribe(() => {}); + license$.subscribe(() => {}); trigger$.next(); expect(fetcher).toHaveBeenCalledTimes(1); @@ -110,9 +110,9 @@ describe('licensing update', () => { }) ); const trigger$ = new Subject(); - const { update$ } = createLicenseUpdate(trigger$, fetcher); + const { license$ } = createLicenseUpdate(trigger$, stop$, fetcher); const values: ILicense[] = []; - update$.subscribe(license => values.push(license)); + license$.subscribe(license => values.push(license)); trigger$.next(); trigger$.next(); @@ -124,29 +124,58 @@ describe('licensing update', () => { await expect(values[0].type).toBe('gold'); }); - it('completes update$ stream when trigger is completed', () => { + it('completes license$ stream when stop$ is triggered', () => { const trigger$ = new Subject(); const fetcher = jest.fn().mockResolvedValue(licenseMock.create()); - const { update$ } = createLicenseUpdate(trigger$, fetcher); + const { license$ } = createLicenseUpdate(trigger$, stop$, fetcher); let completed = false; - update$.subscribe({ complete: () => (completed = true) }); + license$.subscribe({ complete: () => (completed = true) }); - trigger$.complete(); + stop$.next(); expect(completed).toBe(true); }); - it('stops fetching when fetch subscription unsubscribed', () => { + it('stops fetching when stop$ is triggered', () => { const trigger$ = new Subject(); const fetcher = jest.fn().mockResolvedValue(licenseMock.create()); - const { update$, fetchSubscription } = createLicenseUpdate(trigger$, fetcher); + const { license$ } = createLicenseUpdate(trigger$, stop$, fetcher); const values: ILicense[] = []; - update$.subscribe(license => values.push(license)); + license$.subscribe(license => values.push(license)); - fetchSubscription.unsubscribe(); + stop$.next(); trigger$.next(); expect(fetcher).toHaveBeenCalledTimes(0); }); + + it('refreshManually guarantees license fetching', async () => { + const trigger$ = new Subject(); + const firstLicense = licenseMock.create({ license: { uid: 'first', type: 'basic' } }); + const secondLicense = licenseMock.create({ license: { uid: 'second', type: 'gold' } }); + + const fetcher = jest + .fn() + .mockImplementationOnce(async () => { + await delay(100); + return firstLicense; + }) + .mockImplementationOnce(async () => { + await delay(100); + return secondLicense; + }); + + const { license$, refreshManually } = createLicenseUpdate(trigger$, stop$, fetcher); + let fromObservable; + license$.subscribe(license => (fromObservable = license)); + + const licenseResult = await refreshManually(); + expect(licenseResult.uid).toBe('first'); + expect(licenseResult).toBe(fromObservable); + + const secondResult = await refreshManually(); + expect(secondResult.uid).toBe('second'); + expect(secondResult).toBe(fromObservable); + }); }); diff --git a/x-pack/plugins/licensing/common/license_update.ts b/x-pack/plugins/licensing/common/license_update.ts index 254ea680460ee2..0197ca5396ad11 100644 --- a/x-pack/plugins/licensing/common/license_update.ts +++ b/x-pack/plugins/licensing/common/license_update.ts @@ -3,36 +3,45 @@ * 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 { ConnectableObservable, Observable, Subject, from, merge } from 'rxjs'; -import { filter, map, pairwise, switchMap, publishReplay } from 'rxjs/operators'; +import { filter, map, pairwise, switchMap, publishReplay, takeUntil } from 'rxjs/operators'; import { hasLicenseInfoChanged } from './has_license_info_changed'; import { ILicense } from './types'; export function createLicenseUpdate( trigger$: Observable, + stop$: Observable, fetcher: () => Promise, initialValues?: ILicense ) { - const fetched$ = trigger$.pipe( - switchMap(fetcher), + const triggerRefresh$ = trigger$.pipe(switchMap(fetcher)); + const manuallyFetched$ = new Subject(); + + const fetched$ = merge(triggerRefresh$, manuallyFetched$).pipe( + takeUntil(stop$), 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(); + stop$.subscribe({ complete: () => fetchSubscription.unsubscribe() }); const initialValues$ = initialValues ? from([undefined, initialValues]) : from([undefined]); - const update$: Observable = merge(initialValues$, fetched$).pipe( + const license$: Observable = merge(initialValues$, fetched$).pipe( pairwise(), filter(([previous, next]) => hasLicenseInfoChanged(previous, next!)), map(([, next]) => next!) ); return { - update$, - fetchSubscription, + license$, + async refreshManually() { + const license = await fetcher(); + manuallyFetched$.next(license); + return license; + }, }; } diff --git a/x-pack/plugins/licensing/common/types.ts b/x-pack/plugins/licensing/common/types.ts index c8edd8fd0cca81..c5d838d23d8c38 100644 --- a/x-pack/plugins/licensing/common/types.ts +++ b/x-pack/plugins/licensing/common/types.ts @@ -183,5 +183,5 @@ export interface LicensingPluginSetup { /** * Triggers licensing information re-fetch. */ - refresh(): void; + refresh(): Promise; } diff --git a/x-pack/plugins/licensing/public/expired_banner.tsx b/x-pack/plugins/licensing/public/expired_banner.tsx new file mode 100644 index 00000000000000..9c42d3ff6997d5 --- /dev/null +++ b/x-pack/plugins/licensing/public/expired_banner.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiCallOut } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; + +interface Props { + type: string; + uploadUrl: string; +} + +const ExpiredBanner: React.FunctionComponent = props => ( + + } + > + + + + ), + }} + /> + +); + +export const mountExpiredBanner = (props: Props) => + toMountPoint(); diff --git a/x-pack/test/api_integration/apis/licensing/index.ts b/x-pack/plugins/licensing/public/plugin.test.mocks.ts similarity index 50% rename from x-pack/test/api_integration/apis/licensing/index.ts rename to x-pack/plugins/licensing/public/plugin.test.mocks.ts index f14d5102f6f4e2..6635a7950c0e85 100644 --- a/x-pack/test/api_integration/apis/licensing/index.ts +++ b/x-pack/plugins/licensing/public/plugin.test.mocks.ts @@ -4,10 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function licensingIntegrationTests({ loadTestFile }: FtrProviderContext) { - describe('Licensing', () => { - loadTestFile(require.resolve('./info')); - }); -} +export const mountExpiredBannerMock = jest.fn(); +jest.doMock('./expired_banner', () => ({ + mountExpiredBanner: mountExpiredBannerMock, +})); diff --git a/x-pack/plugins/licensing/public/plugin.test.ts b/x-pack/plugins/licensing/public/plugin.test.ts index 60dfd1f6cb260a..c356f7f5df1848 100644 --- a/x-pack/plugins/licensing/public/plugin.test.ts +++ b/x-pack/plugins/licensing/public/plugin.test.ts @@ -5,6 +5,7 @@ */ import { take } from 'rxjs/operators'; +import { mountExpiredBannerMock } from './plugin.test.mocks'; import { LicenseType } from '../common/types'; import { LicensingPlugin, licensingSessionStorageKey } from './plugin'; @@ -14,10 +15,13 @@ import { licenseMock } from '../common/license.mock'; import { coreMock } from '../../../../src/core/public/mocks'; import { HttpInterceptor } from 'src/core/public'; +const delay = (ms: number) => new Promise(res => setTimeout(res, ms)); + describe('licensing plugin', () => { let plugin: LicensingPlugin; afterEach(async () => { + jest.clearAllMocks(); await plugin.stop(); }); @@ -28,15 +32,30 @@ describe('licensing plugin', () => { plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); const coreSetup = coreMock.createSetup(); - const fetchedLicense = licenseMock.create({ license: { uid: 'fetched' } }); - coreSetup.http.get.mockResolvedValue(fetchedLicense); + const firstLicense = licenseMock.create({ license: { uid: 'first', type: 'basic' } }); + const secondLicense = licenseMock.create({ license: { uid: 'second', type: 'gold' } }); + coreSetup.http.get + .mockImplementationOnce(async () => { + await delay(100); + return firstLicense; + }) + .mockImplementationOnce(async () => { + await delay(100); + return secondLicense; + }); const { license$, refresh } = await plugin.setup(coreSetup); - refresh(); - const license = await license$.pipe(take(1)).toPromise(); + let fromObservable; + license$.subscribe(license => (fromObservable = license)); - expect(license.uid).toBe('fetched'); + const licenseResult = await refresh(); + expect(licenseResult.uid).toBe('first'); + expect(licenseResult).toBe(fromObservable); + + const secondResult = await refresh(); + expect(secondResult.uid).toBe('second'); + expect(secondResult).toBe(fromObservable); }); it('data re-fetch call marked as a system api', async () => { @@ -49,7 +68,7 @@ describe('licensing plugin', () => { const { refresh } = await plugin.setup(coreSetup); - refresh(); + await refresh(); expect(coreSetup.http.get.mock.calls[0][1]).toMatchObject({ headers: { @@ -119,7 +138,7 @@ describe('licensing plugin', () => { const { license$, refresh } = await plugin.setup(coreSetup); - refresh(); + await refresh(); const license = await license$.pipe(take(1)).toPromise(); expect(license.uid).toBe('fresh'); @@ -143,7 +162,7 @@ describe('licensing plugin', () => { coreSetup.http.get.mockRejectedValue(new Error('reason')); const { license$, refresh } = await plugin.setup(coreSetup); - refresh(); + await refresh(); const license = await license$.pipe(take(1)).toPromise(); @@ -161,7 +180,7 @@ describe('licensing plugin', () => { const { license$, refresh } = await plugin.setup(coreSetup); expect(sessionStorage.removeItem).toHaveBeenCalledTimes(0); - refresh(); + await refresh(); await license$.pipe(take(1)).toPromise(); expect(sessionStorage.removeItem).toHaveBeenCalledTimes(1); @@ -169,6 +188,7 @@ describe('licensing plugin', () => { }); }); }); + describe('interceptor', () => { it('register http interceptor checking signature header', async () => { const sessionStorage = coreMock.createStorage(); @@ -201,7 +221,7 @@ describe('licensing plugin', () => { response: { headers: { get(name: string) { - if (name === 'kbn-xpack-sig') { + if (name === 'kbn-license-sig') { return 'signature-1'; } throw new Error('unexpected header'); @@ -226,6 +246,40 @@ describe('licensing plugin', () => { expect(coreSetup.http.get).toHaveBeenCalledTimes(1); }); + it('http interceptor does not trigger license re-fetch for anonymous pages', async () => { + const sessionStorage = coreMock.createStorage(); + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + + const coreSetup = coreMock.createSetup(); + coreSetup.http.anonymousPaths.isAnonymous.mockReturnValue(true); + + let registeredInterceptor: HttpInterceptor; + coreSetup.http.intercept.mockImplementation((interceptor: HttpInterceptor) => { + registeredInterceptor = interceptor; + return () => undefined; + }); + + await plugin.setup(coreSetup); + const httpResponse = { + response: { + headers: { + get(name: string) { + if (name === 'kbn-license-sig') { + return 'signature-1'; + } + throw new Error('unexpected header'); + }, + }, + }, + request: { + url: 'http://10.10.10.10:5601/api/hello', + }, + }; + await registeredInterceptor!.response!(httpResponse as any, null as any); + + expect(coreSetup.http.get).toHaveBeenCalledTimes(0); + }); + 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); @@ -249,7 +303,7 @@ describe('licensing plugin', () => { response: { headers: { get(name: string) { - if (name === 'kbn-xpack-sig') { + if (name === 'kbn-license-sig') { return 'signature-1'; } throw new Error('unexpected header'); @@ -269,31 +323,71 @@ describe('licensing plugin', () => { expect(updated).toBe(false); }); }); - describe('#stop', () => { - it('stops polling', async () => { + + describe('expired banner', () => { + it('does not show "license expired" banner if license is not expired.', async () => { const sessionStorage = coreMock.createStorage(); plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + const coreSetup = coreMock.createSetup(); - const { license$ } = await plugin.setup(coreSetup); + coreSetup.http.get.mockResolvedValueOnce( + licenseMock.create({ license: { status: 'active', type: 'gold' } }) + ); - let completed = false; - license$.subscribe({ complete: () => (completed = true) }); + const { refresh } = await plugin.setup(coreSetup); - await plugin.stop(); - expect(completed).toBe(true); + const coreStart = coreMock.createStart(); + await plugin.start(coreStart); + + await refresh(); + expect(coreStart.overlays.banners.add).toHaveBeenCalledTimes(0); }); - it('refresh does not trigger data re-fetch', async () => { + it('shows "license expired" banner if license is expired only once.', async () => { const sessionStorage = coreMock.createStorage(); plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage); + const coreSetup = coreMock.createSetup(); + const activeLicense = licenseMock.create({ license: { status: 'active', type: 'gold' } }); + const expiredLicense = licenseMock.create({ license: { status: 'expired', type: 'gold' } }); + coreSetup.http.get + .mockResolvedValueOnce(activeLicense) + .mockResolvedValueOnce(expiredLicense) + .mockResolvedValueOnce(activeLicense) + .mockResolvedValueOnce(expiredLicense); + const { refresh } = await plugin.setup(coreSetup); - await plugin.stop(); + const coreStart = coreMock.createStart(); + await plugin.start(coreStart); + + await refresh(); + expect(coreStart.overlays.banners.add).toHaveBeenCalledTimes(0); + await refresh(); + expect(coreStart.overlays.banners.add).toHaveBeenCalledTimes(1); + await refresh(); + expect(coreStart.overlays.banners.add).toHaveBeenCalledTimes(1); + await refresh(); + expect(coreStart.overlays.banners.add).toHaveBeenCalledTimes(1); + expect(mountExpiredBannerMock).toHaveBeenCalledWith({ + type: 'gold', + uploadUrl: '/app/kibana#/management/elasticsearch/license_management/upload_license', + }); + }); + }); + + 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); - refresh(); + let completed = false; + license$.subscribe({ complete: () => (completed = true) }); - expect(coreSetup.http.get).toHaveBeenCalledTimes(0); + await plugin.stop(); + expect(completed).toBe(true); }); it('removes http interceptor', async () => { diff --git a/x-pack/plugins/licensing/public/plugin.ts b/x-pack/plugins/licensing/public/plugin.ts index 79ad6f289b67e6..c0dc0f21b90bed 100644 --- a/x-pack/plugins/licensing/public/plugin.ts +++ b/x-pack/plugins/licensing/public/plugin.ts @@ -3,15 +3,14 @@ * 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 } from 'rxjs'; -import { Subject, Subscription, merge } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; - -import { CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; +import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; import { ILicense, LicensingPluginSetup } from '../common/types'; import { createLicenseUpdate } from '../common/license_update'; import { License } from '../common/license'; +import { mountExpiredBanner } from './expired_banner'; export const licensingSessionStorageKey = 'xpack.licensing'; @@ -30,10 +29,11 @@ export class LicensingPlugin implements Plugin { * A function to execute once the plugin's HTTP interceptor needs to stop listening. */ private removeInterceptor?: () => void; - private licenseFetchSubscription?: Subscription; - private storageSubscription?: Subscription; + private internalSubscription?: Subscription; + private isLicenseExpirationBannerShown? = false; private readonly infoEndpoint = '/api/licensing/info'; + private coreStart?: CoreStart; private prevSignature?: string; constructor( @@ -65,19 +65,16 @@ export class LicensingPlugin implements Plugin { } 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$, + const { license$, refreshManually } = createLicenseUpdate( + signatureUpdated$, + this.stop$, () => this.fetchLicense(core), - savedLicense + this.getSaved() ); - this.licenseFetchSubscription = fetchSubscription; - this.storageSubscription = update$.subscribe(license => { + this.internalSubscription = license$.subscribe(license => { if (license.isAvailable) { this.prevSignature = license.signature; this.save(license); @@ -86,12 +83,19 @@ export class LicensingPlugin implements Plugin { // Prevent reusing stale license if the fetch operation fails this.removeSaved(); } + + if (license.status === 'expired' && !this.isLicenseExpirationBannerShown && this.coreStart) { + this.isLicenseExpirationBannerShown = true; + this.showExpiredBanner(license); + } }); this.removeInterceptor = core.http.intercept({ response: async httpResponse => { + // we don't track license as anon users do not have one. + if (core.http.anonymousPaths.isAnonymous(window.location.pathname)) return httpResponse; if (httpResponse.response) { - const signatureHeader = httpResponse.response.headers.get('kbn-xpack-sig'); + const signatureHeader = httpResponse.response.headers.get('kbn-license-sig'); if (this.prevSignature !== signatureHeader) { if (!httpResponse.request!.url.includes(this.infoEndpoint)) { signatureUpdated$.next(); @@ -103,14 +107,14 @@ export class LicensingPlugin implements Plugin { }); return { - refresh: () => { - manualRefresh$.next(); - }, - license$: update$, + refresh: refreshManually, + license$, }; } - public async start() {} + public async start(core: CoreStart) { + this.coreStart = core; + } public stop() { this.stop$.next(); @@ -119,13 +123,9 @@ export class LicensingPlugin implements Plugin { if (this.removeInterceptor !== undefined) { this.removeInterceptor(); } - if (this.licenseFetchSubscription !== undefined) { - this.licenseFetchSubscription.unsubscribe(); - this.licenseFetchSubscription = undefined; - } - if (this.storageSubscription !== undefined) { - this.storageSubscription.unsubscribe(); - this.storageSubscription = undefined; + if (this.internalSubscription !== undefined) { + this.internalSubscription.unsubscribe(); + this.internalSubscription = undefined; } } @@ -136,7 +136,6 @@ export class LicensingPlugin implements Plugin { 'kbn-system-api': 'true', }, }); - return new License({ license: response.license, features: response.features, @@ -146,4 +145,16 @@ export class LicensingPlugin implements Plugin { return new License({ error: error.message, signature: '' }); } }; + + private showExpiredBanner(license: ILicense) { + const uploadUrl = this.coreStart!.http.basePath.prepend( + '/app/kibana#/management/elasticsearch/license_management/upload_license' + ); + this.coreStart!.overlays.banners.add( + mountExpiredBanner({ + type: license.type!, + uploadUrl, + }) + ); + } } diff --git a/x-pack/plugins/licensing/server/licensing_config.ts b/x-pack/plugins/licensing/server/licensing_config.ts index 7be19398828e9a..6cb3e8d9ef3a19 100644 --- a/x-pack/plugins/licensing/server/licensing_config.ts +++ b/x-pack/plugins/licensing/server/licensing_config.ts @@ -6,10 +6,9 @@ import { schema, TypeOf } from '@kbn/config-schema'; -const SECOND = 1000; export const config = { schema: schema.object({ - pollingFrequency: schema.number({ defaultValue: 30 * SECOND }), + pollingFrequency: schema.duration({ defaultValue: '30s' }), }), }; diff --git a/x-pack/plugins/licensing/server/on_pre_response_handler.test.ts b/x-pack/plugins/licensing/server/on_pre_response_handler.test.ts new file mode 100644 index 00000000000000..4251e72accc9fa --- /dev/null +++ b/x-pack/plugins/licensing/server/on_pre_response_handler.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { BehaviorSubject } from 'rxjs'; +import { createOnPreResponseHandler } from './on_pre_response_handler'; +import { httpServiceMock, httpServerMock } from '../../../../src/core/server/mocks'; +import { licenseMock } from '../common/license.mock'; + +describe('createOnPreResponseHandler', () => { + it('sets license.signature header immediately for non-error responses', async () => { + const refresh = jest.fn(); + const license$ = new BehaviorSubject(licenseMock.create({ signature: 'foo' })); + const toolkit = httpServiceMock.createOnPreResponseToolkit(); + + const interceptor = createOnPreResponseHandler(refresh, license$); + await interceptor(httpServerMock.createKibanaRequest(), { statusCode: 200 }, toolkit); + + expect(refresh).toHaveBeenCalledTimes(0); + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(toolkit.next).toHaveBeenCalledWith({ + headers: { + 'kbn-license-sig': 'foo', + }, + }); + }); + it('sets license.signature header after refresh for non-error responses', async () => { + const updatedLicense = licenseMock.create({ signature: 'bar' }); + const license$ = new BehaviorSubject(licenseMock.create({ signature: 'foo' })); + const refresh = jest.fn().mockImplementation( + () => + new Promise(resolve => { + setTimeout(() => { + license$.next(updatedLicense); + resolve(); + }, 50); + }) + ); + + const toolkit = httpServiceMock.createOnPreResponseToolkit(); + + const interceptor = createOnPreResponseHandler(refresh, license$); + await interceptor(httpServerMock.createKibanaRequest(), { statusCode: 400 }, toolkit); + + expect(refresh).toHaveBeenCalledTimes(1); + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(toolkit.next).toHaveBeenCalledWith({ + headers: { + 'kbn-license-sig': 'bar', + }, + }); + }); +}); diff --git a/x-pack/plugins/licensing/server/on_pre_response_handler.ts b/x-pack/plugins/licensing/server/on_pre_response_handler.ts new file mode 100644 index 00000000000000..c8befceb4fe322 --- /dev/null +++ b/x-pack/plugins/licensing/server/on_pre_response_handler.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { OnPreResponseHandler } from '../../../../src/core/server'; +import { ILicense } from '../common/types'; + +export function createOnPreResponseHandler( + refresh: () => Promise, + license$: Observable +): OnPreResponseHandler { + return async (req, res, t) => { + // If we're returning an error response, refresh license info from + // Elasticsearch in case the error is due to a change in license information + // in Elasticsearch. + // https://github.com/elastic/x-pack-kibana/pull/2876 + if (res.statusCode >= 400) { + await refresh(); + } + const license = await license$.pipe(take(1)).toPromise(); + return t.next({ + headers: { + 'kbn-license-sig': license.signature, + }, + }); + }; +} diff --git a/x-pack/plugins/licensing/server/plugin.test.ts b/x-pack/plugins/licensing/server/plugin.test.ts index 2af3637a2aaf09..62b6ec6a106b78 100644 --- a/x-pack/plugins/licensing/server/plugin.test.ts +++ b/x-pack/plugins/licensing/server/plugin.test.ts @@ -6,6 +6,7 @@ import { BehaviorSubject } from 'rxjs'; import { take, toArray } from 'rxjs/operators'; +import moment from 'moment'; import { LicenseType } from '../common/types'; import { ElasticsearchError, RawLicense } from './types'; import { LicensingPlugin } from './plugin'; @@ -24,7 +25,7 @@ function buildRawLicense(options: Partial = {}): RawLicense { }; return Object.assign(defaultRawLicense, options); } -const pollingFrequency = 100; +const pollingFrequency = moment.duration(100); const flushPromises = (ms = 50) => new Promise(res => setTimeout(res, ms)); @@ -199,7 +200,7 @@ describe('licensing plugin', () => { plugin = new LicensingPlugin( coreMock.createPluginInitializerContext({ // disable polling mechanism - pollingFrequency: 50000, + pollingFrequency: moment.duration(50000), }) ); const dataClient = elasticsearchServiceMock.createClusterClient(); @@ -251,6 +252,26 @@ describe('licensing plugin', () => { `); }); }); + + describe('registers on pre-response interceptor', () => { + let plugin: LicensingPlugin; + + beforeEach(() => { + plugin = new LicensingPlugin(coreMock.createPluginInitializerContext({ pollingFrequency })); + }); + + afterEach(async () => { + await plugin.stop(); + }); + + it('once', async () => { + const coreSetup = coreMock.createSetup(); + + await plugin.setup(coreSetup); + + expect(coreSetup.http.registerOnPreResponse).toHaveBeenCalledTimes(1); + }); + }); }); describe('#stop', () => { @@ -269,31 +290,5 @@ describe('licensing plugin', () => { 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); - - const { refresh } = await plugin.setup(coreSetup); - - dataClient.callAsInternalUser.mockClear(); - - 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 d3dc84c05e25c8..64f7cc56948f2c 100644 --- a/x-pack/plugins/licensing/server/plugin.ts +++ b/x-pack/plugins/licensing/server/plugin.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Observable, Subject, Subscription, merge, timer } from 'rxjs'; -import { take, takeUntil } from 'rxjs/operators'; -import moment from 'moment'; +import { Observable, Subject, Subscription, timer } from 'rxjs'; +import { take } from 'rxjs/operators'; +import moment, { Duration } from 'moment'; import { createHash } from 'crypto'; import stringify from 'json-stable-stringify'; @@ -28,6 +28,7 @@ import { registerRoutes } from './routes'; import { LicenseConfigType } from './licensing_config'; import { createRouteHandlerContext } from './licensing_route_handler_context'; +import { createOnPreResponseHandler } from './on_pre_response_handler'; function normalizeServerLicense(license: RawLicense): PublicLicense { return { @@ -78,7 +79,6 @@ export class LicensingPlugin implements Plugin { private stop$ = new Subject(); private readonly logger: Logger; private readonly config$: Observable; - private licenseFetchSubscription?: Subscription; private loggingSubscription?: Subscription; constructor(private readonly context: PluginInitializerContext) { @@ -94,7 +94,9 @@ export class LicensingPlugin implements Plugin { const { refresh, license$ } = this.createLicensePoller(dataClient, config.pollingFrequency); core.http.registerRouteHandlerContext('licensing', createRouteHandlerContext(license$)); + registerRoutes(core.http.createRouter()); + core.http.registerOnPreResponse(createOnPreResponseHandler(refresh, license$)); return { refresh, @@ -102,17 +104,14 @@ export class LicensingPlugin implements Plugin { }; } - private createLicensePoller(clusterClient: IClusterClient, pollingFrequency: number) { - const manualRefresh$ = new Subject(); - const intervalRefresh$ = timer(0, pollingFrequency); - const refresh$ = merge(intervalRefresh$, manualRefresh$).pipe(takeUntil(this.stop$)); + private createLicensePoller(clusterClient: IClusterClient, pollingFrequency: Duration) { + const intervalRefresh$ = timer(0, pollingFrequency.asMilliseconds()); - const { update$, fetchSubscription } = createLicenseUpdate(refresh$, () => + const { license$, refreshManually } = createLicenseUpdate(intervalRefresh$, this.stop$, () => this.fetchLicense(clusterClient) ); - this.licenseFetchSubscription = fetchSubscription; - this.loggingSubscription = update$.subscribe(license => + this.loggingSubscription = license$.subscribe(license => this.logger.debug( 'Imported license information from Elasticsearch:' + [ @@ -124,11 +123,11 @@ export class LicensingPlugin implements Plugin { ); return { - refresh: () => { + refresh: async () => { this.logger.debug('Requesting Elasticsearch licensing API'); - manualRefresh$.next(); + return await refreshManually(); }, - license$: update$, + license$, }; } @@ -139,8 +138,13 @@ export class LicensingPlugin implements Plugin { path: '/_xpack', }); - const normalizedLicense = normalizeServerLicense(response.license); - const normalizedFeatures = normalizeFeatures(response.features); + const normalizedLicense = response.license + ? normalizeServerLicense(response.license) + : undefined; + const normalizedFeatures = response.features + ? normalizeFeatures(response.features) + : undefined; + const signature = sign({ license: normalizedLicense, features: normalizedFeatures, @@ -179,11 +183,6 @@ export class LicensingPlugin implements Plugin { 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/security/public/session/session_timeout.test.tsx b/x-pack/plugins/security/public/session/session_timeout.test.tsx index eb947ab95c43b6..eca3e7d6727df8 100644 --- a/x-pack/plugins/security/public/session/session_timeout.test.tsx +++ b/x-pack/plugins/security/public/session/session_timeout.test.tsx @@ -124,7 +124,7 @@ describe('Session Timeout', () => { }); test(`starts and does not initialize on an anonymous path`, async () => { - http.anonymousPaths.register(window.location.pathname); + http.anonymousPaths.isAnonymous.mockReturnValue(true); await sessionTimeout.start(); // eslint-disable-next-line dot-notation expect(sessionTimeout['channel']).toBeUndefined(); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1b2b4a83d2140f..71e3b9f37110e6 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6404,9 +6404,6 @@ "xpack.logstash.upgradeFailureActions.goBackButtonLabel": "戻る", "xpack.logstash.upstreamPipelineArgumentMustContainAnIdPropertyErrorMessage": "upstreamPipeline 引数には id プロパティを含める必要があります", "xpack.logstash.workersTooltip": "パイプラインのフィルターとアウトプットステージを同時に実行するワーカーの数です。イベントが詰まってしまう場合や CPU が飽和状態ではない場合は、マシンの処理能力をより有効に活用するため、この数字を上げてみてください。\n\nデフォルト値:ホストの CPU コア数です", - "xpack.main.welcomeBanner.licenseIsExpiredDescription": "管理者または {updateYourLicenseLinkText} に直接お問い合わせください。", - "xpack.main.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "ライセンスを更新", - "xpack.main.welcomeBanner.licenseIsExpiredTitle": "ご使用の {licenseType} ライセンスは期限切れです", "xpack.maps.addLayerPanel.changeDataSourceButtonLabel": "データソースを変更", "xpack.maps.addLayerPanel.chooseDataSourceTitle": "データソースの選択", "xpack.maps.appDescription": "マップアプリケーション", @@ -12752,6 +12749,12 @@ "xpack.lens.xyVisualization.stackedAreaLabel": "スタックされたエリア", "xpack.lens.xyVisualization.stackedBarHorizontalLabel": "スタックされた横棒", "xpack.lens.xyVisualization.stackedBarLabel": "スタックされたバー", - "xpack.lens.xyVisualization.xyLabel": "XY" + "xpack.lens.xyVisualization.xyLabel": "XY", + "xpack.licensing.check.errorExpiredMessage": "{licenseType} ライセンスが期限切れのため {pluginName} を使用できません。", + "xpack.licensing.check.errorUnavailableMessage": "現在ライセンス情報が利用できないため {pluginName} を使用できません。", + "xpack.licensing.check.errorUnsupportedMessage": "ご使用の {licenseType} ライセンスは {pluginName} をサポートしていません。ライセンスをアップグレードしてください。", + "xpack.licensing.welcomeBanner.licenseIsExpiredDescription": "管理者または {updateYourLicenseLinkText} に直接お問い合わせください。", + "xpack.licensing.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "ライセンスを更新", + "xpack.licensing.welcomeBanner.licenseIsExpiredTitle": "ご使用の {licenseType} ライセンスは期限切れです" } } diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3837cb09fa627d..61051779674458 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6345,9 +6345,6 @@ "xpack.logstash.upgradeFailureActions.goBackButtonLabel": "返回", "xpack.logstash.upstreamPipelineArgumentMustContainAnIdPropertyErrorMessage": "upstreamPipeline 参数必须包含 id 属性", "xpack.logstash.workersTooltip": "并行执行管道的筛选和输出阶段的工作线程数目。如果您发现事件出现积压或 CPU 未饱和,请考虑增大此数值,以更好地利用机器处理能力。\n\n默认值:主机的 CPU 核心数", - "xpack.main.welcomeBanner.licenseIsExpiredDescription": "联系您的管理员或直接{updateYourLicenseLinkText}。", - "xpack.main.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "更新您的许可", - "xpack.main.welcomeBanner.licenseIsExpiredTitle": "您的{licenseType}许可已过期", "xpack.maps.addLayerPanel.changeDataSourceButtonLabel": "更改数据源", "xpack.maps.addLayerPanel.chooseDataSourceTitle": "选择数据源", "xpack.maps.appDescription": "地图应用程序", @@ -12780,6 +12777,12 @@ "xpack.lens.xyVisualization.stackedAreaLabel": "堆叠面积图", "xpack.lens.xyVisualization.stackedBarHorizontalLabel": "堆叠水平条形图", "xpack.lens.xyVisualization.stackedBarLabel": "堆叠条形图", - "xpack.lens.xyVisualization.xyLabel": "XY" + "xpack.lens.xyVisualization.xyLabel": "XY", + "xpack.licensing.check.errorExpiredMessage": "您不能使用 {pluginName},因为您的{licenseType}许可证已过期。", + "xpack.licensing.check.errorUnavailableMessage": "您不能使用 {pluginName},因为许可证信息当前不可用。", + "xpack.licensing.check.errorUnsupportedMessage": "您的{licenseType}许可证不支持 {pluginName}。请升级您的许可证。", + "xpack.licensing.welcomeBanner.licenseIsExpiredDescription": "联系您的管理员或直接{updateYourLicenseLinkText}。", + "xpack.licensing.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "更新您的许可", + "xpack.licensing.welcomeBanner.licenseIsExpiredTitle": "您的{licenseType}许可已过期" } -} \ No newline at end of file +} diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 2ac8fff6ef8ab7..e50b19462fff6c 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -34,4 +34,5 @@ require('@kbn/test').runTestsCli([ require.resolve('../test/ui_capabilities/security_only/config'), require.resolve('../test/ui_capabilities/spaces_only/config'), require.resolve('../test/upgrade_assistant_integration/config'), + require.resolve('../test/licensing_plugin/config'), ]); diff --git a/x-pack/test/api_integration/apis/index.js b/x-pack/test/api_integration/apis/index.js index ca339e9f407f22..86ef4458990390 100644 --- a/x-pack/test/api_integration/apis/index.js +++ b/x-pack/test/api_integration/apis/index.js @@ -27,6 +27,5 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./siem')); loadTestFile(require.resolve('./short_urls')); loadTestFile(require.resolve('./lens')); - loadTestFile(require.resolve('./licensing')); }); } diff --git a/x-pack/test/licensing_plugin/apis/changes.ts b/x-pack/test/licensing_plugin/apis/changes.ts new file mode 100644 index 00000000000000..cbff783a0633c3 --- /dev/null +++ b/x-pack/test/licensing_plugin/apis/changes.ts @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../services'; +import { PublicLicenseJSON } from '../../../plugins/licensing/server'; + +const delay = (ms: number) => new Promise(res => setTimeout(res, ms)); + +export default function({ getService, getPageObjects }: FtrProviderContext) { + const supertest = getService('supertest'); + const esSupertestWithoutAuth = getService('esSupertestWithoutAuth'); + const security = getService('security'); + const PageObjects = getPageObjects(['common', 'security']); + const testSubjects = getService('testSubjects'); + + const scenario = { + async setup() { + await security.role.create('license_manager-role', { + elasticsearch: { + cluster: ['all'], + }, + kibana: [ + { + base: ['all'], + spaces: ['*'], + }, + ], + }); + + await security.user.create('license_manager_user', { + password: 'license_manager_user-password', + roles: ['license_manager-role'], + full_name: 'license_manager user', + }); + + // ensure we're logged out so we can login as the appropriate users + await PageObjects.security.logout(); + await PageObjects.security.login('license_manager_user', 'license_manager_user-password'); + }, + + async teardown() { + await security.role.delete('license_manager-role'); + }, + + async startBasic() { + const response = await esSupertestWithoutAuth + .post('/_license/start_basic?acknowledge=true') + .auth('license_manager_user', 'license_manager_user-password') + .expect(200); + + expect(response.body.basic_was_started).to.be(true); + }, + + async startTrial() { + const response = await esSupertestWithoutAuth + .post('/_license/start_trial?acknowledge=true') + .auth('license_manager_user', 'license_manager_user-password') + .expect(200); + + expect(response.body.trial_was_started).to.be(true); + }, + + async deleteLicense() { + const response = await esSupertestWithoutAuth + .delete('/_license') + .auth('license_manager_user', 'license_manager_user-password') + .expect(200); + + expect(response.body.acknowledged).to.be(true); + }, + + async getLicense(): Promise { + // > --xpack.licensing.pollingFrequency set in test config + // to wait for Kibana server to re-fetch the license from Elasticsearch + await delay(1000); + + const { body } = await supertest.get('/api/licensing/info').expect(200); + return body; + }, + }; + + describe('changes in license types', () => { + after(async () => { + await scenario.startBasic(); + }); + + it('provides changes in license types', async () => { + await scenario.setup(); + const initialLicense = await scenario.getLicense(); + expect(initialLicense.license?.type).to.be('basic'); + // security enabled explicitly in test config + expect(initialLicense.features?.security).to.eql({ + isAvailable: true, + isEnabled: true, + }); + + const refetchedLicense = await scenario.getLicense(); + expect(refetchedLicense.license?.type).to.be('basic'); + expect(refetchedLicense.signature).to.be(initialLicense.signature); + + // server allows to request trial only once. + // other attempts will throw 403 + await scenario.startTrial(); + const trialLicense = await scenario.getLicense(); + expect(trialLicense.license?.type).to.be('trial'); + expect(trialLicense.signature).to.not.be(initialLicense.signature); + expect(trialLicense.features?.security).to.eql({ + isAvailable: true, + isEnabled: true, + }); + + await scenario.startBasic(); + const basicLicense = await scenario.getLicense(); + expect(basicLicense.license?.type).to.be('basic'); + expect(basicLicense.signature).not.to.be(initialLicense.signature); + expect(trialLicense.features?.security).to.eql({ + isAvailable: true, + isEnabled: true, + }); + + await scenario.deleteLicense(); + const inactiveLicense = await scenario.getLicense(); + expect(inactiveLicense.signature).to.not.be(initialLicense.signature); + expect(inactiveLicense).to.not.have.property('license'); + expect(inactiveLicense.features?.security).to.eql({ + isAvailable: false, + isEnabled: true, + }); + // banner shown only when license expired not just deleted + await testSubjects.missingOrFail('licenseExpiredBanner'); + }); + }); +} diff --git a/x-pack/test/licensing_plugin/apis/header.ts b/x-pack/test/licensing_plugin/apis/header.ts new file mode 100644 index 00000000000000..8d95054feaaf23 --- /dev/null +++ b/x-pack/test/licensing_plugin/apis/header.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../services'; + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('Header', () => { + it("Injects 'kbn-license-sig' header to the all responses", async () => { + const response = await supertest.get('/'); + + expect(response.header).property('kbn-license-sig'); + expect(response.header['kbn-license-sig']).to.not.be.empty(); + }); + }); +} diff --git a/x-pack/test/licensing_plugin/apis/index.ts b/x-pack/test/licensing_plugin/apis/index.ts new file mode 100644 index 00000000000000..fbc0449dcd8fcf --- /dev/null +++ b/x-pack/test/licensing_plugin/apis/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../services'; + +export default function({ loadTestFile }: FtrProviderContext) { + describe('Licensing plugin', function() { + this.tags('ciGroup2'); + loadTestFile(require.resolve('./info')); + loadTestFile(require.resolve('./header')); + + // MUST BE LAST! CHANGES LICENSE TYPE! + loadTestFile(require.resolve('./changes')); + }); +} diff --git a/x-pack/test/api_integration/apis/licensing/info.ts b/x-pack/test/licensing_plugin/apis/info.ts similarity index 75% rename from x-pack/test/api_integration/apis/licensing/info.ts rename to x-pack/test/licensing_plugin/apis/info.ts index 0b48080616fb98..7ec009d85cd094 100644 --- a/x-pack/test/api_integration/apis/licensing/info.ts +++ b/x-pack/test/licensing_plugin/apis/info.ts @@ -5,7 +5,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; +import { FtrProviderContext } from '../services'; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -19,6 +19,12 @@ export default function({ getService }: FtrProviderContext) { expect(response.body).property('license'); expect(response.body).property('signature'); }); + + it('returns a correct license type', async () => { + const response = await supertest.get('/api/licensing/info').expect(200); + + expect(response.body.license.type).to.be('basic'); + }); }); }); } diff --git a/x-pack/test/licensing_plugin/config.ts b/x-pack/test/licensing_plugin/config.ts new file mode 100644 index 00000000000000..810dd3edc76b9d --- /dev/null +++ b/x-pack/test/licensing_plugin/config.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { services, pageObjects } from './services'; + +const license = 'basic'; + +export default async function({ readConfigFile }: FtrConfigProviderContext) { + const functionalTestsConfig = await readConfigFile(require.resolve('../functional/config.js')); + + const servers = { + ...functionalTestsConfig.get('servers'), + elasticsearch: { + ...functionalTestsConfig.get('servers.elasticsearch'), + }, + kibana: { + ...functionalTestsConfig.get('servers.kibana'), + }, + }; + + return { + testFiles: [require.resolve('./apis')], + servers, + services, + pageObjects, + junit: { + reportName: 'License plugin API Integration Tests', + }, + + esTestCluster: { + ...functionalTestsConfig.get('esTestCluster'), + license, + serverArgs: [ + ...functionalTestsConfig.get('esTestCluster.serverArgs'), + 'xpack.security.enabled=true', + ], + }, + + kbnTestServer: { + ...functionalTestsConfig.get('kbnTestServer'), + serverArgs: [ + ...functionalTestsConfig.get('kbnTestServer.serverArgs'), + '--xpack.licensing.pollingFrequency=300', + ], + }, + + apps: { + ...functionalTestsConfig.get('apps'), + }, + }; +} diff --git a/x-pack/test/licensing_plugin/services.ts b/x-pack/test/licensing_plugin/services.ts new file mode 100644 index 00000000000000..7ded6df80cf556 --- /dev/null +++ b/x-pack/test/licensing_plugin/services.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; + +import { services as functionalTestServices } from '../functional/services'; +import { services as kibanaApiIntegrationServices } from '../api_integration/services'; +import { pageObjects } from '../functional/page_objects'; + +export const services = { + ...functionalTestServices, + supertest: kibanaApiIntegrationServices.supertest, + esSupertestWithoutAuth: kibanaApiIntegrationServices.esSupertestWithoutAuth, +}; + +export { pageObjects }; + +export type FtrProviderContext = GenericFtrProviderContext;