NP licensing plugin improvements (#51818)

* add onPreResponse interceptor

* use onPreResponse interceptor to add license sign

* expose registerPreResponse to plugins

* refresh for license update get the most fresh license

* license plugin injects own header for signature:  'kbn-license-sig'

* add integration tests for license type and license header

* switch config to duration

* don't run interceptor on anon paths. add tests

* add functional tests for licensing plugin

* regen docs

* fix test in security due to updated mocks;

* update snapshots accoring to new mock implementation

* migrate license expired banner to NP

* add readme for the licensing plugin

* remove outdated import. licensing has separate functional tests

* add tag for test to run on CI

* regen docs

* Update x-pack/plugins/licensing/README.md

Co-Authored-By: Josh Dover <me@joshdover.com>

* update tests
This commit is contained in:
Mikhail Shustov 2019-12-09 10:40:18 +01:00 committed by GitHub
parent b66af16153
commit dce893bb79
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 800 additions and 419 deletions

View file

@ -1225,6 +1225,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)_

View file

@ -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<HttpSetup> & {
basePath: BasePath;
anonymousPaths: jest.Mocked<HttpSetup['anonymousPaths']>;
};
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(),

View file

@ -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<CoreSetup> & { notifications: MockedKeys<NotificationsSetup> } = {
const mock = {
application: applicationServiceMock.createSetupContract(),
context: contextServiceMock.createSetupContract(),
fatalErrors: fatalErrorsServiceMock.createSetupContract(),
@ -58,7 +58,7 @@ function createCoreSetupMock({ basePath = '' } = {}) {
}
function createCoreStartMock({ basePath = '' } = {}) {
const mock: MockedKeys<CoreStart> & { notifications: MockedKeys<NotificationsSetup> } = {
const mock = {
application: applicationServiceMock.createStartContract(),
chrome: chromeServiceMock.createStartContract(),
docLinks: docLinksServiceMock.createStartContract(),

View file

@ -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<InternalHttpServiceSetup> & {
basePath: jest.Mocked<InternalHttpServiceSetup['basePath']>;
@ -93,12 +94,17 @@ const createAuthToolkitMock = (): jest.Mocked<AuthToolkit> => ({
authenticated: jest.fn(),
});
const createOnPreResponseToolkitMock = (): jest.Mocked<OnPreResponseToolkit> => ({
next: jest.fn(),
});
export const httpServiceMock = {
create: createHttpServiceMock,
createBasePath: createBasePathMock,
createSetupContract: createSetupContractMock,
createOnPreAuthToolkit: createOnPreAuthToolkitMock,
createOnPostAuthToolkit: createOnPostAuthToolkitMock,
createOnPreResponseToolkit: createOnPreResponseToolkitMock,
createAuthToolkit: createAuthToolkitMock,
createRouter: mockRouter.create,
};

View file

@ -964,7 +964,6 @@ describe('OnPreResponse', () => {
headers: { 'x-kibana-header': 'value' },
})
);
registerOnPreResponse((req, res, t) =>
t.next({
headers: { 'x-kibana-header': 'value' },

View file

@ -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": "",

View file

@ -47,9 +47,6 @@ export const xpackMain = (kibana) => {
},
uiExports: {
hacks: [
'plugins/xpack_main/hacks/check_xpack_info_change',
],
replaceInjectedVars,
injectDefaultVars(server) {
const config = server.config();

View file

@ -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);
});
});

View file

@ -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: (
<EuiCallOut
iconType="help"
color="warning"
title={<FormattedMessage
id="xpack.main.welcomeBanner.licenseIsExpiredTitle"
defaultMessage="Your {licenseType} license is expired"
values={{ licenseType: license.type }}
/>}
>
<FormattedMessage
id="xpack.main.welcomeBanner.licenseIsExpiredDescription"
defaultMessage="Contact your administrator or {updateYourLicenseLinkText} directly."
values={{
updateYourLicenseLinkText: (
<a href={uploadLicensePath}>
<FormattedMessage
id="xpack.main.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText"
defaultMessage="update your license"
/>
</a>
)
}}
/>
</EuiCallOut>
),
});
}
});
/**
* 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');
});

View file

@ -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<ILicense>` 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<ILicense>` 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<ILicense>` 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<ILicense>` 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
});
})
}
}
```

View file

@ -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);
});
});

View file

@ -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<unknown>,
stop$: Observable<unknown>,
fetcher: () => Promise<ILicense>,
initialValues?: ILicense
) {
const fetched$ = trigger$.pipe(
switchMap(fetcher),
const triggerRefresh$ = trigger$.pipe(switchMap(fetcher));
const manuallyFetched$ = new Subject<ILicense>();
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<ILicense>;
const fetchSubscription = fetched$.connect();
stop$.subscribe({ complete: () => fetchSubscription.unsubscribe() });
const initialValues$ = initialValues ? from([undefined, initialValues]) : from([undefined]);
const update$: Observable<ILicense> = merge(initialValues$, fetched$).pipe(
const license$: Observable<ILicense> = 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;
},
};
}

View file

@ -183,5 +183,5 @@ export interface LicensingPluginSetup {
/**
* Triggers licensing information re-fetch.
*/
refresh(): void;
refresh(): Promise<ILicense>;
}

View file

