Skip to content

Commit

Permalink
Licensed feature usage for connectors (elastic#77679)
Browse files Browse the repository at this point in the history
* Initial work

* Fix type check and jest failures

* Add unit tests

* No need to notifyUsage from alert execution handler

* Fix ESLint

* Log action usage from alerts

* Add integration tests

* Fix jest test

* Skip feature usage of basic action types

* Fix types

* Fix ESLint issue

* Clarify comment

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
  • Loading branch information
mikecote and elasticmachine committed Oct 15, 2020
1 parent 49e0a51 commit 9c89e7c
Show file tree
Hide file tree
Showing 25 changed files with 451 additions and 26 deletions.
80 changes: 77 additions & 3 deletions x-pack/plugins/actions/server/action_type_registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ActionExecutor, ExecutorError, ILicenseState, TaskRunnerFactory } from
import { actionsConfigMock } from './actions_config.mock';
import { licenseStateMock } from './lib/license_state.mock';
import { ActionsConfigurationUtilities } from './actions_config';
import { licensingMock } from '../../licensing/server/mocks';

const mockTaskManager = taskManagerMock.setup();
let mockedLicenseState: jest.Mocked<ILicenseState>;
Expand All @@ -22,6 +23,7 @@ beforeEach(() => {
mockedLicenseState = licenseStateMock.create();
mockedActionsConfig = actionsConfigMock.create();
actionTypeRegistryParams = {
licensing: licensingMock.createSetup(),
taskManager: mockTaskManager,
taskRunnerFactory: new TaskRunnerFactory(
new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false })
Expand Down Expand Up @@ -51,7 +53,7 @@ describe('register()', () => {
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
minimumLicenseRequired: 'gold',
executor,
});
expect(actionTypeRegistry.has('my-action-type')).toEqual(true);
Expand All @@ -69,6 +71,10 @@ describe('register()', () => {
},
]
`);
expect(actionTypeRegistryParams.licensing.featureUsage.register).toHaveBeenCalledWith(
'Connector: My action type',
'gold'
);
});

test('shallow clones the given action type', () => {
Expand Down Expand Up @@ -123,6 +129,31 @@ describe('register()', () => {
expect(getRetry(0, new ExecutorError('my message', {}, undefined))).toEqual(false);
expect(getRetry(0, new ExecutorError('my message', {}, retryTime))).toEqual(retryTime);
});

test('registers gold+ action types to the licensing feature usage API', () => {
const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'gold',
executor,
});
expect(actionTypeRegistryParams.licensing.featureUsage.register).toHaveBeenCalledWith(
'Connector: My action type',
'gold'
);
});

test(`doesn't register basic action types to the licensing feature usage API`, () => {
const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
executor,
});
expect(actionTypeRegistryParams.licensing.featureUsage.register).not.toHaveBeenCalled();
});
});

describe('get()', () => {
Expand Down Expand Up @@ -232,10 +263,20 @@ describe('isActionTypeEnabled', () => {
expect(actionTypeRegistry.isActionExecutable('my-slack1', 'foo')).toEqual(true);
});

test('should call isLicenseValidForActionType of the license state', async () => {
test('should call isLicenseValidForActionType of the license state with notifyUsage false by default', async () => {
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
actionTypeRegistry.isActionTypeEnabled('foo');
expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType);
expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType, {
notifyUsage: false,
});
});

test('should call isLicenseValidForActionType of the license state with notifyUsage true when specified', async () => {
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
actionTypeRegistry.isActionTypeEnabled('foo', { notifyUsage: true });
expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType, {
notifyUsage: true,
});
});

test('should return false when isActionTypeEnabled is false and isLicenseValidForActionType is true', async () => {
Expand Down Expand Up @@ -298,3 +339,36 @@ describe('ensureActionTypeEnabled', () => {
).toThrowErrorMatchingInlineSnapshot(`"Fail"`);
});
});

