Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,47 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { BehaviorSubject } from 'rxjs';
import { createOnPreResponseHandler } from './on_pre_response_handler';
import { httpServiceMock, httpServerMock } from '@kbn/core/server/mocks';
import { licenseMock } from '../common/licensing.mock';

describe('createOnPreResponseHandler', () => {
beforeAll(() => {
jest.useFakeTimers();
});

afterAll(() => {
jest.useRealTimers();
});

it('sets unknown if license.signature is unset', async () => {
const refresh = jest.fn();
const getLicense = jest.fn(() => undefined);
const toolkit = httpServiceMock.createOnPreResponseToolkit();

const interceptor = createOnPreResponseHandler(refresh, getLicense);
await interceptor(httpServerMock.createKibanaRequest(), { statusCode: 200 }, toolkit);

expect(refresh).toHaveBeenCalledTimes(0);
expect(getLicense).toHaveBeenCalledTimes(1);
expect(toolkit.next).toHaveBeenCalledTimes(1);
expect(toolkit.next).toHaveBeenCalledWith({
headers: {
'kbn-license-sig': 'unknown',
},
});
});

it('sets license.signature header immediately for non-error responses', async () => {
const refresh = jest.fn();
const license$ = new BehaviorSubject(licenseMock.createLicense({ signature: 'foo' }));
const getLicense = jest.fn(() => licenseMock.createLicense({ signature: 'foo' }));
const toolkit = httpServiceMock.createOnPreResponseToolkit();

const interceptor = createOnPreResponseHandler(refresh, license$);
const interceptor = createOnPreResponseHandler(refresh, getLicense);
await interceptor(httpServerMock.createKibanaRequest(), { statusCode: 200 }, toolkit);

expect(refresh).toHaveBeenCalledTimes(0);
expect(getLicense).toHaveBeenCalledTimes(1);
expect(toolkit.next).toHaveBeenCalledTimes(1);
expect(toolkit.next).toHaveBeenCalledWith({
headers: {
Expand All @@ -29,13 +54,14 @@ describe('createOnPreResponseHandler', () => {
});
it('sets license.signature header immediately for 429 error responses', async () => {
const refresh = jest.fn();
const license$ = new BehaviorSubject(licenseMock.createLicense({ signature: 'foo' }));
const getLicense = jest.fn(() => licenseMock.createLicense({ signature: 'foo' }));
const toolkit = httpServiceMock.createOnPreResponseToolkit();

const interceptor = createOnPreResponseHandler(refresh, license$);
const interceptor = createOnPreResponseHandler(refresh, getLicense);
await interceptor(httpServerMock.createKibanaRequest(), { statusCode: 429 }, toolkit);

expect(refresh).toHaveBeenCalledTimes(0);
expect(getLicense).toHaveBeenCalledTimes(1);
expect(toolkit.next).toHaveBeenCalledTimes(1);
expect(toolkit.next).toHaveBeenCalledWith({
headers: {
Expand All @@ -45,24 +71,33 @@ describe('createOnPreResponseHandler', () => {
});
it('sets license.signature header after refresh for other error responses', async () => {
const updatedLicense = licenseMock.createLicense({ signature: 'bar' });
const license$ = new BehaviorSubject(licenseMock.createLicense({ signature: 'foo' }));
const refresh = jest.fn().mockImplementation(
() =>
new Promise<void>((resolve) => {
setTimeout(() => {
license$.next(updatedLicense);
resolve();
}, 50);
})
);
const getLicense = jest.fn(() => licenseMock.createLicense({ signature: 'foo' }));
const refresh = jest.fn().mockImplementation(async () => {
setTimeout(() => {
getLicense.mockReturnValue(updatedLicense);
}, 1);
});

const toolkit = httpServiceMock.createOnPreResponseToolkit();

const interceptor = createOnPreResponseHandler(refresh, license$);
const interceptor = createOnPreResponseHandler(refresh, getLicense);
await interceptor(httpServerMock.createKibanaRequest(), { statusCode: 400 }, toolkit);

expect(refresh).toHaveBeenCalledTimes(1);
expect(getLicense).toHaveBeenCalledTimes(1);
expect(toolkit.next).toHaveBeenCalledTimes(1);
expect(toolkit.next).toHaveBeenCalledWith({
headers: {
'kbn-license-sig': 'foo',
},
});

jest.advanceTimersByTime(10);
await interceptor(httpServerMock.createKibanaRequest(), { statusCode: 400 }, toolkit);

expect(refresh).toHaveBeenCalledTimes(2);
expect(getLicense).toHaveBeenCalledTimes(2);
expect(toolkit.next).toHaveBeenCalledTimes(2);
expect(toolkit.next).toHaveBeenCalledWith({
headers: {
'kbn-license-sig': 'bar',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@
* 2.0.
*/

import type { Observable } from 'rxjs';
import { firstValueFrom } from 'rxjs';
import type { OnPreResponseHandler } from '@kbn/core/server';
import type { ILicense } from '@kbn/licensing-types';

export function createOnPreResponseHandler(
refresh: () => Promise<ILicense>,
license$: Observable<ILicense>
getLicense: () => Readonly<ILicense> | undefined
): OnPreResponseHandler {
return async (req, res, t) => {
// If we're returning an error response, refresh license info from
Expand All @@ -22,9 +20,18 @@ export function createOnPreResponseHandler(
// that back-pressure should be applied, and we don't need to refresh the license in these
// situations.
if (res.statusCode >= 400 && res.statusCode !== 429) {
await refresh();
void refresh();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One pitfall with this approach is that if the ES license ever changes and we are unable to get it for whatever reason Kibana will still report that last seen license in responses until we are able to asynchronously get an updated license from ES.

}
const license = await firstValueFrom(license$);
const license = getLicense();

if (!license) {
return t.next({
headers: {
'kbn-license-sig': 'unknown',
},
});
}

return t.next({
headers: {
'kbn-license-sig': license.signature,
Expand Down
18 changes: 10 additions & 8 deletions x-pack/platform/plugins/shared/licensing/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPl
private readonly isElasticsearchAvailable$ = new ReplaySubject<boolean>(1);
private readonly logger: Logger;
private readonly config: LicenseConfigType;
private loggingSubscription?: Subscription;
private currentLicense: undefined | Readonly<ILicense>;
private licenseSubscription?: Subscription;
private featureUsage = new FeatureUsageService();

private refresh?: () => Promise<ILicense>;
Expand Down Expand Up @@ -89,7 +90,7 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPl
const featureUsageSetup = this.featureUsage.setup();

registerRoutes(core.http.createRouter(), featureUsageSetup, core.getStartServices);
core.http.registerOnPreResponse(createOnPreResponseHandler(refresh, license$));
core.http.registerOnPreResponse(createOnPreResponseHandler(refresh, () => this.currentLicense));

this.refresh = refresh;
this.license$ = license$;
Expand Down Expand Up @@ -132,7 +133,8 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPl
licenseFetcher
);

this.loggingSubscription = license$.subscribe((license) =>
this.licenseSubscription = license$.subscribe((license) => {
this.currentLicense = license;
this.logger.debug(
() =>
'Imported license information from Elasticsearch:' +
Expand All @@ -141,8 +143,8 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPl
`status: ${license.status}`,
`expiry date: ${moment(license.expiryDateInMillis, 'x').format()}`,
].join(' | ')
)
);
);
});

return {
refresh: async () => {
Expand Down Expand Up @@ -170,9 +172,9 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup, LicensingPl
this.stop$.next();
this.stop$.complete();

if (this.loggingSubscription !== undefined) {
this.loggingSubscription.unsubscribe();
this.loggingSubscription = undefined;
if (this.licenseSubscription !== undefined) {
this.licenseSubscription.unsubscribe();
this.licenseSubscription = undefined;
}
}
}