Licensing plugin (#49345) (#51026)

* 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:
Mikhail Shustov 2019-11-19 15:00:43 +01:00 committed by GitHub
parent 10deb3386b
commit 7f5e568b74
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 1796 additions and 1080 deletions

View file

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

View file

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

View file

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

View file

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

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

View 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,
};

View 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"`);
});
});
});

View 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,
};
}
}

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

View 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,
};
}

View 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;
}

View file

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

View 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);

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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