@ -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> = props => (
<EuiCallOut
iconType="help"
color="warning"
data-test-subj="licenseExpiredBanner"
title={
<FormattedMessage
id="xpack.licensing.welcomeBanner.licenseIsExpiredTitle"
defaultMessage="Your {licenseType} license is expired"
values={{ licenseType: props.type }}
/>
}
>
<FormattedMessage
id="xpack.licensing.welcomeBanner.licenseIsExpiredDescription"
defaultMessage="Contact your administrator or {updateYourLicenseLinkText} directly."
values={{
updateYourLicenseLinkText: (
<a href={props.uploadUrl}>
<FormattedMessage
id="xpack.licensing.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText"
defaultMessage="update your license"
/>
</a>
),
}}
/>
</EuiCallOut>
);
export const mountExpiredBanner = (props: Props) =>
toMountPoint(<ExpiredBanner type={props.type!} uploadUrl={props.uploadUrl} />);

View file

@ -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,
}));

View file

@ -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,6 +323,59 @@ describe('licensing plugin', () => {
expect(updated).toBe(false);
});
});
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();
coreSetup.http.get.mockResolvedValueOnce(
licenseMock.create({ license: { status: 'active', type: 'gold' } })
);
const { refresh } = await plugin.setup(coreSetup);
const coreStart = coreMock.createStart();
await plugin.start(coreStart);
await refresh();
expect(coreStart.overlays.banners.add).toHaveBeenCalledTimes(0);
});
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);
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();
@ -283,19 +390,6 @@ describe('licensing plugin', () => {
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);

View file

@ -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<LicensingPluginSetup> {
* 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<LicensingPluginSetup> {
}
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<LicensingPluginSetup> {
// 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<LicensingPluginSetup> {
});
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<LicensingPluginSetup> {
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<LicensingPluginSetup> {
'kbn-system-api': 'true',
},
});
return new License({
license: response.license,
features: response.features,
@ -146,4 +145,16 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
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,
})
);
}
}

View file

@ -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' }),
}),
};

View file

@ -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',
},
});
});
});

View file

@ -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<ILicense>,
license$: Observable<ILicense>
): 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,
},
});
};
}

View file

@ -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> = {}): 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);
});
});
});

View file

@ -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<LicensingPluginSetup> {
private stop$ = new Subject();
private readonly logger: Logger;
private readonly config$: Observable<LicenseConfigType>;
private licenseFetchSubscription?: Subscription;
private loggingSubscription?: Subscription;
constructor(private readonly context: PluginInitializerContext) {
@ -94,7 +94,9 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
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<LicensingPluginSetup> {
};
}
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<LicensingPluginSetup> {
);
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<LicensingPluginSetup> {
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<LicensingPluginSetup> {
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;

View file

@ -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();

View file

@ -6396,9 +6396,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": "マップアプリケーション",
@ -12750,6 +12747,9 @@
"xpack.lens.xyVisualization.xyLabel": "XY",
"xpack.licensing.check.errorExpiredMessage": "{licenseType} ライセンスが期限切れのため {pluginName} を使用できません。",
"xpack.licensing.check.errorUnavailableMessage": "現在ライセンス情報が利用できないため {pluginName} を使用できません。",
"xpack.licensing.check.errorUnsupportedMessage": "ご使用の {licenseType} ライセンスは {pluginName} をサポートしていません。ライセンスをアップグレードしてください。"
"xpack.licensing.check.errorUnsupportedMessage": "ご使用の {licenseType} ライセンスは {pluginName} をサポートしていません。ライセンスをアップグレードしてください。",
"xpack.licensing.welcomeBanner.licenseIsExpiredDescription": "管理者または {updateYourLicenseLinkText} に直接お問い合わせください。",
"xpack.licensing.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "ライセンスを更新",
"xpack.licensing.welcomeBanner.licenseIsExpiredTitle": "ご使用の {licenseType} ライセンスは期限切れです"
}
}

View file

@ -6398,9 +6398,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": "地图应用程序",
@ -12839,6 +12836,9 @@
"xpack.lens.xyVisualization.xyLabel": "XY",
"xpack.licensing.check.errorExpiredMessage": "您不能使用 {pluginName},因为您的{licenseType}许可证已过期。",
"xpack.licensing.check.errorUnavailableMessage": "您不能使用 {pluginName},因为许可证信息当前不可用。",
"xpack.licensing.check.errorUnsupportedMessage": "您的{licenseType}许可证不支持 {pluginName}。请升级您的许可证。"
"xpack.licensing.check.errorUnsupportedMessage": "您的{licenseType}许可证不支持 {pluginName}。请升级您的许可证。",
"xpack.licensing.welcomeBanner.licenseIsExpiredDescription": "联系您的管理员或直接{updateYourLicenseLinkText}。",
"xpack.licensing.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "更新您的许可",
"xpack.licensing.welcomeBanner.licenseIsExpiredTitle": "您的{licenseType}许可已过期"
}
}
}

View file

@ -35,4 +35,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'),
]);

View file

@ -27,7 +27,6 @@ export default function ({ loadTestFile }) {
loadTestFile(require.resolve('./siem'));
loadTestFile(require.resolve('./short_urls'));
loadTestFile(require.resolve('./lens'));
loadTestFile(require.resolve('./licensing'));
loadTestFile(require.resolve('./endpoint'));
});
}

View file

@ -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<PublicLicenseJSON> {
// > --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');
});
});
}

View file

@ -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();
});
});
}

View file

@ -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'));
});
}

View file

@ -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');
});
});
});
}

View file

@ -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'),
},
};
}

View file

@ -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<typeof services, typeof pageObjects>;