From 0415e9666bd2a1ecd7975024c3c70dd1c10d43f5 Mon Sep 17 00:00:00 2001 From: Eli Perelman Date: Fri, 12 Jul 2019 12:30:33 -0500 Subject: [PATCH 1/2] Initial implementation of server licensing service --- src/core/server/licensing/constants.ts | 34 ++++ src/core/server/licensing/index.ts | 21 ++ src/core/server/licensing/license_feature.ts | 51 +++++ src/core/server/licensing/licensing_config.ts | 36 ++++ .../server/licensing/licensing_service.ts | 160 +++++++++++++++ .../licensing/licensing_service_setup.ts | 191 ++++++++++++++++++ src/core/server/licensing/schema.ts | 27 +++ src/core/server/licensing/types.ts | 38 ++++ src/core/server/plugins/plugins_service.ts | 2 + src/core/server/server.ts | 7 +- 10 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 src/core/server/licensing/constants.ts create mode 100644 src/core/server/licensing/index.ts create mode 100644 src/core/server/licensing/license_feature.ts create mode 100644 src/core/server/licensing/licensing_config.ts create mode 100644 src/core/server/licensing/licensing_service.ts create mode 100644 src/core/server/licensing/licensing_service_setup.ts create mode 100644 src/core/server/licensing/schema.ts create mode 100644 src/core/server/licensing/types.ts diff --git a/src/core/server/licensing/constants.ts b/src/core/server/licensing/constants.ts new file mode 100644 index 00000000000000..1ba22cfa186f02 --- /dev/null +++ b/src/core/server/licensing/constants.ts @@ -0,0 +1,34 @@ +/* + * 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. + */ + +export const SERVICE_NAME = 'licensing'; +export const DEFAULT_POLLING_FREQUENCY = 30001; // 30 seconds +export enum LICENSE_STATUS { + Unavailable = 'UNAVAILABLE', + Invalid = 'INVALID', + Expired = 'EXPIRED', + Valid = 'VALID', +} +export enum LICENSE_TYPE { + basic = 10, + standard = 20, + gold = 30, + platinum = 40, + trial = 50, +} diff --git a/src/core/server/licensing/index.ts b/src/core/server/licensing/index.ts new file mode 100644 index 00000000000000..799be15247cad3 --- /dev/null +++ b/src/core/server/licensing/index.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +export * from './types'; +export { LicensingService } from './licensing_service'; diff --git a/src/core/server/licensing/license_feature.ts b/src/core/server/licensing/license_feature.ts new file mode 100644 index 00000000000000..0405b04d15af86 --- /dev/null +++ b/src/core/server/licensing/license_feature.ts @@ -0,0 +1,51 @@ +/* + * 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 { LicensingServiceSetup } from './licensing_service_setup'; +import { LicenseFeatureSerializer } from './types'; + +export class LicenseFeature { + private serializable: LicenseFeatureSerializer = service => ({ + name: this.name, + isAvailable: this.isAvailable, + isEnabled: this.isEnabled, + }); + + constructor( + public name: string, + private feature: any = {}, + private service: LicensingServiceSetup + ) {} + + get isAvailable() { + return !!this.feature.available; + } + + get isEnabled() { + return !!this.feature.enabled; + } + + onObject(serializable: LicenseFeatureSerializer) { + this.serializable = serializable; + } + + toObject() { + return this.serializable(this.service); + } +} diff --git a/src/core/server/licensing/licensing_config.ts b/src/core/server/licensing/licensing_config.ts new file mode 100644 index 00000000000000..0eea886795646b --- /dev/null +++ b/src/core/server/licensing/licensing_config.ts @@ -0,0 +1,36 @@ +/* + * 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 { Env } from '../config'; +import { LicensingConfigType } from './types'; + +export class LicensingConfig { + public enabled: boolean; + public clusterSource: string; + public pollingFrequency: number; + + /** + * @internal + */ + constructor(rawConfig: LicensingConfigType, env: Env) { + this.enabled = rawConfig.enabled; + this.clusterSource = rawConfig.clusterSource; + this.pollingFrequency = rawConfig.pollingFrequency; + } +} diff --git a/src/core/server/licensing/licensing_service.ts b/src/core/server/licensing/licensing_service.ts new file mode 100644 index 00000000000000..d34eb815fe9901 --- /dev/null +++ b/src/core/server/licensing/licensing_service.ts @@ -0,0 +1,160 @@ +/* + * 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, Observable, Subscription, from, interval } from 'rxjs'; +import { first, map, switchMap, tap } from 'rxjs/operators'; +import moment from 'moment'; +import { CoreService } from '../../types'; +import { Logger } from '../logging'; +import { CoreContext } from '../core_context'; +import { LicensingServiceSubject, LicensingConfigType, LicensingSetupDependencies } from './types'; +import { SERVICE_NAME, LICENSE_TYPE } from './constants'; +import { LicensingConfig } from './licensing_config'; +import { LicensingServiceSetup } from './licensing_service_setup'; + +/** @internal */ +export class LicensingService + implements CoreService { + private readonly config$: Observable; + private readonly logger: Logger; + private poller$!: Observable; + private pollerSubscription!: Subscription; + private service$!: LicensingServiceSubject; + private license: any; + + constructor(private readonly coreContext: CoreContext) { + this.logger = coreContext.logger.get(SERVICE_NAME); + this.config$ = coreContext.configService + .atPath(SERVICE_NAME) + .pipe(map(rawConfig => new LicensingConfig(rawConfig, coreContext.env))); + } + + private hasLicenseInfoChanged(newLicense: any) { + return ( + newLicense.mode !== this.license.mode || + newLicense.status !== this.license.status || + newLicense.expiry_date_in_millis !== this.license.expiry_date_in_millis + ); + } + + private async fetchInfo( + { http }: LicensingSetupDependencies, + clusterSource: string, + pollingFrequency: number + ) { + this.logger.debug( + `Calling [${clusterSource}] Elasticsearch _xpack API. Polling frequency: ${pollingFrequency}` + ); + + const cluster = http.server.plugins.elasticsearch.getCluster(clusterSource); + + try { + const response = await cluster.callWithInternalUser('transport.request', { + method: 'GET', + path: '/_xpack', + }); + const newLicense = (response && response.license) || {}; + const features = (response && response.features) || {}; + const licenseInfoChanged = this.hasLicenseInfoChanged(newLicense); + + if (licenseInfoChanged) { + const licenseInfo = [ + `mode: ${newLicense.mode}`, + `status: ${newLicense.status}`, + 'expiry_date_in_millis' in newLicense && + `expiry date: ${moment(newLicense.expiry_date_in_millis, 'x').format()}`, + ] + .filter(Boolean) + .join(' | '); + + this.logger.info( + `Imported ${this.license ? 'changed ' : ''}license information` + + ` from Elasticsearch for the [${clusterSource}] cluster: ${licenseInfo}` + ); + + return { license: false, error: null, features }; + } + + return { license: newLicense, 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, + deps: LicensingSetupDependencies + ) { + if (this.service$) { + return this.service$; + } + + const service$ = new BehaviorSubject(null); + + this.poller$ = interval(pollingFrequency); + this.poller$.pipe( + switchMap(_ => + from(this.fetchInfo(deps, clusterSource, pollingFrequency)).pipe( + tap(({ license, error, features }) => { + // If license is false, the license did not change and we don't need to push + // a new one + if (license !== false) { + service$.next(new LicensingServiceSetup(license, features, error, clusterSource)); + } + }) + ) + ) + ); + + this.pollerSubscription = this.poller$.subscribe(); + + deps.http.server.events.on('stop', () => { + this.stop(); + }); + + return service$; + } + + public async setup(deps: LicensingSetupDependencies) { + const config = await this.config$.pipe(first()).toPromise(); + const service$ = this.create(config, deps); + + this.service$ = service$; + + return this.service$; + } + + public async start(deps: LicensingSetupDependencies) { + const config = await this.config$.pipe(first()).toPromise(); + const service$ = this.create(config, deps); + + return service$; + } + + public async stop() { + if (this.pollerSubscription) { + this.pollerSubscription.unsubscribe(); + } + } +} diff --git a/src/core/server/licensing/licensing_service_setup.ts b/src/core/server/licensing/licensing_service_setup.ts new file mode 100644 index 00000000000000..66e9d15ba55c76 --- /dev/null +++ b/src/core/server/licensing/licensing_service_setup.ts @@ -0,0 +1,191 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { createHash } from 'crypto'; +import { LicenseFeature } from './license_feature'; +import { LICENSE_STATUS, LICENSE_TYPE } from './constants'; +import { LicenseType } 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 LicensingServiceSetup { + private readonly _license: any; + private readonly license: any; + private readonly features: any; + private _signature!: string; + private objectified!: any; + private featuresMap: Map; + + constructor( + license: any, + features: any, + private error: Error | null, + private clusterSource: string + ) { + this._license = license; + this.license = license || {}; + this.features = features; + this.featuresMap = new Map(); + } + + get uid() { + return this.license.uid; + } + + get isActive() { + return this.license.status === 'active'; + } + + get expiryDateInMillis() { + return this.license.expiry_date_in_millis; + } + + get type() { + return this.license.type; + } + + get isAvailable() { + return !!this._license; + } + + get mode() { + return this.license.mode; + } + + get isBasic() { + return this.isActive && this.mode === 'basic'; + } + + get isNotBasic() { + return this.isActive && this.mode !== 'basic'; + } + + get reasonUnavailable() { + if (!this._license) { + 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; + } + + 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.license.mode); + } + + meetsMinimumOf(minimum: LICENSE_TYPE) { + return minimum >= LICENSE_TYPE[this.mode as LicenseType]; + } + + check(pluginName: string, minimumLicenseRequired: LICENSE_TYPE | string) { + const minimum = toLicenseType(minimumLicenseRequired); + + if (!this._license || !this.isAvailable) { + return { + status: LICENSE_STATUS.Unavailable, + message: i18n.translate('xpack.server.checkLicense.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 { + status: LICENSE_STATUS.Invalid, + message: i18n.translate('xpack.server.checkLicense.errorUnsupportedMessage', { + defaultMessage: + 'Your {licenseType} license does not support {pluginName}. Please upgrade your license.', + values: { licenseType, pluginName }, + }), + }; + } + + if (!this.license.isActive) { + return { + status: LICENSE_STATUS.Expired, + message: i18n.translate('xpack.server.checkLicense.errorExpiredMessage', { + defaultMessage: + 'You cannot use {pluginName} because your {licenseType} license has expired.', + values: { licenseType, pluginName }, + }), + }; + } + + return { status: 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; + } + + feature(name: string) { + if (!this.featuresMap.has(name)) { + this.featuresMap.set(name, new LicenseFeature(name, this.features[name], this)); + } + + return this.featuresMap.get(name); + } +} diff --git a/src/core/server/licensing/schema.ts b/src/core/server/licensing/schema.ts new file mode 100644 index 00000000000000..f863f3a9d575ad --- /dev/null +++ b/src/core/server/licensing/schema.ts @@ -0,0 +1,27 @@ +/* + * 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 { schema as Schema } from '@kbn/config-schema'; +import { DEFAULT_POLLING_FREQUENCY } from './constants'; + +export const schema = Schema.object({ + enabled: Schema.boolean({ defaultValue: true }), + clusterSource: Schema.string({ defaultValue: 'data' }), + pollingFrequency: Schema.number({ defaultValue: DEFAULT_POLLING_FREQUENCY }), +}); diff --git a/src/core/server/licensing/types.ts b/src/core/server/licensing/types.ts new file mode 100644 index 00000000000000..e417c4ed2290dc --- /dev/null +++ b/src/core/server/licensing/types.ts @@ -0,0 +1,38 @@ +/* + * 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 } from 'rxjs'; +import { TypeOf } from '@kbn/config-schema'; +import { HttpServiceSetup } from '../http'; +import { schema } from './schema'; +import { LICENSE_TYPE } from './constants'; +import { LicensingServiceSetup } from './licensing_service_setup'; + +/** @public */ +export type LicensingServiceSubject = BehaviorSubject; +/** @public */ +export type LicensingConfigType = TypeOf; +/** @public */ +export type LicenseType = keyof typeof LICENSE_TYPE; +/** @public */ +export interface LicensingSetupDependencies { + http: HttpServiceSetup; +} +/** @public */ +export type LicenseFeatureSerializer = (service: LicensingServiceSetup) => any; diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index 95d3f26fff91eb..332839c9aee9b3 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -28,6 +28,7 @@ import { discover, PluginDiscoveryError, PluginDiscoveryErrorType } from './disc import { DiscoveredPlugin, DiscoveredPluginInternal, PluginWrapper, PluginName } from './plugin'; import { PluginsConfig, PluginsConfigType } from './plugins_config'; import { PluginsSystem } from './plugins_system'; +import { LicensingServiceSetup } from '../licensing'; /** @public */ export interface PluginsServiceSetup { @@ -47,6 +48,7 @@ export interface PluginsServiceStart { export interface PluginsServiceSetupDeps { elasticsearch: ElasticsearchServiceSetup; http: HttpServiceSetup; + licensing: LicensingServiceSetup; } /** @internal */ diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 01f2673c3f9e5c..db826d65458a27 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -22,6 +22,7 @@ import { Type } from '@kbn/config-schema'; import { ConfigService, Env, Config, ConfigPath } from './config'; import { ElasticsearchService } from './elasticsearch'; import { HttpService, HttpServiceSetup, Router } from './http'; +import { LicensingService } from './licensing'; import { LegacyService } from './legacy'; import { Logger, LoggerFactory } from './logging'; import { PluginsService, config as pluginsConfig } from './plugins'; @@ -36,6 +37,7 @@ export class Server { public readonly configService: ConfigService; private readonly elasticsearch: ElasticsearchService; private readonly http: HttpService; + private readonly licensing: LicensingService; private readonly plugins: PluginsService; private readonly legacy: LegacyService; private readonly log: Logger; @@ -50,6 +52,7 @@ export class Server { const core = { configService: this.configService, env, logger }; this.http = new HttpService(core); + this.licensing = new LicensingService(core); this.plugins = new PluginsService(core); this.legacy = new LegacyService(core); this.elasticsearch = new ElasticsearchService(core); @@ -64,15 +67,17 @@ export class Server { const elasticsearchServiceSetup = await this.elasticsearch.setup({ http: httpSetup, }); - + const licensingSetup = await this.licensing.setup({ http: httpSetup }); const pluginsSetup = await this.plugins.setup({ elasticsearch: elasticsearchServiceSetup, http: httpSetup, + licensing: licensingSetup, }); const coreSetup = { elasticsearch: elasticsearchServiceSetup, http: httpSetup, + licensing: licensingSetup, plugins: pluginsSetup, }; From 499ff7b23af1c5b676a3a7b628f914df7faf525a Mon Sep 17 00:00:00 2001 From: Eli Perelman Date: Fri, 12 Jul 2019 13:24:22 -0500 Subject: [PATCH 2/2] Set licensing to false to denote lack of license change correctly --- .../server/licensing/licensing_service.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/core/server/licensing/licensing_service.ts b/src/core/server/licensing/licensing_service.ts index d34eb815fe9901..e1404b192c379a 100644 --- a/src/core/server/licensing/licensing_service.ts +++ b/src/core/server/licensing/licensing_service.ts @@ -73,24 +73,24 @@ export class LicensingService const features = (response && response.features) || {}; const licenseInfoChanged = this.hasLicenseInfoChanged(newLicense); - if (licenseInfoChanged) { - const licenseInfo = [ - `mode: ${newLicense.mode}`, - `status: ${newLicense.status}`, - 'expiry_date_in_millis' in newLicense && - `expiry date: ${moment(newLicense.expiry_date_in_millis, 'x').format()}`, - ] - .filter(Boolean) - .join(' | '); - - this.logger.info( - `Imported ${this.license ? 'changed ' : ''}license information` + - ` from Elasticsearch for the [${clusterSource}] cluster: ${licenseInfo}` - ); - - return { license: false, error: null, features }; + if (!licenseInfoChanged) { + return { license: false, error: null, features: null }; } + const licenseInfo = [ + `mode: ${newLicense.mode}`, + `status: ${newLicense.status}`, + 'expiry_date_in_millis' in newLicense && + `expiry date: ${moment(newLicense.expiry_date_in_millis, 'x').format()}`, + ] + .filter(Boolean) + .join(' | '); + + this.logger.info( + `Imported ${this.license ? 'changed ' : ''}license information` + + ` from Elasticsearch for the [${clusterSource}] cluster: ${licenseInfo}` + ); + return { license: newLicense, error: null, features }; } catch (err) { this.logger.warn(