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