describe('isActionExecutable()', () => {
let actionTypeRegistry: ActionTypeRegistry;
const fooActionType: ActionType = {
id: 'foo',
name: 'Foo',
minimumLicenseRequired: 'basic',
executor: async (options) => {
return { status: 'ok', actionId: options.actionId };
},
};

beforeEach(() => {
actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
actionTypeRegistry.register(fooActionType);
});

test('should call isLicenseValidForActionType of the license state with notifyUsage false by default', async () => {
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
actionTypeRegistry.isActionExecutable('123', 'foo');
expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType, {
notifyUsage: false,
});
});

test('should call isLicenseValidForActionType of the license state with notifyUsage true when specified', async () => {
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
actionTypeRegistry.isActionExecutable('123', 'foo', { notifyUsage: true });
expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType, {
notifyUsage: true,
});
});
});
38 changes: 32 additions & 6 deletions x-pack/plugins/actions/server/action_type_registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@
import Boom from 'boom';
import { i18n } from '@kbn/i18n';
import { RunContext, TaskManagerSetupContract } from '../../task_manager/server';
import { ExecutorError, TaskRunnerFactory, ILicenseState } from './lib';
import { ActionType as CommonActionType } from '../common';
import { ActionsConfigurationUtilities } from './actions_config';
import { LicensingPluginSetup } from '../../licensing/server';
import {
ExecutorError,
getActionTypeFeatureUsageName,
TaskRunnerFactory,
ILicenseState,
} from './lib';
import {
ActionType,
PreConfiguredAction,
Expand All @@ -19,6 +25,7 @@ import {
} from './types';

export interface ActionTypeRegistryOpts {
licensing: LicensingPluginSetup;
taskManager: TaskManagerSetupContract;
taskRunnerFactory: TaskRunnerFactory;
actionsConfigUtils: ActionsConfigurationUtilities;
Expand All @@ -33,13 +40,15 @@ export class ActionTypeRegistry {
private readonly actionsConfigUtils: ActionsConfigurationUtilities;
private readonly licenseState: ILicenseState;
private readonly preconfiguredActions: PreConfiguredAction[];
private readonly licensing: LicensingPluginSetup;

constructor(constructorParams: ActionTypeRegistryOpts) {
this.taskManager = constructorParams.taskManager;
this.taskRunnerFactory = constructorParams.taskRunnerFactory;
this.actionsConfigUtils = constructorParams.actionsConfigUtils;
this.licenseState = constructorParams.licenseState;
this.preconfiguredActions = constructorParams.preconfiguredActions;
this.licensing = constructorParams.licensing;
}

/**
Expand All @@ -54,26 +63,36 @@ export class ActionTypeRegistry {
*/
public ensureActionTypeEnabled(id: string) {
this.actionsConfigUtils.ensureActionTypeEnabled(id);
// Important to happen last because the function will notify of feature usage at the
// same time and it shouldn't notify when the action type isn't enabled
this.licenseState.ensureLicenseForActionType(this.get(id));
}

/**
* Returns true if action type is enabled in the config and a valid license is used.
*/
public isActionTypeEnabled(id: string) {
public isActionTypeEnabled(
id: string,
options: { notifyUsage: boolean } = { notifyUsage: false }
) {
return (
this.actionsConfigUtils.isActionTypeEnabled(id) &&
this.licenseState.isLicenseValidForActionType(this.get(id)).isValid === true
this.licenseState.isLicenseValidForActionType(this.get(id), options).isValid === true
);
}

/**
* Returns true if action type is enabled or it is a preconfigured action type.
*/
public isActionExecutable(actionId: string, actionTypeId: string) {
public isActionExecutable(
actionId: string,
actionTypeId: string,
options: { notifyUsage: boolean } = { notifyUsage: false }
) {
const actionTypeEnabled = this.isActionTypeEnabled(actionTypeId, options);
return (
this.isActionTypeEnabled(actionTypeId) ||
(!this.isActionTypeEnabled(actionTypeId) &&
actionTypeEnabled ||
(!actionTypeEnabled &&
this.preconfiguredActions.find(
(preconfiguredAction) => preconfiguredAction.id === actionId
) !== undefined)
Expand Down Expand Up @@ -118,6 +137,13 @@ export class ActionTypeRegistry {
createTaskRunner: (context: RunContext) => this.taskRunnerFactory.create(context),
},
});
// No need to notify usage on basic action types
if (actionType.minimumLicenseRequired !== 'basic') {
this.licensing.featureUsage.register(
getActionTypeFeatureUsageName(actionType as ActionType),
actionType.minimumLicenseRequired
);
}
}

/**
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/actions/server/actions_client.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const createActionsClientMock = () => {
execute: jest.fn(),
enqueueExecution: jest.fn(),
listTypes: jest.fn(),
isActionTypeEnabled: jest.fn(),
};
return mocked;
};
Expand Down
33 changes: 32 additions & 1 deletion x-pack/plugins/actions/server/actions_client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import { schema } from '@kbn/config-schema';

import { ActionTypeRegistry, ActionTypeRegistryOpts } from './action_type_registry';
import { ActionsClient } from './actions_client';
import { ExecutorType } from './types';
import { ExecutorType, ActionType } from './types';
import { ActionExecutor, TaskRunnerFactory, ILicenseState } from './lib';
import { taskManagerMock } from '../../task_manager/server/task_manager.mock';
import { actionsConfigMock } from './actions_config.mock';
import { getActionsConfigurationUtilities } from './actions_config';
import { licenseStateMock } from './lib/license_state.mock';
import { licensingMock } from '../../licensing/server/mocks';

import {
elasticsearchServiceMock,
Expand Down Expand Up @@ -47,6 +48,7 @@ beforeEach(() => {
jest.resetAllMocks();
mockedLicenseState = licenseStateMock.create();
actionTypeRegistryParams = {
licensing: licensingMock.createSetup(),
taskManager: mockTaskManager,
taskRunnerFactory: new TaskRunnerFactory(
new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false })
Expand Down Expand Up @@ -299,6 +301,7 @@ describe('create()', () => {
});

const localActionTypeRegistryParams = {
licensing: licensingMock.createSetup(),
taskManager: mockTaskManager,
taskRunnerFactory: new TaskRunnerFactory(
new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false })
Expand Down Expand Up @@ -1244,3 +1247,31 @@ describe('enqueueExecution()', () => {
expect(executionEnqueuer).toHaveBeenCalledWith(unsecuredSavedObjectsClient, opts);
});
});

describe('isActionTypeEnabled()', () => {
const fooActionType: ActionType = {
id: 'foo',
name: 'Foo',
minimumLicenseRequired: 'gold',
executor: jest.fn(),
};
beforeEach(() => {
actionTypeRegistry.register(fooActionType);
});

test('should call isLicenseValidForActionType of the license state with notifyUsage false by default', () => {
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
actionsClient.isActionTypeEnabled('foo');
expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType, {
notifyUsage: false,
});
});

test('should call isLicenseValidForActionType of the license state with notifyUsage true when specified', () => {
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
actionsClient.isActionTypeEnabled('foo', { notifyUsage: true });
expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType, {
notifyUsage: true,
});
});
});
7 changes: 7 additions & 0 deletions x-pack/plugins/actions/server/actions_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,13 @@ export class ActionsClient {
public async listTypes(): Promise<ActionType[]> {
return this.actionTypeRegistry.list();
}

public isActionTypeEnabled(
actionTypeId: string,
options: { notifyUsage: boolean } = { notifyUsage: false }
) {
return this.actionTypeRegistry.isActionTypeEnabled(actionTypeId, options);
}
}

function actionFromSavedObject(savedObject: SavedObject<RawAction>): ActionResult {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Logger } from '../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../src/core/server/mocks';
import { actionsConfigMock } from '../actions_config.mock';
import { licenseStateMock } from '../lib/license_state.mock';
import { licensingMock } from '../../../licensing/server/mocks';

const ACTION_TYPE_IDS = ['.index', '.email', '.pagerduty', '.server-log', '.slack', '.webhook'];

Expand All @@ -21,6 +22,7 @@ export function createActionTypeRegistry(): {
} {
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
const actionTypeRegistry = new ActionTypeRegistry({
licensing: licensingMock.createSetup(),
taskManager: taskManagerMock.setup(),
taskRunnerFactory: new TaskRunnerFactory(
new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ beforeEach(() => jest.resetAllMocks());

describe('execute()', () => {
test('schedules the action with all given parameters', async () => {
const actionTypeRegistry = actionTypeRegistryMock.create();
const executeFn = createExecutionEnqueuerFunction({
taskManager: mockTaskManager,
actionTypeRegistry: actionTypeRegistryMock.create(),
actionTypeRegistry,
isESOUsingEphemeralEncryptionKey: false,
preconfiguredActions: [],
});
Expand Down Expand Up @@ -76,6 +77,9 @@ describe('execute()', () => {
},
{}
);
expect(actionTypeRegistry.isActionExecutable).toHaveBeenCalledWith('123', 'mock-action', {
notifyUsage: true,
});
});

test('schedules the action with all given parameters with a preconfigured action', async () => {
Expand Down
2 changes: 1 addition & 1 deletion x-pack/plugins/actions/server/create_execute_function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function createExecutionEnqueuerFunction({
id
);

if (!actionTypeRegistry.isActionExecutable(id, actionTypeId)) {
if (!actionTypeRegistry.isActionExecutable(id, actionTypeId, { notifyUsage: true })) {
actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
}

Expand Down
3 changes: 3 additions & 0 deletions x-pack/plugins/actions/server/lib/action_executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ test('successfully executes', async () => {
);

expect(actionTypeRegistry.get).toHaveBeenCalledWith('test');
expect(actionTypeRegistry.isActionExecutable).toHaveBeenCalledWith('1', 'test', {
notifyUsage: true,
});

expect(actionType.executor).toHaveBeenCalledWith({
actionId: '1',
Expand Down
2 changes: 1 addition & 1 deletion x-pack/plugins/actions/server/lib/action_executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export class ActionExecutor {
namespace.namespace
);

if (!actionTypeRegistry.isActionExecutable(actionId, actionTypeId)) {
if (!actionTypeRegistry.isActionExecutable(actionId, actionTypeId, { notifyUsage: true })) {
actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
}
const actionType = actionTypeRegistry.get(actionTypeId);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 { ActionType } from '../types';

export function getActionTypeFeatureUsageName(actionType: ActionType) {
return `Connector: ${actionType.name}`;
}
1 change: 1 addition & 0 deletions x-pack/plugins/actions/server/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export { TaskRunnerFactory } from './task_runner_factory';
export { ActionExecutor, ActionExecutorContract } from './action_executor';
export { ILicenseState, LicenseState } from './license_state';
export { verifyApiAccess } from './verify_api_access';
export { getActionTypeFeatureUsageName } from './get_action_type_feature_usage_name';
export {
ActionTypeDisabledError,
ActionTypeDisabledReason,
Expand Down
Loading

0 comments on commit 9c89e7c

Please sign in to comment.