License checks for actions plugin (#59070)

* Define minimum license required for each action type (#58668)

* Add minimum required license

* Require at least gold license as a minimum license required on third party action types

* Use strings for license references

* Ensure license type is valid

* Fix some tests

* Add servicenow to gold

* Add tests

* Set license requirements on other built in action types

* Use jest.Mocked<ActionType> instead

* Change servicenow to platinum

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>

* Make actions config mock and license state mock use factory pattern and jest mocks (#59370)

* Add license checks to action HTTP APIs (#59153)

* Initial work

* Handle errors in update action API

* Add unit tests for APIs

* Make action executor throw when action type isn't enabled

* Add test suite for basic license

* Fix ESLint errors

* Fix failing tests

* Attempt 1 to fix CI

* ESLint fixes

* Create sendResponse function on ActionTypeDisabledError

* Make disabled action types by config return 403

* Remove switch case

* Fix ESLint

* Add license checks within alerting / actions framework (#59699)

* Initial work

* Handle errors in update action API

* Add unit tests for APIs

* Verify action type before scheduling action task

* Make actions plugin.execute throw error if action type is disabled

* Bug fixes

* Make action executor throw when action type isn't enabled

* Add test suite for basic license

* Fix ESLint errors

* Stop action task from re-running when license check fails

* Fix failing tests

* Attempt 1 to fix CI

* ESLint fixes

* Create sendResponse function on ActionTypeDisabledError

* Make disabled action types by config return 403

* Remove switch case

* Fix ESLint

* Fix confusing assertion

* Add comment explaining double mock

* Log warning when alert action isn't scheduled

* Disable action types in UI when license doesn't support it (#59819)

* Initial work

* Handle errors in update action API

* Add unit tests for APIs

* Verify action type before scheduling action task

* Make actions plugin.execute throw error if action type is disabled

* Bug fixes

* Make action executor throw when action type isn't enabled

* Add test suite for basic license

* Fix ESLint errors

* Stop action task from re-running when license check fails

* Fix failing tests

* Attempt 1 to fix CI

* ESLint fixes

* Return enabledInConfig and enabledInLicense from actions get types API

* Disable cards that have invalid license in create connector flyout

* Create sendResponse function on ActionTypeDisabledError

* Make disabled action types by config return 403

* Remove switch case

* Fix ESLint

* Disable when creating alert action

* Return minimumLicenseRequired in /types API

* Disable row in connectors when action type is disabled

* Fix failing jest test

* Some refactoring

* Card in edit alert flyout

* Sort action types by name

* Add tooltips to create connector action type selector

* Add tooltips to alert flyout action type selector

* Add get more actions link in alert flyout

* Add callout when creating a connector

* Typos

* remove float right and use flexgroup

* replace pixels with eui variables

* turn on sass lint for triggers_actions_ui dir

* trying to add padding around cards

* Add callout in edit alert screen when some actions are disabled

* improve card selection for Add Connector flyout

* Fix cards for create connector

* Add tests

* ESLint issue

* Cleanup

* Cleanup pt2

* Fix type check errors

* moving to 3-columns cards for connector selection

* Change re-enable to enable terminology

* Revert "Change re-enable to enable terminology"

This reverts commit b497dfd6b6.

* Add re-enable comment

* Remove unecessary fragment

* Add type to actionTypeNodes

* Fix EuiLink to not have opacity of 0.7 when not hovered

* design cleanup in progress

* updating classNames

* using EuiIconTip

* Remove label on icon tip

* Fix failing jest test

Co-authored-by: Andrea Del Rio <delrio.andre@gmail.com>

* Add index to .index action type test

* PR feedback

* Add isErrorThatHandlesItsOwnResponse

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Andrea Del Rio <delrio.andre@gmail.com>
This commit is contained in:
Mike Côté 2020-03-20 10:49:37 -04:00 committed by GitHub
parent 64e09af107
commit 851b8a82a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
102 changed files with 2402 additions and 397 deletions

View file

@ -7,6 +7,7 @@ files:
- 'x-pack/legacy/plugins/rollup/**/*.s+(a|c)ss'
- 'x-pack/legacy/plugins/security/**/*.s+(a|c)ss'
- 'x-pack/legacy/plugins/canvas/**/*.s+(a|c)ss'
- 'x-pack/plugins/triggers_actions_ui/**/*.s+(a|c)ss'
ignore:
- 'x-pack/legacy/plugins/canvas/shareable_runtime/**/*.s+(a|c)ss'
- 'x-pack/legacy/plugins/lens/**/*.s+(a|c)ss'

View file

@ -21,6 +21,7 @@ import { useConnectors } from '../../../../containers/case/configure/use_connect
import { useCaseConfigure } from '../../../../containers/case/configure/use_configure';
import {
ActionsConnectorsContextProvider,
ActionType,
ConnectorAddFlyout,
ConnectorEditFlyout,
} from '../../../../../../../../plugins/triggers_actions_ui/public';
@ -60,11 +61,14 @@ const initialState: State = {
mapping: null,
};
const actionTypes = [
const actionTypes: ActionType[] = [
{
id: '.servicenow',
name: 'ServiceNow',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'platinum',
},
];

View file

@ -4,10 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { LicenseType } from '../../licensing/common/types';
export interface ActionType {
id: string;
name: string;
enabled: boolean;
enabledInConfig: boolean;
enabledInLicense: boolean;
minimumLicenseRequired: LicenseType;
}
export interface ActionResult {

View file

@ -13,6 +13,7 @@ const createActionTypeRegistryMock = () => {
get: jest.fn(),
list: jest.fn(),
ensureActionTypeEnabled: jest.fn(),
isActionTypeEnabled: jest.fn(),
};
return mocked;
};

View file

@ -5,21 +5,31 @@
*/
import { taskManagerMock } from '../../task_manager/server/task_manager.mock';
import { ActionTypeRegistry } from './action_type_registry';
import { ExecutorType } from './types';
import { ActionExecutor, ExecutorError, TaskRunnerFactory } from './lib';
import { configUtilsMock } from './actions_config.mock';
import { ActionTypeRegistry, ActionTypeRegistryOpts } from './action_type_registry';
import { ActionType, ExecutorType } from './types';
import { ActionExecutor, ExecutorError, ILicenseState, TaskRunnerFactory } from './lib';
import { actionsConfigMock } from './actions_config.mock';
import { licenseStateMock } from './lib/license_state.mock';
import { ActionsConfigurationUtilities } from './actions_config';
const mockTaskManager = taskManagerMock.setup();
const actionTypeRegistryParams = {
taskManager: mockTaskManager,
taskRunnerFactory: new TaskRunnerFactory(
new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false })
),
actionsConfigUtils: configUtilsMock,
};
let mockedLicenseState: jest.Mocked<ILicenseState>;
let mockedActionsConfig: jest.Mocked<ActionsConfigurationUtilities>;
let actionTypeRegistryParams: ActionTypeRegistryOpts;
beforeEach(() => jest.resetAllMocks());
beforeEach(() => {
jest.resetAllMocks();
mockedLicenseState = licenseStateMock.create();
mockedActionsConfig = actionsConfigMock.create();
actionTypeRegistryParams = {
taskManager: mockTaskManager,
taskRunnerFactory: new TaskRunnerFactory(
new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false })
),
actionsConfigUtils: mockedActionsConfig,
licenseState: mockedLicenseState,
};
});
const executor: ExecutorType = async options => {
return { status: 'ok', actionId: options.actionId };
@ -31,6 +41,7 @@ describe('register()', () => {
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
executor,
});
expect(actionTypeRegistry.has('my-action-type')).toEqual(true);
@ -55,12 +66,14 @@ describe('register()', () => {
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
executor,
});
expect(() =>
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
executor,
})
).toThrowErrorMatchingInlineSnapshot(
@ -73,6 +86,7 @@ describe('register()', () => {
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
executor,
});
expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1);
@ -94,6 +108,7 @@ describe('get()', () => {
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
executor,
});
const actionType = actionTypeRegistry.get('my-action-type');
@ -101,6 +116,7 @@ describe('get()', () => {
Object {
"executor": [Function],
"id": "my-action-type",
"minimumLicenseRequired": "basic",
"name": "My action type",
}
`);
@ -116,10 +132,12 @@ describe('get()', () => {
describe('list()', () => {
test('returns list of action types', () => {
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
executor,
});
const actionTypes = actionTypeRegistry.list();
@ -128,8 +146,13 @@ describe('list()', () => {
id: 'my-action-type',
name: 'My action type',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
},
]);
expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalled();
expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalled();
});
});
@ -144,8 +167,94 @@ describe('has()', () => {
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
executor,
});
expect(actionTypeRegistry.has('my-action-type'));
});
});
describe('isActionTypeEnabled', () => {
let actionTypeRegistry: ActionTypeRegistry;
const fooActionType: ActionType = {
id: 'foo',
name: 'Foo',
minimumLicenseRequired: 'basic',
executor: async () => {},
};
beforeEach(() => {
actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
actionTypeRegistry.register(fooActionType);
});
test('should call isActionTypeEnabled of the actions config', async () => {
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
actionTypeRegistry.isActionTypeEnabled('foo');
expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalledWith('foo');
});
test('should call isLicenseValidForActionType of the license state', async () => {
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
actionTypeRegistry.isActionTypeEnabled('foo');
expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType);
});
test('should return false when isActionTypeEnabled is false and isLicenseValidForActionType is true', async () => {
mockedActionsConfig.isActionTypeEnabled.mockReturnValue(false);
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
expect(actionTypeRegistry.isActionTypeEnabled('foo')).toEqual(false);
});
test('should return false when isActionTypeEnabled is true and isLicenseValidForActionType is false', async () => {
mockedActionsConfig.isActionTypeEnabled.mockReturnValue(true);
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({
isValid: false,
reason: 'invalid',
});
expect(actionTypeRegistry.isActionTypeEnabled('foo')).toEqual(false);
});
});
describe('ensureActionTypeEnabled', () => {
let actionTypeRegistry: ActionTypeRegistry;
const fooActionType: ActionType = {
id: 'foo',
name: 'Foo',
minimumLicenseRequired: 'basic',
executor: async () => {},
};
beforeEach(() => {
actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
actionTypeRegistry.register(fooActionType);
});
test('should call ensureActionTypeEnabled of the action config', async () => {
actionTypeRegistry.ensureActionTypeEnabled('foo');
expect(mockedActionsConfig.ensureActionTypeEnabled).toHaveBeenCalledWith('foo');
});
test('should call ensureLicenseForActionType on the license state', async () => {
actionTypeRegistry.ensureActionTypeEnabled('foo');
expect(mockedLicenseState.ensureLicenseForActionType).toHaveBeenCalledWith(fooActionType);
});
test('should throw when ensureActionTypeEnabled throws', async () => {
mockedActionsConfig.ensureActionTypeEnabled.mockImplementation(() => {
throw new Error('Fail');
});
expect(() =>
actionTypeRegistry.ensureActionTypeEnabled('foo')
).toThrowErrorMatchingInlineSnapshot(`"Fail"`);
});
test('should throw when ensureLicenseForActionType throws', async () => {
mockedLicenseState.ensureLicenseForActionType.mockImplementation(() => {
throw new Error('Fail');
});
expect(() =>
actionTypeRegistry.ensureActionTypeEnabled('foo')
).toThrowErrorMatchingInlineSnapshot(`"Fail"`);
});
});

View file

@ -7,15 +7,16 @@
import Boom from 'boom';
import { i18n } from '@kbn/i18n';
import { RunContext, TaskManagerSetupContract } from '../../task_manager/server';
import { ExecutorError, TaskRunnerFactory } from './lib';
import { ExecutorError, TaskRunnerFactory, ILicenseState } from './lib';
import { ActionType } from './types';
import { ActionType as CommonActionType } from '../common';
import { ActionsConfigurationUtilities } from './actions_config';
interface ConstructorOptions {
export interface ActionTypeRegistryOpts {
taskManager: TaskManagerSetupContract;
taskRunnerFactory: TaskRunnerFactory;
actionsConfigUtils: ActionsConfigurationUtilities;
licenseState: ILicenseState;
}
export class ActionTypeRegistry {
@ -23,11 +24,13 @@ export class ActionTypeRegistry {
private readonly actionTypes: Map<string, ActionType> = new Map();
private readonly taskRunnerFactory: TaskRunnerFactory;
private readonly actionsConfigUtils: ActionsConfigurationUtilities;
private readonly licenseState: ILicenseState;
constructor(constructorParams: ConstructorOptions) {
constructor(constructorParams: ActionTypeRegistryOpts) {
this.taskManager = constructorParams.taskManager;
this.taskRunnerFactory = constructorParams.taskRunnerFactory;
this.actionsConfigUtils = constructorParams.actionsConfigUtils;
this.licenseState = constructorParams.licenseState;
}
/**
@ -42,6 +45,17 @@ export class ActionTypeRegistry {
*/
public ensureActionTypeEnabled(id: string) {
this.actionsConfigUtils.ensureActionTypeEnabled(id);
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) {
return (
this.actionsConfigUtils.isActionTypeEnabled(id) &&
this.licenseState.isLicenseValidForActionType(this.get(id)).isValid === true
);
}
/**
@ -103,7 +117,10 @@ export class ActionTypeRegistry {
return Array.from(this.actionTypes).map(([actionTypeId, actionType]) => ({
id: actionTypeId,
name: actionType.name,
enabled: this.actionsConfigUtils.isActionTypeEnabled(actionTypeId),
minimumLicenseRequired: actionType.minimumLicenseRequired,
enabled: this.isActionTypeEnabled(actionTypeId),
enabledInConfig: this.actionsConfigUtils.isActionTypeEnabled(actionTypeId),
enabledInLicense: this.licenseState.isLicenseValidForActionType(actionType).isValid === true,
}));
}
}

View file

@ -6,13 +6,14 @@
import { schema } from '@kbn/config-schema';
import { ActionTypeRegistry } from './action_type_registry';
import { ActionTypeRegistry, ActionTypeRegistryOpts } from './action_type_registry';
import { ActionsClient } from './actions_client';
import { ExecutorType } from './types';
import { ActionExecutor, TaskRunnerFactory } from './lib';
import { ActionExecutor, TaskRunnerFactory, ILicenseState } from './lib';
import { taskManagerMock } from '../../task_manager/server/task_manager.mock';
import { configUtilsMock } from './actions_config.mock';
import { actionsConfigMock } from './actions_config.mock';
import { getActionsConfigurationUtilities } from './actions_config';
import { licenseStateMock } from './lib/license_state.mock';
import {
elasticsearchServiceMock,
@ -25,22 +26,25 @@ const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient()
const mockTaskManager = taskManagerMock.setup();
const actionTypeRegistryParams = {
taskManager: mockTaskManager,
taskRunnerFactory: new TaskRunnerFactory(
new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false })
),
actionsConfigUtils: configUtilsMock,
};
let actionsClient: ActionsClient;
let mockedLicenseState: jest.Mocked<ILicenseState>;
let actionTypeRegistry: ActionTypeRegistry;
let actionTypeRegistryParams: ActionTypeRegistryOpts;
const executor: ExecutorType = async options => {
return { status: 'ok', actionId: options.actionId };
};
beforeEach(() => {
jest.resetAllMocks();
mockedLicenseState = licenseStateMock.create();
actionTypeRegistryParams = {
taskManager: mockTaskManager,
taskRunnerFactory: new TaskRunnerFactory(
new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false })
),
actionsConfigUtils: actionsConfigMock.create(),
licenseState: mockedLicenseState,
};
actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
actionsClient = new ActionsClient({
actionTypeRegistry,
@ -65,6 +69,7 @@ describe('create()', () => {
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
executor,
});
savedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult);
@ -100,6 +105,7 @@ describe('create()', () => {
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
validate: {
config: schema.object({
param1: schema.string(),
@ -140,6 +146,7 @@ describe('create()', () => {
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
executor,
});
savedObjectsClient.create.mockResolvedValueOnce({
@ -210,6 +217,7 @@ describe('create()', () => {
new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false })
),
actionsConfigUtils: localConfigUtils,
licenseState: licenseStateMock.create(),
};
actionTypeRegistry = new ActionTypeRegistry(localActionTypeRegistryParams);
@ -233,6 +241,7 @@ describe('create()', () => {
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
executor,
});
savedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult);
@ -250,6 +259,39 @@ describe('create()', () => {
`"action type \\"my-action-type\\" is not enabled in the Kibana config xpack.actions.enabledActionTypes"`
);
});
test('throws error when ensureActionTypeEnabled throws', async () => {
const savedObjectCreateResult = {
id: '1',
type: 'type',
attributes: {
name: 'my name',
actionTypeId: 'my-action-type',
config: {},
},
references: [],
};
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
executor,
});
mockedLicenseState.ensureLicenseForActionType.mockImplementation(() => {
throw new Error('Fail');
});
savedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult);
await expect(
actionsClient.create({
action: {
name: 'my name',
actionTypeId: 'my-action-type',
config: {},
secrets: {},
},
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`);
});
});
describe('get()', () => {
@ -346,6 +388,7 @@ describe('update()', () => {
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
executor,
});
savedObjectsClient.get.mockResolvedValueOnce({
@ -407,6 +450,7 @@ describe('update()', () => {
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
validate: {
config: schema.object({
param1: schema.string(),
@ -440,6 +484,7 @@ describe('update()', () => {
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
executor,
});
savedObjectsClient.get.mockResolvedValueOnce({
@ -505,4 +550,45 @@ describe('update()', () => {
]
`);
});
test('throws an error when ensureActionTypeEnabled throws', async () => {
actionTypeRegistry.register({
id: 'my-action-type',
name: 'My action type',
minimumLicenseRequired: 'basic',
executor,
});
mockedLicenseState.ensureLicenseForActionType.mockImplementation(() => {
throw new Error('Fail');
});
savedObjectsClient.get.mockResolvedValueOnce({
id: '1',
type: 'action',
attributes: {
actionTypeId: 'my-action-type',
},
references: [],
});
savedObjectsClient.update.mockResolvedValueOnce({
id: 'my-action',
type: 'action',
attributes: {
actionTypeId: 'my-action-type',
name: 'my name',
config: {},
secrets: {},
},
references: [],
});
await expect(
actionsClient.update({
id: 'my-action',
action: {
name: 'my name',
config: {},
secrets: {},
},
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`);
});
});

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import {
IScopedClusterClient,
SavedObjectsClientContract,
@ -93,11 +92,7 @@ export class ActionsClient {
const validatedActionTypeConfig = validateConfig(actionType, config);
const validatedActionTypeSecrets = validateSecrets(actionType, secrets);
try {
this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
} catch (err) {
throw Boom.badRequest(err.message);
}
this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
const result = await this.savedObjectsClient.create('action', {
actionTypeId,
@ -125,6 +120,8 @@ export class ActionsClient {
const validatedActionTypeConfig = validateConfig(actionType, config);
const validatedActionTypeSecrets = validateSecrets(actionType, secrets);
this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
const result = await this.savedObjectsClient.update('action', id, {
actionTypeId,
name,

View file

@ -6,11 +6,18 @@
import { ActionsConfigurationUtilities } from './actions_config';
export const configUtilsMock: ActionsConfigurationUtilities = {
isWhitelistedHostname: _ => true,
isWhitelistedUri: _ => true,
isActionTypeEnabled: _ => true,
ensureWhitelistedHostname: _ => {},
ensureWhitelistedUri: _ => {},
ensureActionTypeEnabled: _ => {},
const createActionsConfigMock = () => {
const mocked: jest.Mocked<ActionsConfigurationUtilities> = {
isWhitelistedHostname: jest.fn().mockReturnValue(true),
isWhitelistedUri: jest.fn().mockReturnValue(true),
isActionTypeEnabled: jest.fn().mockReturnValue(true),
ensureWhitelistedHostname: jest.fn().mockReturnValue({}),
ensureWhitelistedUri: jest.fn().mockReturnValue({}),
ensureActionTypeEnabled: jest.fn().mockReturnValue({}),
};
return mocked;
};
export const actionsConfigMock = {
create: createActionsConfigMock,
};

View file

@ -11,6 +11,7 @@ import { curry } from 'lodash';
import { pipe } from 'fp-ts/lib/pipeable';
import { ActionsConfigType } from './types';
import { ActionTypeDisabledError } from './lib';
export enum WhitelistedHosts {
Any = '*',
@ -103,7 +104,7 @@ export function getActionsConfigurationUtilities(
},
ensureActionTypeEnabled(actionType: string) {
if (!isActionTypeEnabled(actionType)) {
throw new Error(disabledActionTypeErrorMessage(actionType));
throw new ActionTypeDisabledError(disabledActionTypeErrorMessage(actionType), 'config');
}
},
};

View file

@ -12,7 +12,7 @@ import { Logger } from '../../../../../src/core/server';
import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
import { ActionType, ActionTypeExecutorOptions } from '../types';
import { configUtilsMock } from '../actions_config.mock';
import { actionsConfigMock } from '../actions_config.mock';
import { validateConfig, validateSecrets, validateParams } from '../lib';
import { createActionTypeRegistry } from './index.test';
import { sendEmail } from './lib/send_email';
@ -37,13 +37,10 @@ const services = {
let actionType: ActionType;
let mockedLogger: jest.Mocked<Logger>;
beforeAll(() => {
const { actionTypeRegistry } = createActionTypeRegistry();
actionType = actionTypeRegistry.get(ACTION_TYPE_ID);
});
beforeEach(() => {
jest.resetAllMocks();
const { actionTypeRegistry } = createActionTypeRegistry();
actionType = actionTypeRegistry.get(ACTION_TYPE_ID);
});
describe('actionTypeRegistry.get() works', () => {
@ -128,7 +125,7 @@ describe('config validation', () => {
actionType = getActionType({
logger: mockedLogger,
configurationUtilities: {
...configUtilsMock,
...actionsConfigMock.create(),
isWhitelistedHostname: hostname => hostname === NODEMAILER_AOL_SERVICE_HOST,
},
});

View file

@ -118,6 +118,7 @@ export function getActionType(params: GetActionTypeParams): ActionType {
const { logger, configurationUtilities } = params;
return {
id: '.email',
minimumLicenseRequired: 'gold',
name: i18n.translate('xpack.actions.builtin.emailTitle', {
defaultMessage: 'Email',
}),

View file

@ -36,6 +36,7 @@ const ParamsSchema = schema.object({
export function getActionType({ logger }: { logger: Logger }): ActionType {
return {
id: '.index',
minimumLicenseRequired: 'basic',
name: i18n.translate('xpack.actions.builtin.esIndexTitle', {
defaultMessage: 'Index',
}),

View file

@ -10,7 +10,8 @@ import { taskManagerMock } from '../../../task_manager/server/task_manager.mock'
import { registerBuiltInActionTypes } from './index';
import { Logger } from '../../../../../src/core/server';
import { loggingServiceMock } from '../../../../../src/core/server/mocks';
import { configUtilsMock } from '../actions_config.mock';
import { actionsConfigMock } from '../actions_config.mock';
import { licenseStateMock } from '../lib/license_state.mock';
const ACTION_TYPE_IDS = ['.index', '.email', '.pagerduty', '.server-log', '.slack', '.webhook'];
@ -24,12 +25,13 @@ export function createActionTypeRegistry(): {
taskRunnerFactory: new TaskRunnerFactory(
new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false })
),
actionsConfigUtils: configUtilsMock,
actionsConfigUtils: actionsConfigMock.create(),
licenseState: licenseStateMock.create(),
});
registerBuiltInActionTypes({
logger,
actionTypeRegistry,
actionsConfigUtils: configUtilsMock,
actionsConfigUtils: actionsConfigMock.create(),
});
return { logger, actionTypeRegistry };
}

View file

@ -15,7 +15,7 @@ import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
import { postPagerduty } from './lib/post_pagerduty';
import { createActionTypeRegistry } from './index.test';
import { Logger } from '../../../../../src/core/server';
import { configUtilsMock } from '../actions_config.mock';
import { actionsConfigMock } from '../actions_config.mock';
const postPagerdutyMock = postPagerduty as jest.Mock;
@ -60,7 +60,7 @@ describe('validateConfig()', () => {
actionType = getActionType({
logger: mockedLogger,
configurationUtilities: {
...configUtilsMock,
...actionsConfigMock.create(),
ensureWhitelistedUri: url => {
expect(url).toEqual('https://events.pagerduty.com/v2/enqueue');
},
@ -76,7 +76,7 @@ describe('validateConfig()', () => {
actionType = getActionType({
logger: mockedLogger,
configurationUtilities: {
...configUtilsMock,
...actionsConfigMock.create(),
ensureWhitelistedUri: _ => {
throw new Error(`target url is not whitelisted`);
},

View file

@ -96,6 +96,7 @@ export function getActionType({
}): ActionType {
return {
id: '.pagerduty',
minimumLicenseRequired: 'gold',
name: i18n.translate('xpack.actions.builtin.pagerdutyTitle', {
defaultMessage: 'PagerDuty',
}),

View file

@ -35,6 +35,7 @@ const ParamsSchema = schema.object({
export function getActionType({ logger }: { logger: Logger }): ActionType {
return {
id: '.server-log',
minimumLicenseRequired: 'basic',
name: i18n.translate('xpack.actions.builtin.serverLogTitle', {
defaultMessage: 'Server log',
}),

View file

@ -9,7 +9,7 @@ import { ActionType, Services, ActionTypeExecutorOptions } from '../../types';
import { validateConfig, validateSecrets, validateParams } from '../../lib';
import { savedObjectsClientMock } from '../../../../../../src/core/server/mocks';
import { createActionTypeRegistry } from '../index.test';
import { configUtilsMock } from '../../actions_config.mock';
import { actionsConfigMock } from '../../actions_config.mock';
import { ACTION_TYPE_ID } from './constants';
import * as i18n from './translations';
@ -109,7 +109,7 @@ describe('validateConfig()', () => {
test('should validate and pass when the servicenow url is whitelisted', () => {
actionType = getActionType({
configurationUtilities: {
...configUtilsMock,
...actionsConfigMock.create(),
ensureWhitelistedUri: url => {
expect(url).toEqual(mockOptions.config.apiUrl);
},
@ -122,7 +122,7 @@ describe('validateConfig()', () => {
test('config validation returns an error if the specified URL isnt whitelisted', () => {
actionType = getActionType({
configurationUtilities: {
...configUtilsMock,
...actionsConfigMock.create(),
ensureWhitelistedUri: _ => {
throw new Error(`target url is not whitelisted`);
},

View file

@ -56,6 +56,7 @@ export function getActionType({
return {
id: ACTION_TYPE_ID,
name: i18n.NAME,
minimumLicenseRequired: 'platinum',
validate: {
config: schema.object(ConfigSchemaProps, {
validate: curry(validateConfig)(configurationUtilities),

View file

@ -8,7 +8,7 @@ import { ActionType, Services, ActionTypeExecutorOptions } from '../types';
import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
import { validateParams, validateSecrets } from '../lib';
import { getActionType } from './slack';
import { configUtilsMock } from '../actions_config.mock';
import { actionsConfigMock } from '../actions_config.mock';
const ACTION_TYPE_ID = '.slack';
@ -22,7 +22,7 @@ let actionType: ActionType;
beforeAll(() => {
actionType = getActionType({
async executor(options: ActionTypeExecutorOptions): Promise<any> {},
configurationUtilities: configUtilsMock,
configurationUtilities: actionsConfigMock.create(),
});
});
@ -85,7 +85,7 @@ describe('validateActionTypeSecrets()', () => {
test('should validate and pass when the slack webhookUrl is whitelisted', () => {
actionType = getActionType({
configurationUtilities: {
...configUtilsMock,
...actionsConfigMock.create(),
ensureWhitelistedUri: url => {
expect(url).toEqual('https://api.slack.com/');
},
@ -100,7 +100,7 @@ describe('validateActionTypeSecrets()', () => {
test('config validation returns an error if the specified URL isnt whitelisted', () => {
actionType = getActionType({
configurationUtilities: {
...configUtilsMock,
...actionsConfigMock.create(),
ensureWhitelistedHostname: url => {
throw new Error(`target hostname is not whitelisted`);
},
@ -135,7 +135,7 @@ describe('execute()', () => {
actionType = getActionType({
executor: mockSlackExecutor,
configurationUtilities: configUtilsMock,
configurationUtilities: actionsConfigMock.create(),
});
});

View file

@ -50,6 +50,7 @@ export function getActionType({
}): ActionType {
return {
id: '.slack',
minimumLicenseRequired: 'gold',
name: i18n.translate('xpack.actions.builtin.slackTitle', {
defaultMessage: 'Slack',
}),

View file

@ -12,7 +12,7 @@ import { getActionType } from './webhook';
import { ActionType, Services } from '../types';
import { validateConfig, validateSecrets, validateParams } from '../lib';
import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
import { configUtilsMock } from '../actions_config.mock';
import { actionsConfigMock } from '../actions_config.mock';
import { createActionTypeRegistry } from './index.test';
import { Logger } from '../../../../../src/core/server';
import axios from 'axios';
@ -164,7 +164,7 @@ describe('config validation', () => {
actionType = getActionType({
logger: mockedLogger,
configurationUtilities: {
...configUtilsMock,
...actionsConfigMock.create(),
ensureWhitelistedUri: _ => {
throw new Error(`target url is not whitelisted`);
},
@ -207,7 +207,7 @@ describe('execute()', () => {
axiosRequestMock.mockReset();
actionType = getActionType({
logger: mockedLogger,
configurationUtilities: configUtilsMock,
configurationUtilities: actionsConfigMock.create(),
});
});

View file

@ -66,6 +66,7 @@ export function getActionType({
}): ActionType {
return {
id: '.webhook',
minimumLicenseRequired: 'gold',
name: i18n.translate('xpack.actions.builtin.webhookTitle', {
defaultMessage: 'Webhook',
}),

View file

@ -7,6 +7,7 @@
import { taskManagerMock } from '../../task_manager/server/task_manager.mock';
import { createExecuteFunction } from './create_execute_function';
import { savedObjectsClientMock } from '../../../../src/core/server/mocks';
import { actionTypeRegistryMock } from './action_type_registry.mock';
const mockTaskManager = taskManagerMock.start();
const savedObjectsClient = savedObjectsClientMock.create();
@ -19,6 +20,7 @@ describe('execute()', () => {
const executeFn = createExecuteFunction({
getBasePath,
taskManager: mockTaskManager,
actionTypeRegistry: actionTypeRegistryMock.create(),
getScopedSavedObjectsClient: jest.fn().mockReturnValueOnce(savedObjectsClient),
isESOUsingEphemeralEncryptionKey: false,
});
@ -73,6 +75,7 @@ describe('execute()', () => {
taskManager: mockTaskManager,
getScopedSavedObjectsClient,
isESOUsingEphemeralEncryptionKey: false,
actionTypeRegistry: actionTypeRegistryMock.create(),
});
savedObjectsClient.get.mockResolvedValueOnce({
id: '123',
@ -121,6 +124,7 @@ describe('execute()', () => {
taskManager: mockTaskManager,
getScopedSavedObjectsClient,
isESOUsingEphemeralEncryptionKey: false,
actionTypeRegistry: actionTypeRegistryMock.create(),
});
savedObjectsClient.get.mockResolvedValueOnce({
id: '123',
@ -166,6 +170,7 @@ describe('execute()', () => {
taskManager: mockTaskManager,
getScopedSavedObjectsClient,
isESOUsingEphemeralEncryptionKey: true,
actionTypeRegistry: actionTypeRegistryMock.create(),
});
await expect(
executeFn({
@ -178,4 +183,36 @@ describe('execute()', () => {
`"Unable to execute action due to the Encrypted Saved Objects plugin using an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in kibana.yml"`
);
});
test('should ensure action type is enabled', async () => {
const mockedActionTypeRegistry = actionTypeRegistryMock.create();
const getScopedSavedObjectsClient = jest.fn().mockReturnValueOnce(savedObjectsClient);
const executeFn = createExecuteFunction({
getBasePath,
taskManager: mockTaskManager,
getScopedSavedObjectsClient,
isESOUsingEphemeralEncryptionKey: false,
actionTypeRegistry: mockedActionTypeRegistry,
});
mockedActionTypeRegistry.ensureActionTypeEnabled.mockImplementation(() => {
throw new Error('Fail');
});
savedObjectsClient.get.mockResolvedValueOnce({
id: '123',
type: 'action',
attributes: {
actionTypeId: 'mock-action',
},
references: [],
});
await expect(
executeFn({
id: '123',
params: { baz: false },
spaceId: 'default',
apiKey: null,
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`);
});
});

View file

@ -6,13 +6,14 @@
import { SavedObjectsClientContract } from '../../../../src/core/server';
import { TaskManagerStartContract } from '../../task_manager/server';
import { GetBasePathFunction, RawAction } from './types';
import { GetBasePathFunction, RawAction, ActionTypeRegistryContract } from './types';
interface CreateExecuteFunctionOptions {
taskManager: TaskManagerStartContract;
getScopedSavedObjectsClient: (request: any) => SavedObjectsClientContract;
getBasePath: GetBasePathFunction;
isESOUsingEphemeralEncryptionKey: boolean;
actionTypeRegistry: ActionTypeRegistryContract;
}
export interface ExecuteOptions {
@ -25,6 +26,7 @@ export interface ExecuteOptions {
export function createExecuteFunction({
getBasePath,
taskManager,
actionTypeRegistry,
getScopedSavedObjectsClient,
isESOUsingEphemeralEncryptionKey,
}: CreateExecuteFunctionOptions) {
@ -60,6 +62,9 @@ export function createExecuteFunction({
const savedObjectsClient = getScopedSavedObjectsClient(fakeRequest);
const actionSavedObject = await savedObjectsClient.get<RawAction>('action', id);
actionTypeRegistry.ensureActionTypeEnabled(actionSavedObject.attributes.actionTypeId);
const actionTaskParamsRecord = await savedObjectsClient.create('action_task_params', {
actionId: id,
params,

View file

@ -12,6 +12,7 @@ import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/serv
import { savedObjectsClientMock, loggingServiceMock } from '../../../../../src/core/server/mocks';
import { eventLoggerMock } from '../../../event_log/server/mocks';
import { spacesServiceMock } from '../../../spaces/server/spaces_service/spaces_service.mock';
import { ActionType } from '../types';
const actionExecutor = new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false });
const savedObjectsClient = savedObjectsClientMock.create();
@ -50,9 +51,10 @@ beforeEach(() => {
});
test('successfully executes', async () => {
const actionType = {
const actionType: jest.Mocked<ActionType> = {
id: 'test',
name: 'Test',
minimumLicenseRequired: 'basic',
executor: jest.fn(),
};
const actionSavedObject = {
@ -96,9 +98,10 @@ test('successfully executes', async () => {
});
test('provides empty config when config and / or secrets is empty', async () => {
const actionType = {
const actionType: jest.Mocked<ActionType> = {
id: 'test',
name: 'Test',
minimumLicenseRequired: 'basic',
executor: jest.fn(),
};
const actionSavedObject = {
@ -120,9 +123,10 @@ test('provides empty config when config and / or secrets is empty', async () =>
});
test('throws an error when config is invalid', async () => {
const actionType = {
const actionType: jest.Mocked<ActionType> = {
id: 'test',
name: 'Test',
minimumLicenseRequired: 'basic',
validate: {
config: schema.object({
param1: schema.string(),
@ -152,9 +156,10 @@ test('throws an error when config is invalid', async () => {
});
test('throws an error when params is invalid', async () => {
const actionType = {
const actionType: jest.Mocked<ActionType> = {
id: 'test',
name: 'Test',
minimumLicenseRequired: 'basic',
validate: {
params: schema.object({
param1: schema.string(),
@ -190,10 +195,11 @@ test('throws an error when failing to load action through savedObjectsClient', a
);
});
test('returns an error if actionType is not enabled', async () => {
const actionType = {
test('throws an error if actionType is not enabled', async () => {
const actionType: jest.Mocked<ActionType> = {
id: 'test',
name: 'Test',
minimumLicenseRequired: 'basic',
executor: jest.fn(),
};
const actionSavedObject = {
@ -210,17 +216,11 @@ test('returns an error if actionType is not enabled', async () => {
actionTypeRegistry.ensureActionTypeEnabled.mockImplementationOnce(() => {
throw new Error('not enabled for test');
});
const result = await actionExecutor.execute(executeParams);
await expect(actionExecutor.execute(executeParams)).rejects.toThrowErrorMatchingInlineSnapshot(
`"not enabled for test"`
);
expect(actionTypeRegistry.ensureActionTypeEnabled).toHaveBeenCalledWith('test');
expect(result).toMatchInlineSnapshot(`
Object {
"actionId": "1",
"message": "not enabled for test",
"retry": false,
"status": "error",
}
`);
});
test('throws an error when passing isESOUsingEphemeralEncryptionKey with value of true', async () => {

View file

@ -82,11 +82,7 @@ export class ActionExecutor {
attributes: { actionTypeId, config, name },
} = await services.savedObjectsClient.get<RawAction>('action', actionId);
try {
actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
} catch (err) {
return { status: 'error', actionId, message: err.message, retry: false };
}
actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
// Only get encrypted attributes here, the remaining attributes can be fetched in
// the savedObjectsClient call

View file

@ -0,0 +1,27 @@
/*
* 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 { KibanaResponseFactory } from '../../../../../../src/core/server';
import { ErrorThatHandlesItsOwnResponse } from './types';
export type ActionTypeDisabledReason =
| 'config'
| 'license_unavailable'
| 'license_invalid'
| 'license_expired';
export class ActionTypeDisabledError extends Error implements ErrorThatHandlesItsOwnResponse {
public readonly reason: ActionTypeDisabledReason;
constructor(message: string, reason: ActionTypeDisabledReason) {
super(message);
this.reason = reason;
}
public sendResponse(res: KibanaResponseFactory) {
return res.forbidden({ body: { message: this.message } });
}
}

View file

@ -0,0 +1,15 @@
/*
* 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 { ErrorThatHandlesItsOwnResponse } from './types';
export function isErrorThatHandlesItsOwnResponse(
e: ErrorThatHandlesItsOwnResponse
): e is ErrorThatHandlesItsOwnResponse {
return typeof (e as ErrorThatHandlesItsOwnResponse).sendResponse === 'function';
}
export { ActionTypeDisabledError, ActionTypeDisabledReason } from './action_type_disabled';

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 { KibanaResponseFactory, IKibanaResponse } from '../../../../../../src/core/server';
export interface ErrorThatHandlesItsOwnResponse extends Error {
sendResponse(res: KibanaResponseFactory): IKibanaResponse;
}

View file

@ -8,3 +8,10 @@ export { ExecutorError } from './executor_error';
export { validateParams, validateConfig, validateSecrets } from './validate_with_schema';
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 {
ActionTypeDisabledError,
ActionTypeDisabledReason,
isErrorThatHandlesItsOwnResponse,
} from './errors';

View file

@ -4,35 +4,22 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { of } from 'rxjs';
import { LicenseState } from './license_state';
import { LICENSE_CHECK_STATE, ILicense } from '../../../licensing/server';
import { ILicenseState } from './license_state';
import { LICENSE_CHECK_STATE } from '../../../licensing/server';
export const mockLicenseState = () => {
const license: ILicense = {
uid: '123',
status: 'active',
isActive: true,
signature: 'sig',
isAvailable: true,
toJSON: () => ({
signature: 'sig',
export const createLicenseStateMock = () => {
const licenseState: jest.Mocked<ILicenseState> = {
clean: jest.fn(),
getLicenseInformation: jest.fn(),
ensureLicenseForActionType: jest.fn(),
isLicenseValidForActionType: jest.fn(),
checkLicense: jest.fn().mockResolvedValue({
state: LICENSE_CHECK_STATE.Valid,
}),
getUnavailableReason: () => undefined,
hasAtLeast() {
return true;
},
check() {
return {
state: LICENSE_CHECK_STATE.Valid,
};
},
getFeature() {
return {
isAvailable: true,
isEnabled: true,
};
},
};
return new LicenseState(of(license));
return licenseState;
};
export const licenseStateMock = {
create: createLicenseStateMock,
};

View file

@ -4,12 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { LicenseState } from './license_state';
import { ActionType } from '../types';
import { BehaviorSubject } from 'rxjs';
import { LicenseState, ILicenseState } from './license_state';
import { licensingMock } from '../../../licensing/server/mocks';
import { LICENSE_CHECK_STATE } from '../../../licensing/server';
import { LICENSE_CHECK_STATE, ILicense } from '../../../licensing/server';
describe('license_state', () => {
describe('checkLicense()', () => {
let getRawLicense: any;
beforeEach(() => {
@ -29,7 +30,7 @@ describe('license_state', () => {
const licensing = licensingMock.createSetup();
const licenseState = new LicenseState(licensing.license$);
const actionsLicenseInfo = licenseState.checkLicense(getRawLicense());
expect(actionsLicenseInfo.enableAppLink).to.be(false);
expect(actionsLicenseInfo.enableAppLink).toBe(false);
});
});
@ -46,7 +47,131 @@ describe('license_state', () => {
const licensing = licensingMock.createSetup();
const licenseState = new LicenseState(licensing.license$);
const actionsLicenseInfo = licenseState.checkLicense(getRawLicense());
expect(actionsLicenseInfo.showAppLink).to.be(true);
expect(actionsLicenseInfo.showAppLink).toBe(true);
});
});
});
describe('isLicenseValidForActionType', () => {
let license: BehaviorSubject<ILicense>;
let licenseState: ILicenseState;
const fooActionType: ActionType = {
id: 'foo',
name: 'Foo',
minimumLicenseRequired: 'gold',
executor: async () => {},
};
beforeEach(() => {
license = new BehaviorSubject(null as any);
licenseState = new LicenseState(license);
});
test('should return false when license not defined', () => {
expect(licenseState.isLicenseValidForActionType(fooActionType)).toEqual({
isValid: false,
reason: 'unavailable',
});
});
test('should return false when license not available', () => {
license.next({ isAvailable: false } as any);
expect(licenseState.isLicenseValidForActionType(fooActionType)).toEqual({
isValid: false,
reason: 'unavailable',
});
});
test('should return false when license is expired', () => {
const expiredLicense = licensingMock.createLicense({ license: { status: 'expired' } });
license.next(expiredLicense);
expect(licenseState.isLicenseValidForActionType(fooActionType)).toEqual({
isValid: false,
reason: 'expired',
});
});
test('should return false when license is invalid', () => {
const basicLicense = licensingMock.createLicense({
license: { status: 'active', type: 'basic' },
});
license.next(basicLicense);
expect(licenseState.isLicenseValidForActionType(fooActionType)).toEqual({
isValid: false,
reason: 'invalid',
});
});
test('should return true when license is valid', () => {
const goldLicense = licensingMock.createLicense({
license: { status: 'active', type: 'gold' },
});
license.next(goldLicense);
expect(licenseState.isLicenseValidForActionType(fooActionType)).toEqual({
isValid: true,
});
});
});
describe('ensureLicenseForActionType()', () => {
let license: BehaviorSubject<ILicense>;
let licenseState: ILicenseState;
const fooActionType: ActionType = {
id: 'foo',
name: 'Foo',
minimumLicenseRequired: 'gold',
executor: async () => {},
};
beforeEach(() => {
license = new BehaviorSubject(null as any);
licenseState = new LicenseState(license);
});
test('should throw when license not defined', () => {
expect(() =>
licenseState.ensureLicenseForActionType(fooActionType)
).toThrowErrorMatchingInlineSnapshot(
`"Action type foo is disabled because license information is not available at this time."`
);
});
test('should throw when license not available', () => {
license.next({ isAvailable: false } as any);
expect(() =>
licenseState.ensureLicenseForActionType(fooActionType)
).toThrowErrorMatchingInlineSnapshot(
`"Action type foo is disabled because license information is not available at this time."`
);
});
test('should throw when license is expired', () => {
const expiredLicense = licensingMock.createLicense({ license: { status: 'expired' } });
license.next(expiredLicense);
expect(() =>
licenseState.ensureLicenseForActionType(fooActionType)
).toThrowErrorMatchingInlineSnapshot(
`"Action type foo is disabled because your basic license has expired."`
);
});
test('should throw when license is invalid', () => {
const basicLicense = licensingMock.createLicense({
license: { status: 'active', type: 'basic' },
});
license.next(basicLicense);
expect(() =>
licenseState.ensureLicenseForActionType(fooActionType)
).toThrowErrorMatchingInlineSnapshot(
`"Action type foo is disabled because your basic license does not support it. Please upgrade your license."`
);
});
test('should not throw when license is valid', () => {
const goldLicense = licensingMock.createLicense({
license: { status: 'active', type: 'gold' },
});
license.next(goldLicense);
licenseState.ensureLicenseForActionType(fooActionType);
});
});

View file

@ -9,6 +9,10 @@ import { Observable, Subscription } from 'rxjs';
import { assertNever } from '../../../../../src/core/utils';
import { ILicense, LICENSE_CHECK_STATE } from '../../../licensing/common/types';
import { PLUGIN } from '../constants/plugin';
import { ActionType } from '../types';
import { ActionTypeDisabledError } from './errors';
export type ILicenseState = PublicMethodsOf<LicenseState>;
export interface ActionsLicenseInformation {
showAppLink: boolean;
@ -19,12 +23,14 @@ export interface ActionsLicenseInformation {
export class LicenseState {
private licenseInformation: ActionsLicenseInformation = this.checkLicense(undefined);
private subscription: Subscription;
private license?: ILicense;
constructor(license$: Observable<ILicense>) {
this.subscription = license$.subscribe(this.updateInformation.bind(this));
}
private updateInformation(license: ILicense | undefined) {
this.license = license;
this.licenseInformation = this.checkLicense(license);
}
@ -36,6 +42,71 @@ export class LicenseState {
return this.licenseInformation;
}
public isLicenseValidForActionType(
actionType: ActionType
): { isValid: true } | { isValid: false; reason: 'unavailable' | 'expired' | 'invalid' } {
if (!this.license?.isAvailable) {
return { isValid: false, reason: 'unavailable' };
}
const check = this.license.check(actionType.id, actionType.minimumLicenseRequired);
switch (check.state) {
case LICENSE_CHECK_STATE.Expired:
return { isValid: false, reason: 'expired' };
case LICENSE_CHECK_STATE.Invalid:
return { isValid: false, reason: 'invalid' };
case LICENSE_CHECK_STATE.Unavailable:
return { isValid: false, reason: 'unavailable' };
case LICENSE_CHECK_STATE.Valid:
return { isValid: true };
default:
return assertNever(check.state);
}
}
public ensureLicenseForActionType(actionType: ActionType) {
const check = this.isLicenseValidForActionType(actionType);
if (check.isValid) {
return;
}
switch (check.reason) {
case 'unavailable':
throw new ActionTypeDisabledError(
i18n.translate('xpack.actions.serverSideErrors.unavailableLicenseErrorMessage', {
defaultMessage:
'Action type {actionTypeId} is disabled because license information is not available at this time.',
values: {
actionTypeId: actionType.id,
},
}),
'license_unavailable'
);
case 'expired':
throw new ActionTypeDisabledError(
i18n.translate('xpack.actions.serverSideErrors.expirerdLicenseErrorMessage', {
defaultMessage:
'Action type {actionTypeId} is disabled because your {licenseType} license has expired.',
values: { actionTypeId: actionType.id, licenseType: this.license!.type },
}),
'license_expired'
);
case 'invalid':
throw new ActionTypeDisabledError(
i18n.translate('xpack.actions.serverSideErrors.invalidLicenseErrorMessage', {
defaultMessage:
'Action type {actionTypeId} is disabled because your {licenseType} license does not support it. Please upgrade your license.',
values: { actionTypeId: actionType.id, licenseType: this.license!.type },
}),
'license_invalid'
);
default:
assertNever(check.reason);
}
}
public checkLicense(license: ILicense | undefined): ActionsLicenseInformation {
if (!license?.isAvailable) {
return {

View file

@ -14,6 +14,7 @@ import { actionExecutorMock } from './action_executor.mock';
import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks';
import { savedObjectsClientMock, loggingServiceMock } from 'src/core/server/mocks';
import { eventLoggerMock } from '../../../event_log/server/mocks';
import { ActionTypeDisabledError } from './errors';
const spaceIdToNamespace = jest.fn();
const actionTypeRegistry = actionTypeRegistryMock.create();
@ -63,6 +64,7 @@ const actionExecutorInitializerParams = {
};
const taskRunnerFactoryInitializerParams = {
spaceIdToNamespace,
actionTypeRegistry,
logger: loggingServiceMock.create().get(),
encryptedSavedObjectsPlugin: mockedEncryptedSavedObjectsPlugin,
getBasePath: jest.fn().mockReturnValue(undefined),
@ -308,3 +310,32 @@ test(`doesn't use API key when not provided`, async () => {
},
});
});
test(`throws an error when license doesn't support the action type`, async () => {
const taskRunner = taskRunnerFactory.create({
taskInstance: mockedTaskInstance,
});
mockedEncryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({
id: '3',
type: 'action_task_params',
attributes: {
actionId: '2',
params: { baz: true },
apiKey: Buffer.from('123:abc').toString('base64'),
},
references: [],
});
mockedActionExecutor.execute.mockImplementation(() => {
throw new ActionTypeDisabledError('Fail', 'license_invalid');
});
try {
await taskRunner.run();
throw new Error('Should have thrown');
} catch (e) {
expect(e instanceof ExecutorError).toEqual(true);
expect(e.data).toEqual({});
expect(e.retry).toEqual(false);
}
});

View file

@ -9,10 +9,18 @@ import { ExecutorError } from './executor_error';
import { Logger, CoreStart } from '../../../../../src/core/server';
import { RunContext } from '../../../task_manager/server';
import { EncryptedSavedObjectsPluginStart } from '../../../encrypted_saved_objects/server';
import { ActionTaskParams, GetBasePathFunction, SpaceIdToNamespaceFunction } from '../types';
import { ActionTypeDisabledError } from './errors';
import {
ActionTaskParams,
ActionTypeRegistryContract,
GetBasePathFunction,
SpaceIdToNamespaceFunction,
ActionTypeExecutorResult,
} from '../types';
export interface TaskRunnerContext {
logger: Logger;
actionTypeRegistry: ActionTypeRegistryContract;
encryptedSavedObjectsPlugin: EncryptedSavedObjectsPluginStart;
spaceIdToNamespace: SpaceIdToNamespaceFunction;
getBasePath: GetBasePathFunction;
@ -85,11 +93,20 @@ export class TaskRunnerFactory {
},
};
const executorResult = await actionExecutor.execute({
params,
actionId,
request: fakeRequest,
});
let executorResult: ActionTypeExecutorResult;
try {
executorResult = await actionExecutor.execute({
params,
actionId,
request: fakeRequest,
});
} catch (e) {
if (e instanceof ActionTypeDisabledError) {
// We'll stop re-trying due to action being forbidden
throw new ExecutorError(e.message, {}, false);
}
throw e;
}
if (executorResult.status === 'error') {
// Task manager error handler only kicks in when an error thrown (at this time)

View file

@ -14,7 +14,12 @@ const executor: ExecutorType = async options => {
};
test('should validate when there are no validators', () => {
const actionType: ActionType = { id: 'foo', name: 'bar', executor };
const actionType: ActionType = {
id: 'foo',
name: 'bar',
minimumLicenseRequired: 'basic',
executor,
};
const testValue = { any: ['old', 'thing'] };
const result = validateConfig(actionType, testValue);
@ -22,7 +27,13 @@ test('should validate when there are no validators', () => {
});
test('should validate when there are no individual validators', () => {
const actionType: ActionType = { id: 'foo', name: 'bar', executor, validate: {} };
const actionType: ActionType = {
id: 'foo',
name: 'bar',
minimumLicenseRequired: 'basic',
executor,
validate: {},
};
let result;
const testValue = { any: ['old', 'thing'] };
@ -42,6 +53,7 @@ test('should validate when validators return incoming value', () => {
const actionType: ActionType = {
id: 'foo',
name: 'bar',
minimumLicenseRequired: 'basic',
executor,
validate: {
params: selfValidator,
@ -69,6 +81,7 @@ test('should validate when validators return different values', () => {
const actionType: ActionType = {
id: 'foo',
name: 'bar',
minimumLicenseRequired: 'basic',
executor,
validate: {
params: selfValidator,
@ -99,6 +112,7 @@ test('should throw with expected error when validators fail', () => {
const actionType: ActionType = {
id: 'foo',
name: 'bar',
minimumLicenseRequired: 'basic',
executor,
validate: {
params: erroringValidator,
@ -127,6 +141,7 @@ test('should work with @kbn/config-schema', () => {
const actionType: ActionType = {
id: 'foo',
name: 'bar',
minimumLicenseRequired: 'basic',
executor,
validate: {
params: testSchema,

View file

@ -5,9 +5,9 @@
*/
import Boom from 'boom';
import { LicenseState } from './license_state';
import { ILicenseState } from './license_state';
export function verifyApiAccess(licenseState: LicenseState) {
export function verifyApiAccess(licenseState: ILicenseState) {
const licenseCheckResults = licenseState.getLicenseInformation();
if (licenseCheckResults.showAppLink && licenseCheckResults.enableAppLink) {

View file

@ -19,6 +19,7 @@ const createSetupMock = () => {
const createStartMock = () => {
const mock: jest.Mocked<PluginStartContract> = {
execute: jest.fn(),
isActionTypeEnabled: jest.fn(),
getActionsClientWithRequest: jest.fn().mockResolvedValue(actionsClientMock.create()),
};
return mock;

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ActionsPlugin, ActionsPluginsSetup, ActionsPluginsStart } from './plugin';
import { PluginInitializerContext } from '../../../../src/core/server';
import { coreMock, httpServerMock } from '../../../../src/core/server/mocks';
import { licensingMock } from '../../licensing/server/mocks';
@ -12,6 +11,13 @@ import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/
import { taskManagerMock } from '../../task_manager/server/mocks';
import { eventLogMock } from '../../event_log/server/mocks';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { ActionType } from './types';
import {
ActionsPlugin,
ActionsPluginsSetup,
ActionsPluginsStart,
PluginSetupContract,
} from './plugin';
describe('Actions Plugin', () => {
const usageCollectionMock: jest.Mocked<UsageCollectionSetup> = ({
@ -97,6 +103,54 @@ describe('Actions Plugin', () => {
);
});
});
describe('registerType()', () => {
let setup: PluginSetupContract;
const sampleActionType: ActionType = {
id: 'test',
name: 'test',
minimumLicenseRequired: 'basic',
async executor() {},
};
beforeEach(async () => {
setup = await plugin.setup(coreSetup, pluginsSetup);
});
it('should throw error when license type is invalid', async () => {
expect(() =>
setup.registerType({
...sampleActionType,
minimumLicenseRequired: 'foo' as any,
})
).toThrowErrorMatchingInlineSnapshot(`"\\"foo\\" is not a valid license type"`);
});
it('should throw error when license type is less than gold', async () => {
expect(() =>
setup.registerType({
...sampleActionType,
minimumLicenseRequired: 'basic',
})
).toThrowErrorMatchingInlineSnapshot(
`"Third party action type \\"test\\" can only set minimumLicenseRequired to a gold license or higher"`
);
});
it('should not throw when license type is gold', async () => {
setup.registerType({
...sampleActionType,
minimumLicenseRequired: 'gold',
});
});
it('should not throw when license type is higher than gold', async () => {
setup.registerType({
...sampleActionType,
minimumLicenseRequired: 'platinum',
});
});
});
});
describe('start()', () => {
let plugin: ActionsPlugin;

View file

@ -26,11 +26,12 @@ import {
} from '../../encrypted_saved_objects/server';
import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server';
import { LicensingPluginSetup } from '../../licensing/server';
import { LICENSE_TYPE } from '../../licensing/common/types';
import { SpacesPluginSetup, SpacesServiceSetup } from '../../spaces/server';
import { ActionsConfig } from './config';
import { Services } from './types';
import { ActionExecutor, TaskRunnerFactory } from './lib';
import { Services, ActionType } from './types';
import { ActionExecutor, TaskRunnerFactory, LicenseState, ILicenseState } from './lib';
import { ActionsClient } from './actions_client';
import { ActionTypeRegistry } from './action_type_registry';
import { ExecuteOptions } from './create_execute_function';
@ -49,7 +50,6 @@ import {
listActionTypesRoute,
executeActionRoute,
} from './routes';
import { LicenseState } from './lib/license_state';
import { IEventLogger, IEventLogService } from '../../event_log/server';
import { initializeActionsTelemetry, scheduleActionsTelemetry } from './usage/task';
@ -60,10 +60,11 @@ export const EVENT_LOG_ACTIONS = {
};
export interface PluginSetupContract {
registerType: ActionTypeRegistry['register'];
registerType: (actionType: ActionType) => void;
}
export interface PluginStartContract {
isActionTypeEnabled(id: string): boolean;
execute(options: ExecuteOptions): Promise<void>;
getActionsClientWithRequest(request: KibanaRequest): Promise<PublicMethodsOf<ActionsClient>>;
}
@ -91,7 +92,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
private taskRunnerFactory?: TaskRunnerFactory;
private actionTypeRegistry?: ActionTypeRegistry;
private actionExecutor?: ActionExecutor;
private licenseState: LicenseState | null = null;
private licenseState: ILicenseState | null = null;
private spaces?: SpacesServiceSetup;
private eventLogger?: IEventLogger;
private isESOUsingEphemeralEncryptionKey?: boolean;
@ -115,6 +116,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
}
public async setup(core: CoreSetup, plugins: ActionsPluginsSetup): Promise<PluginSetupContract> {
this.licenseState = new LicenseState(plugins.licensing.license$);
this.isESOUsingEphemeralEncryptionKey =
plugins.encryptedSavedObjects.usingEphemeralEncryptionKey;
@ -156,6 +158,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
taskRunnerFactory,
taskManager: plugins.taskManager,
actionsConfigUtils,
licenseState: this.licenseState,
});
this.taskRunnerFactory = taskRunnerFactory;
this.actionTypeRegistry = actionTypeRegistry;
@ -190,7 +193,6 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
);
// Routes
this.licenseState = new LicenseState(plugins.licensing.license$);
const router = core.http.createRouter();
createActionRoute(router, this.licenseState);
deleteActionRoute(router, this.licenseState);
@ -201,7 +203,17 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
executeActionRoute(router, this.licenseState, actionExecutor);
return {
registerType: actionTypeRegistry.register.bind(actionTypeRegistry),
registerType: (actionType: ActionType) => {
if (!(actionType.minimumLicenseRequired in LICENSE_TYPE)) {
throw new Error(`"${actionType.minimumLicenseRequired}" is not a valid license type`);
}
if (LICENSE_TYPE[actionType.minimumLicenseRequired] < LICENSE_TYPE.gold) {
throw new Error(
`Third party action type "${actionType.id}" can only set minimumLicenseRequired to a gold license or higher`
);
}
actionTypeRegistry.register(actionType);
},
};
}
@ -227,6 +239,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
taskRunnerFactory!.initialize({
logger,
actionTypeRegistry: actionTypeRegistry!,
encryptedSavedObjectsPlugin: plugins.encryptedSavedObjects,
getBasePath: this.getBasePath,
spaceIdToNamespace: this.spaceIdToNamespace,
@ -238,10 +251,14 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
return {
execute: createExecuteFunction({
taskManager: plugins.taskManager,
actionTypeRegistry: actionTypeRegistry!,
getScopedSavedObjectsClient: core.savedObjects.getScopedClient,
getBasePath: this.getBasePath,
isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!,
}),
isActionTypeEnabled: id => {
return this.actionTypeRegistry!.isActionTypeEnabled(id);
},
// Ability to get an actions client from legacy code
async getActionsClientWithRequest(request: KibanaRequest) {
if (isESOUsingEphemeralEncryptionKey === true) {

View file

@ -5,11 +5,11 @@
*/
import { createActionRoute } from './create';
import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock';
import { mockLicenseState } from '../lib/license_state.mock';
import { verifyApiAccess } from '../lib/license_api_access';
import { licenseStateMock } from '../lib/license_state.mock';
import { verifyApiAccess, ActionTypeDisabledError } from '../lib';
import { mockHandlerArguments } from './_mock_handler_arguments';
jest.mock('../lib/license_api_access.ts', () => ({
jest.mock('../lib/verify_api_access.ts', () => ({
verifyApiAccess: jest.fn(),
}));
@ -19,7 +19,7 @@ beforeEach(() => {
describe('createActionRoute', () => {
it('creates an action with proper parameters', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
createActionRoute(router, licenseState);
@ -82,7 +82,7 @@ describe('createActionRoute', () => {
});
it('ensures the license allows creating actions', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
createActionRoute(router, licenseState);
@ -106,7 +106,7 @@ describe('createActionRoute', () => {
});
it('ensures the license check prevents creating actions', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
(verifyApiAccess as jest.Mock).mockImplementation(() => {
@ -132,4 +132,23 @@ describe('createActionRoute', () => {
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
it('ensures the action type gets validated for the license', async () => {
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
createActionRoute(router, licenseState);
const [, handler] = router.post.mock.calls[0];
const actionsClient = {
create: jest.fn().mockRejectedValue(new ActionTypeDisabledError('Fail', 'license_invalid')),
};
const [context, req, res] = mockHandlerArguments({ actionsClient }, {}, ['ok', 'forbidden']);
await handler(context, req, res);
expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } });
});
});

View file

@ -13,8 +13,7 @@ import {
KibanaResponseFactory,
} from 'kibana/server';
import { ActionResult } from '../types';
import { LicenseState } from '../lib/license_state';
import { verifyApiAccess } from '../lib/license_api_access';
import { ILicenseState, verifyApiAccess, isErrorThatHandlesItsOwnResponse } from '../lib';
export const bodySchema = schema.object({
name: schema.string(),
@ -23,7 +22,7 @@ export const bodySchema = schema.object({
secrets: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }),
});
export const createActionRoute = (router: IRouter, licenseState: LicenseState) => {
export const createActionRoute = (router: IRouter, licenseState: ILicenseState) => {
router.post(
{
path: `/api/action`,
@ -46,10 +45,17 @@ export const createActionRoute = (router: IRouter, licenseState: LicenseState) =
}
const actionsClient = context.actions.getActionsClient();
const action = req.body;
const actionRes: ActionResult = await actionsClient.create({ action });
return res.ok({
body: actionRes,
});
try {
const actionRes: ActionResult = await actionsClient.create({ action });
return res.ok({
body: actionRes,
});
} catch (e) {
if (isErrorThatHandlesItsOwnResponse(e)) {
return e.sendResponse(res);
}
throw e;
}
})
);
};

View file

@ -5,11 +5,11 @@
*/
import { deleteActionRoute } from './delete';
import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock';
import { mockLicenseState } from '../lib/license_state.mock';
import { verifyApiAccess } from '../lib/license_api_access';
import { licenseStateMock } from '../lib/license_state.mock';
import { verifyApiAccess } from '../lib';
import { mockHandlerArguments } from './_mock_handler_arguments';
jest.mock('../lib/license_api_access.ts', () => ({
jest.mock('../lib/verify_api_access.ts', () => ({
verifyApiAccess: jest.fn(),
}));
@ -19,7 +19,7 @@ beforeEach(() => {
describe('deleteActionRoute', () => {
it('deletes an action with proper parameters', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
deleteActionRoute(router, licenseState);
@ -64,7 +64,7 @@ describe('deleteActionRoute', () => {
});
it('ensures the license allows deleting actions', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
deleteActionRoute(router, licenseState);
@ -85,7 +85,7 @@ describe('deleteActionRoute', () => {
});
it('ensures the license check prevents deleting actions', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
(verifyApiAccess as jest.Mock).mockImplementation(() => {

View file

@ -17,14 +17,13 @@ import {
IKibanaResponse,
KibanaResponseFactory,
} from 'kibana/server';
import { LicenseState } from '../lib/license_state';
import { verifyApiAccess } from '../lib/license_api_access';
import { ILicenseState, verifyApiAccess } from '../lib';
const paramSchema = schema.object({
id: schema.string(),
});
export const deleteActionRoute = (router: IRouter, licenseState: LicenseState) => {
export const deleteActionRoute = (router: IRouter, licenseState: ILicenseState) => {
router.delete(
{
path: `/api/action/{id}`,

View file

@ -6,12 +6,11 @@
import { executeActionRoute } from './execute';
import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock';
import { mockLicenseState } from '../lib/license_state.mock';
import { verifyApiAccess } from '../lib/license_api_access';
import { licenseStateMock } from '../lib/license_state.mock';
import { mockHandlerArguments } from './_mock_handler_arguments';
import { ActionExecutorContract } from '../lib';
import { ActionExecutorContract, verifyApiAccess, ActionTypeDisabledError } from '../lib';
jest.mock('../lib/license_api_access.ts', () => ({
jest.mock('../lib/verify_api_access.ts', () => ({
verifyApiAccess: jest.fn(),
}));
@ -21,7 +20,7 @@ beforeEach(() => {
describe('executeActionRoute', () => {
it('executes an action with proper parameters', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
const [context, req, res] = mockHandlerArguments(
@ -77,7 +76,7 @@ describe('executeActionRoute', () => {
});
it('returns a "204 NO CONTENT" when the executor returns a nullish value', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
const [context, req, res] = mockHandlerArguments(
@ -115,7 +114,7 @@ describe('executeActionRoute', () => {
});
it('ensures the license allows action execution', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
const [context, req, res] = mockHandlerArguments(
@ -147,7 +146,7 @@ describe('executeActionRoute', () => {
});
it('ensures the license check prevents action execution', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
(verifyApiAccess as jest.Mock).mockImplementation(() => {
@ -181,4 +180,33 @@ describe('executeActionRoute', () => {
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
it('ensures the action type gets validated for the license', async () => {
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
const [context, req, res] = mockHandlerArguments(
{},
{
body: {},
params: {},
},
['ok', 'forbidden']
);
const actionExecutor = {
initialize: jest.fn(),
execute: jest.fn().mockImplementation(() => {
throw new ActionTypeDisabledError('Fail', 'license_invalid');
}),
} as jest.Mocked<ActionExecutorContract>;
executeActionRoute(router, licenseState, actionExecutor);
const [, handler] = router.post.mock.calls[0];
await handler(context, req, res);
expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } });
});
});

View file

@ -11,8 +11,7 @@ import {
IKibanaResponse,
KibanaResponseFactory,
} from 'kibana/server';
import { LicenseState } from '../lib/license_state';
import { verifyApiAccess } from '../lib/license_api_access';
import { ILicenseState, verifyApiAccess, isErrorThatHandlesItsOwnResponse } from '../lib';
import { ActionExecutorContract } from '../lib';
import { ActionTypeExecutorResult } from '../types';
@ -27,7 +26,7 @@ const bodySchema = schema.object({
export const executeActionRoute = (
router: IRouter,
licenseState: LicenseState,
licenseState: ILicenseState,
actionExecutor: ActionExecutorContract
) => {
router.post(
@ -49,16 +48,23 @@ export const executeActionRoute = (
verifyApiAccess(licenseState);
const { params } = req.body;
const { id } = req.params;
const body: ActionTypeExecutorResult = await actionExecutor.execute({
params,
request: req,
actionId: id,
});
return body
? res.ok({
body,
})
: res.noContent();
try {
const body: ActionTypeExecutorResult = await actionExecutor.execute({
params,
request: req,
actionId: id,
});
return body
? res.ok({
body,
})
: res.noContent();
} catch (e) {
if (isErrorThatHandlesItsOwnResponse(e)) {
return e.sendResponse(res);
}
throw e;
}
})
);
};

View file

@ -6,11 +6,11 @@
import { findActionRoute } from './find';
import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock';
import { mockLicenseState } from '../lib/license_state.mock';
import { verifyApiAccess } from '../lib/license_api_access';
import { licenseStateMock } from '../lib/license_state.mock';
import { verifyApiAccess } from '../lib';
import { mockHandlerArguments } from './_mock_handler_arguments';
jest.mock('../lib/license_api_access.ts', () => ({
jest.mock('../lib/verify_api_access.ts', () => ({
verifyApiAccess: jest.fn(),
}));
@ -20,7 +20,7 @@ beforeEach(() => {
describe('findActionRoute', () => {
it('finds actions with proper parameters', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
findActionRoute(router, licenseState);
@ -93,7 +93,7 @@ describe('findActionRoute', () => {
});
it('ensures the license allows finding actions', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
findActionRoute(router, licenseState);
@ -123,7 +123,7 @@ describe('findActionRoute', () => {
});
it('ensures the license check prevents finding actions', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
(verifyApiAccess as jest.Mock).mockImplementation(() => {

View file

@ -13,8 +13,7 @@ import {
KibanaResponseFactory,
} from 'kibana/server';
import { FindOptions } from '../../../alerting/server';
import { LicenseState } from '../lib/license_state';
import { verifyApiAccess } from '../lib/license_api_access';
import { ILicenseState, verifyApiAccess } from '../lib';
// config definition
const querySchema = schema.object({
@ -41,7 +40,7 @@ const querySchema = schema.object({
filter: schema.maybe(schema.string()),
});
export const findActionRoute = (router: IRouter, licenseState: LicenseState) => {
export const findActionRoute = (router: IRouter, licenseState: ILicenseState) => {
router.get(
{
path: `/api/action/_find`,

View file

@ -6,11 +6,11 @@
import { getActionRoute } from './get';
import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock';
import { mockLicenseState } from '../lib/license_state.mock';
import { verifyApiAccess } from '../lib/license_api_access';
import { licenseStateMock } from '../lib/license_state.mock';
import { verifyApiAccess } from '../lib';
import { mockHandlerArguments } from './_mock_handler_arguments';
jest.mock('../lib/license_api_access.ts', () => ({
jest.mock('../lib/verify_api_access.ts', () => ({
verifyApiAccess: jest.fn(),
}));
@ -20,7 +20,7 @@ beforeEach(() => {
describe('getActionRoute', () => {
it('gets an action with proper parameters', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
getActionRoute(router, licenseState);
@ -74,7 +74,7 @@ describe('getActionRoute', () => {
});
it('ensures the license allows getting actions', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
getActionRoute(router, licenseState);
@ -104,7 +104,7 @@ describe('getActionRoute', () => {
});
it('ensures the license check prevents getting actions', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
(verifyApiAccess as jest.Mock).mockImplementation(() => {

View file

@ -12,14 +12,13 @@ import {
IKibanaResponse,
KibanaResponseFactory,
} from 'kibana/server';
import { LicenseState } from '../lib/license_state';
import { verifyApiAccess } from '../lib/license_api_access';
import { ILicenseState, verifyApiAccess } from '../lib';
const paramSchema = schema.object({
id: schema.string(),
});
export const getActionRoute = (router: IRouter, licenseState: LicenseState) => {
export const getActionRoute = (router: IRouter, licenseState: ILicenseState) => {
router.get(
{
path: `/api/action/{id}`,

View file

@ -6,11 +6,11 @@
import { listActionTypesRoute } from './list_action_types';
import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock';
import { mockLicenseState } from '../lib/license_state.mock';
import { verifyApiAccess } from '../lib/license_api_access';
import { licenseStateMock } from '../lib/license_state.mock';
import { verifyApiAccess } from '../lib';
import { mockHandlerArguments } from './_mock_handler_arguments';
jest.mock('../lib/license_api_access.ts', () => ({
jest.mock('../lib/verify_api_access.ts', () => ({
verifyApiAccess: jest.fn(),
}));
@ -20,7 +20,7 @@ beforeEach(() => {
describe('listActionTypesRoute', () => {
it('lists action types with proper parameters', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
listActionTypesRoute(router, licenseState);
@ -66,7 +66,7 @@ describe('listActionTypesRoute', () => {
});
it('ensures the license allows listing action types', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
listActionTypesRoute(router, licenseState);
@ -104,7 +104,7 @@ describe('listActionTypesRoute', () => {
});
it('ensures the license check prevents listing action types', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
(verifyApiAccess as jest.Mock).mockImplementation(() => {

View file

@ -11,10 +11,9 @@ import {
IKibanaResponse,
KibanaResponseFactory,
} from 'kibana/server';
import { LicenseState } from '../lib/license_state';
import { verifyApiAccess } from '../lib/license_api_access';
import { ILicenseState, verifyApiAccess } from '../lib';
export const listActionTypesRoute = (router: IRouter, licenseState: LicenseState) => {
export const listActionTypesRoute = (router: IRouter, licenseState: ILicenseState) => {
router.get(
{
path: `/api/action/types`,

View file

@ -5,11 +5,11 @@
*/
import { updateActionRoute } from './update';
import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock';
import { mockLicenseState } from '../lib/license_state.mock';
import { verifyApiAccess } from '../lib/license_api_access';
import { licenseStateMock } from '../lib/license_state.mock';
import { verifyApiAccess, ActionTypeDisabledError } from '../lib';
import { mockHandlerArguments } from './_mock_handler_arguments';
jest.mock('../lib/license_api_access.ts', () => ({
jest.mock('../lib/verify_api_access.ts', () => ({
verifyApiAccess: jest.fn(),
}));
@ -19,7 +19,7 @@ beforeEach(() => {
describe('updateActionRoute', () => {
it('updates an action with proper parameters', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
updateActionRoute(router, licenseState);
@ -85,7 +85,7 @@ describe('updateActionRoute', () => {
});
it('ensures the license allows deleting actions', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
updateActionRoute(router, licenseState);
@ -124,7 +124,7 @@ describe('updateActionRoute', () => {
});
it('ensures the license check prevents deleting actions', async () => {
const licenseState = mockLicenseState();
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
(verifyApiAccess as jest.Mock).mockImplementation(() => {
@ -165,4 +165,26 @@ describe('updateActionRoute', () => {
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
it('ensures the action type gets validated for the license', async () => {
const licenseState = licenseStateMock.create();
const router: RouterMock = mockRouter.create();
updateActionRoute(router, licenseState);
const [, handler] = router.put.mock.calls[0];
const actionsClient = {
update: jest.fn().mockRejectedValue(new ActionTypeDisabledError('Fail', 'license_invalid')),
};
const [context, req, res] = mockHandlerArguments({ actionsClient }, { params: {}, body: {} }, [
'ok',
'forbidden',
]);
await handler(context, req, res);
expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } });
});
});

View file

@ -12,8 +12,7 @@ import {
IKibanaResponse,
KibanaResponseFactory,
} from 'kibana/server';
import { LicenseState } from '../lib/license_state';
import { verifyApiAccess } from '../lib/license_api_access';
import { ILicenseState, verifyApiAccess, isErrorThatHandlesItsOwnResponse } from '../lib';
const paramSchema = schema.object({
id: schema.string(),
@ -25,7 +24,7 @@ const bodySchema = schema.object({
secrets: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }),
});
export const updateActionRoute = (router: IRouter, licenseState: LicenseState) => {
export const updateActionRoute = (router: IRouter, licenseState: ILicenseState) => {
router.put(
{
path: `/api/action/{id}`,
@ -49,12 +48,20 @@ export const updateActionRoute = (router: IRouter, licenseState: LicenseState) =
const actionsClient = context.actions.getActionsClient();
const { id } = req.params;
const { name, config, secrets } = req.body;
return res.ok({
body: await actionsClient.update({
id,
action: { name, config, secrets },
}),
});
try {
return res.ok({
body: await actionsClient.update({
id,
action: { name, config, secrets },
}),
});
} catch (e) {
if (isErrorThatHandlesItsOwnResponse(e)) {
return e.sendResponse(res);
}
throw e;
}
})
);
};

View file

@ -8,6 +8,7 @@ import { SavedObjectsClientContract, SavedObjectAttributes } from '../../../../s
import { ActionTypeRegistry } from './action_type_registry';
import { PluginSetupContract, PluginStartContract } from './plugin';
import { ActionsClient } from './actions_client';
import { LicenseType } from '../../licensing/common/types';
export type WithoutQueryAndParams<T> = Pick<T, Exclude<keyof T, 'query' | 'params'>>;
export type GetServicesFunction = (request: any) => Services;
@ -84,6 +85,7 @@ export interface ActionType {
id: string;
name: string;
maxAttempts?: number;
minimumLicenseRequired: LicenseType;
validate?: {
params?: ValidatorType;
config?: ValidatorType;

View file

@ -206,7 +206,7 @@ export class AlertingPlugin {
logger,
getServices: this.getServicesFactory(core.savedObjects),
spaceIdToNamespace: this.spaceIdToNamespace,
executeAction: plugins.actions.execute,
actionsPlugin: plugins.actions,
encryptedSavedObjectsPlugin: plugins.encryptedSavedObjects,
getBasePath: this.getBasePath,
});

View file

@ -7,6 +7,7 @@
import { AlertType } from '../types';
import { createExecutionHandler } from './create_execution_handler';
import { loggingServiceMock } from '../../../../../src/core/server/mocks';
import { actionsMock } from '../../../actions/server/mocks';
const alertType: AlertType = {
id: 'test',
@ -20,7 +21,7 @@ const alertType: AlertType = {
};
const createExecutionHandlerParams = {
executeAction: jest.fn(),
actionsPlugin: actionsMock.createStart(),
spaceId: 'default',
alertId: '1',
alertName: 'name-of-alert',
@ -45,9 +46,12 @@ const createExecutionHandlerParams = {
],
};
beforeEach(() => jest.resetAllMocks());
beforeEach(() => {
jest.resetAllMocks();
createExecutionHandlerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true);
});
test('calls executeAction per selected action', async () => {
test('calls actionsPlugin.execute per selected action', async () => {
const executionHandler = createExecutionHandler(createExecutionHandlerParams);
await executionHandler({
actionGroup: 'default',
@ -55,8 +59,8 @@ test('calls executeAction per selected action', async () => {
context: {},
alertInstanceId: '2',
});
expect(createExecutionHandlerParams.executeAction).toHaveBeenCalledTimes(1);
expect(createExecutionHandlerParams.executeAction.mock.calls[0]).toMatchInlineSnapshot(`
expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1);
expect(createExecutionHandlerParams.actionsPlugin.execute.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"apiKey": "MTIzOmFiYw==",
@ -73,7 +77,46 @@ test('calls executeAction per selected action', async () => {
`);
});
test('limits executeAction per action group', async () => {
test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () => {
// Mock two calls, one for check against actions[0] and the second for actions[1]
createExecutionHandlerParams.actionsPlugin.isActionTypeEnabled.mockReturnValueOnce(false);
createExecutionHandlerParams.actionsPlugin.isActionTypeEnabled.mockReturnValueOnce(true);
const executionHandler = createExecutionHandler({
...createExecutionHandlerParams,
actions: [
...createExecutionHandlerParams.actions,
{
id: '2',
group: 'default',
actionTypeId: 'test2',
params: {
foo: true,
contextVal: 'My other {{context.value}} goes here',
stateVal: 'My other {{state.value}} goes here',
},
},
],
});
await executionHandler({
actionGroup: 'default',
state: {},
context: {},
alertInstanceId: '2',
});
expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1);
expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledWith({
id: '2',
params: {
foo: true,
contextVal: 'My other goes here',
stateVal: 'My other goes here',
},
spaceId: 'default',
apiKey: createExecutionHandlerParams.apiKey,
});
});
test('limits actionsPlugin.execute per action group', async () => {
const executionHandler = createExecutionHandler(createExecutionHandlerParams);
await executionHandler({
actionGroup: 'other-group',
@ -81,7 +124,7 @@ test('limits executeAction per action group', async () => {
context: {},
alertInstanceId: '2',
});
expect(createExecutionHandlerParams.executeAction).toMatchInlineSnapshot(`[MockFunction]`);
expect(createExecutionHandlerParams.actionsPlugin.execute).not.toHaveBeenCalled();
});
test('context attribute gets parameterized', async () => {
@ -92,8 +135,8 @@ test('context attribute gets parameterized', async () => {
state: {},
alertInstanceId: '2',
});
expect(createExecutionHandlerParams.executeAction).toHaveBeenCalledTimes(1);
expect(createExecutionHandlerParams.executeAction.mock.calls[0]).toMatchInlineSnapshot(`
expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1);
expect(createExecutionHandlerParams.actionsPlugin.execute.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"apiKey": "MTIzOmFiYw==",
@ -118,8 +161,8 @@ test('state attribute gets parameterized', async () => {
state: { value: 'state-val' },
alertInstanceId: '2',
});
expect(createExecutionHandlerParams.executeAction).toHaveBeenCalledTimes(1);
expect(createExecutionHandlerParams.executeAction.mock.calls[0]).toMatchInlineSnapshot(`
expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1);
expect(createExecutionHandlerParams.actionsPlugin.execute.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"apiKey": "MTIzOmFiYw==",

View file

@ -14,7 +14,7 @@ interface CreateExecutionHandlerOptions {
alertId: string;
alertName: string;
tags?: string[];
executeAction: ActionsPluginStartContract['execute'];
actionsPlugin: ActionsPluginStartContract;
actions: AlertAction[];
spaceId: string;
apiKey: string | null;
@ -34,7 +34,7 @@ export function createExecutionHandler({
alertId,
alertName,
tags,
executeAction,
actionsPlugin,
actions: alertActions,
spaceId,
apiKey,
@ -64,12 +64,18 @@ export function createExecutionHandler({
};
});
for (const action of actions) {
await executeAction({
id: action.id,
params: action.params,
spaceId,
apiKey,
});
if (actionsPlugin.isActionTypeEnabled(action.actionTypeId)) {
await actionsPlugin.execute({
id: action.id,
params: action.params,
spaceId,
apiKey,
});
} else {
logger.warn(
`Alert "${alertId}" skipped scheduling action "${action.id}" because it is disabled`
);
}
}
};
}

View file

@ -12,6 +12,8 @@ import { TaskRunnerContext } from './task_runner_factory';
import { TaskRunner } from './task_runner';
import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_objects/server/mocks';
import { savedObjectsClientMock, loggingServiceMock } from '../../../../../src/core/server/mocks';
import { PluginStartContract as ActionsPluginStart } from '../../../actions/server';
import { actionsMock } from '../../../actions/server/mocks';
const alertType = {
id: 'test',
@ -55,9 +57,11 @@ describe('Task Runner', () => {
savedObjectsClient,
};
const taskRunnerFactoryInitializerParams: jest.Mocked<TaskRunnerContext> = {
const taskRunnerFactoryInitializerParams: jest.Mocked<TaskRunnerContext> & {
actionsPlugin: jest.Mocked<ActionsPluginStart>;
} = {
getServices: jest.fn().mockReturnValue(services),
executeAction: jest.fn(),
actionsPlugin: actionsMock.createStart(),
encryptedSavedObjectsPlugin,
logger: loggingServiceMock.create().get(),
spaceIdToNamespace: jest.fn().mockReturnValue(undefined),
@ -154,7 +158,8 @@ describe('Task Runner', () => {
expect(call.services).toBeTruthy();
});
test('executeAction is called per alert instance that is scheduled', async () => {
test('actionsPlugin.execute is called per alert instance that is scheduled', async () => {
taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true);
alertType.executor.mockImplementation(
({ services: executorServices }: AlertExecutorOptions) => {
executorServices.alertInstanceFactory('1').scheduleActions('default');
@ -175,8 +180,9 @@ describe('Task Runner', () => {
references: [],
});
await taskRunner.run();
expect(taskRunnerFactoryInitializerParams.executeAction).toHaveBeenCalledTimes(1);
expect(taskRunnerFactoryInitializerParams.executeAction.mock.calls[0]).toMatchInlineSnapshot(`
expect(taskRunnerFactoryInitializerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1);
expect(taskRunnerFactoryInitializerParams.actionsPlugin.execute.mock.calls[0])
.toMatchInlineSnapshot(`
Array [
Object {
"apiKey": "MTIzOmFiYw==",

View file

@ -119,7 +119,7 @@ export class TaskRunner {
alertName,
tags,
logger: this.logger,
executeAction: this.context.executeAction,
actionsPlugin: this.context.actionsPlugin,
apiKey,
actions: actionsWithIds,
spaceId,

View file

@ -9,6 +9,7 @@ import { ConcreteTaskInstance, TaskStatus } from '../../../../plugins/task_manag
import { TaskRunnerContext, TaskRunnerFactory } from './task_runner_factory';
import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_objects/server/mocks';
import { savedObjectsClientMock, loggingServiceMock } from '../../../../../src/core/server/mocks';
import { actionsMock } from '../../../actions/server/mocks';
const alertType = {
id: 'test',
@ -56,7 +57,7 @@ describe('Task Runner Factory', () => {
const taskRunnerFactoryInitializerParams: jest.Mocked<TaskRunnerContext> = {
getServices: jest.fn().mockReturnValue(services),
executeAction: jest.fn(),
actionsPlugin: actionsMock.createStart(),
encryptedSavedObjectsPlugin,
logger: loggingServiceMock.create().get(),
spaceIdToNamespace: jest.fn().mockReturnValue(undefined),

View file

@ -18,7 +18,7 @@ import { TaskRunner } from './task_runner';
export interface TaskRunnerContext {
logger: Logger;
getServices: GetServicesFunction;
executeAction: ActionsPluginStartContract['execute'];
actionsPlugin: ActionsPluginStartContract;
encryptedSavedObjectsPlugin: EncryptedSavedObjectsPluginStart;
spaceIdToNamespace: SpaceIdToNamespaceFunction;
getBasePath: GetBasePathFunction;

View file

@ -25,6 +25,9 @@ describe('loadActionTypes', () => {
id: 'test',
name: 'Test',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
},
];
http.get.mockResolvedValueOnce(resolvedValue);

View file

@ -0,0 +1,74 @@
/*
* 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';
import { actionTypeCompare } from './action_type_compare';
test('should sort enabled action types first', async () => {
const actionTypes: ActionType[] = [
{
id: '1',
minimumLicenseRequired: 'basic',
name: 'first',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
},
{
id: '2',
minimumLicenseRequired: 'gold',
name: 'second',
enabled: false,
enabledInConfig: true,
enabledInLicense: false,
},
{
id: '3',
minimumLicenseRequired: 'basic',
name: 'third',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
},
];
const result = [...actionTypes].sort(actionTypeCompare);
expect(result[0]).toEqual(actionTypes[0]);
expect(result[1]).toEqual(actionTypes[2]);
expect(result[2]).toEqual(actionTypes[1]);
});
test('should sort by name when all enabled', async () => {
const actionTypes: ActionType[] = [
{
id: '1',
minimumLicenseRequired: 'basic',
name: 'third',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
},
{
id: '2',
minimumLicenseRequired: 'basic',
name: 'first',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
},
{
id: '3',
minimumLicenseRequired: 'basic',
name: 'second',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
},
];
const result = [...actionTypes].sort(actionTypeCompare);
expect(result[0]).toEqual(actionTypes[1]);
expect(result[1]).toEqual(actionTypes[2]);
expect(result[2]).toEqual(actionTypes[0]);
});

View file

@ -0,0 +1,17 @@
/*
* 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 actionTypeCompare(a: ActionType, b: ActionType) {
if (a.enabled === true && b.enabled === false) {
return -1;
}
if (a.enabled === false && b.enabled === true) {
return 1;
}
return a.name.localeCompare(b.name);
}

View file

@ -0,0 +1,9 @@
.actCheckActionTypeEnabled__disabledActionWarningCard {
background-color: $euiColorLightestShade;
}
.actAccordionActionForm {
.euiCard {
box-shadow: none;
}
}

View file

@ -0,0 +1,88 @@
/*
* 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';
import { checkActionTypeEnabled } from './check_action_type_enabled';
test(`returns isEnabled:true when action type isn't provided`, async () => {
expect(checkActionTypeEnabled()).toMatchInlineSnapshot(`
Object {
"isEnabled": true,
}
`);
});
test('returns isEnabled:true when action type is enabled', async () => {
const actionType: ActionType = {
id: '1',
minimumLicenseRequired: 'basic',
name: 'my action',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
};
expect(checkActionTypeEnabled(actionType)).toMatchInlineSnapshot(`
Object {
"isEnabled": true,
}
`);
});
test('returns isEnabled:false when action type is disabled by license', async () => {
const actionType: ActionType = {
id: '1',
minimumLicenseRequired: 'basic',
name: 'my action',
enabled: false,
enabledInConfig: true,
enabledInLicense: false,
};
expect(checkActionTypeEnabled(actionType)).toMatchInlineSnapshot(`
Object {
"isEnabled": false,
"message": "This connector is disabled because it requires a basic license.",
"messageCard": <EuiCard
className="actCheckActionTypeEnabled__disabledActionWarningCard"
description="To re-enable this action, please upgrade your license."
title="This feature requires a basic license."
titleSize="xs"
>
<ForwardRef
href="https://www.elastic.co/subscriptions"
target="_blank"
>
<FormattedMessage
defaultMessage="View license options"
id="xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByLicenseLinkTitle"
values={Object {}}
/>
</ForwardRef>
</EuiCard>,
}
`);
});
test('returns isEnabled:false when action type is disabled by config', async () => {
const actionType: ActionType = {
id: '1',
minimumLicenseRequired: 'basic',
name: 'my action',
enabled: false,
enabledInConfig: false,
enabledInLicense: true,
};
expect(checkActionTypeEnabled(actionType)).toMatchInlineSnapshot(`
Object {
"isEnabled": false,
"message": "This connector is disabled by the Kibana configuration.",
"messageCard": <EuiCard
className="actCheckActionTypeEnabled__disabledActionWarningCard"
description=""
title="This feature is disabled by the Kibana configuration."
/>,
}
`);
});

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 React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiCard, EuiLink } from '@elastic/eui';
import { ActionType } from '../../types';
import { VIEW_LICENSE_OPTIONS_LINK } from '../../common/constants';
import './check_action_type_enabled.scss';
export interface IsEnabledResult {
isEnabled: true;
}
export interface IsDisabledResult {
isEnabled: false;
message: string;
messageCard: JSX.Element;
}
export function checkActionTypeEnabled(
actionType?: ActionType
): IsEnabledResult | IsDisabledResult {
if (actionType?.enabledInLicense === false) {
return {
isEnabled: false,
message: i18n.translate(
'xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByLicenseMessage',
{
defaultMessage:
'This connector is disabled because it requires a {minimumLicenseRequired} license.',
values: {
minimumLicenseRequired: actionType.minimumLicenseRequired,
},
}
),
messageCard: (
<EuiCard
titleSize="xs"
title={i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByLicenseMessageTitle',
{
defaultMessage: 'This feature requires a {minimumLicenseRequired} license.',
values: {
minimumLicenseRequired: actionType.minimumLicenseRequired,
},
}
)}
// The "re-enable" terminology is used here because this message is used when an alert
// action was previously enabled and needs action to be re-enabled.
description={i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByLicenseMessageDescription',
{ defaultMessage: 'To re-enable this action, please upgrade your license.' }
)}
className="actCheckActionTypeEnabled__disabledActionWarningCard"
children={
<EuiLink href={VIEW_LICENSE_OPTIONS_LINK} target="_blank">
<FormattedMessage
defaultMessage="View license options"
id="xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByLicenseLinkTitle"
/>
</EuiLink>
}
/>
),
};
}
if (actionType?.enabledInConfig === false) {
return {
isEnabled: false,
message: i18n.translate(
'xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByConfigMessage',
{ defaultMessage: 'This connector is disabled by the Kibana configuration.' }
),
messageCard: (
<EuiCard
title={i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByConfigMessageTitle',
{ defaultMessage: 'This feature is disabled by the Kibana configuration.' }
)}
description=""
className="actCheckActionTypeEnabled__disabledActionWarningCard"
/>
),
};
}
return { isEnabled: true };
}

View file

@ -39,6 +39,36 @@ describe('action_form', () => {
actionParamsFields: null,
};
const disabledByConfigActionType = {
id: 'disabled-by-config',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
},
validateParams: (): ValidationResult => {
const validationResult = { errors: {} };
return validationResult;
},
actionConnectorFields: null,
actionParamsFields: null,
};
const disabledByLicenseActionType = {
id: 'disabled-by-license',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
},
validateParams: (): ValidationResult => {
const validationResult = { errors: {} };
return validationResult;
},
actionConnectorFields: null,
actionParamsFields: null,
};
describe('action_form in alert', () => {
let wrapper: ReactWrapper<any>;
@ -49,7 +79,11 @@ describe('action_form', () => {
http: mockes.http,
actionTypeRegistry: actionTypeRegistry as any,
};
actionTypeRegistry.list.mockReturnValue([actionType]);
actionTypeRegistry.list.mockReturnValue([
actionType,
disabledByConfigActionType,
disabledByLicenseActionType,
]);
actionTypeRegistry.has.mockReturnValue(true);
const initialAlert = ({
@ -92,8 +126,38 @@ describe('action_form', () => {
actionTypeRegistry={deps!.actionTypeRegistry}
defaultActionMessage={'Alert [{{ctx.metadata.name}}] has exceeded the threshold'}
actionTypes={[
{ id: actionType.id, name: 'Test', enabled: true },
{ id: '.index', name: 'Index', enabled: true },
{
id: actionType.id,
name: 'Test',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
},
{
id: '.index',
name: 'Index',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
},
{
id: 'disabled-by-config',
name: 'Disabled by config',
enabled: false,
enabledInConfig: false,
enabledInLicense: true,
minimumLicenseRequired: 'gold',
},
{
id: 'disabled-by-license',
name: 'Disabled by license',
enabled: false,
enabledInConfig: true,
enabledInLicense: false,
minimumLicenseRequired: 'gold',
},
]}
toastNotifications={deps!.toastNotifications}
/>
@ -112,6 +176,32 @@ describe('action_form', () => {
`[data-test-subj="${actionType.id}-ActionTypeSelectOption"]`
);
expect(actionOption.exists()).toBeTruthy();
expect(
wrapper
.find(`EuiToolTip [data-test-subj="${actionType.id}-ActionTypeSelectOption"]`)
.exists()
).toBeFalsy();
});
it(`doesn't render action types disabled by config`, async () => {
await setup();
const actionOption = wrapper.find(
`[data-test-subj="disabled-by-config-ActionTypeSelectOption"]`
);
expect(actionOption.exists()).toBeFalsy();
});
it('renders action types disabled by license', async () => {
await setup();
const actionOption = wrapper.find(
`[data-test-subj="disabled-by-license-ActionTypeSelectOption"]`
);
expect(actionOption.exists()).toBeTruthy();
expect(
wrapper
.find('EuiToolTip [data-test-subj="disabled-by-license-ActionTypeSelectOption"]')
.exists()
).toBeTruthy();
});
});
});

View file

@ -21,6 +21,9 @@ import {
EuiButtonIcon,
EuiEmptyPrompt,
EuiButtonEmpty,
EuiToolTip,
EuiIconTip,
EuiLink,
} from '@elastic/eui';
import { HttpSetup, ToastsApi } from 'kibana/public';
import { loadActionTypes, loadAllActions } from '../../lib/action_connector_api';
@ -35,6 +38,9 @@ import {
import { SectionLoading } from '../../components/section_loading';
import { ConnectorAddModal } from './connector_add_modal';
import { TypeRegistry } from '../../type_registry';
import { actionTypeCompare } from '../../lib/action_type_compare';
import { checkActionTypeEnabled } from '../../lib/check_action_type_enabled';
import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants';
interface ActionAccordionFormProps {
actions: AlertAction[];
@ -51,6 +57,7 @@ interface ActionAccordionFormProps {
actionTypes?: ActionType[];
messageVariables?: string[];
defaultActionMessage?: string;
setHasActionsDisabled?: (value: boolean) => void;
}
interface ActiveActionConnectorState {
@ -70,6 +77,7 @@ export const ActionForm = ({
messageVariables,
defaultActionMessage,
toastNotifications,
setHasActionsDisabled,
}: ActionAccordionFormProps) => {
const [addModalVisible, setAddModalVisibility] = useState<boolean>(false);
const [activeActionItem, setActiveActionItem] = useState<ActiveActionConnectorState | undefined>(
@ -91,6 +99,10 @@ export const ActionForm = ({
index[actionTypeItem.id] = actionTypeItem;
}
setActionTypesIndex(index);
const hasActionsDisabled = actions.some(action => !index[action.actionTypeId].enabled);
if (setHasActionsDisabled) {
setHasActionsDisabled(hasActionsDisabled);
}
} catch (e) {
if (toastNotifications) {
toastNotifications.addDanger({
@ -179,60 +191,12 @@ export const ActionForm = ({
const ParamsFieldsComponent = actionTypeRegistered.actionParamsFields;
const actionParamsErrors: { errors: IErrorObject } =
Object.keys(actionsErrors).length > 0 ? actionsErrors[actionItem.id] : { errors: {} };
const checkEnabledResult = checkActionTypeEnabled(
actionTypesIndex && actionTypesIndex[actionConnector.actionTypeId]
);
return (
<EuiAccordion
initialIsOpen={true}
key={index}
id={index.toString()}
className="euiAccordionForm"
buttonContentClassName="euiAccordionForm__button"
data-test-subj={`alertActionAccordion-${defaultActionGroupId}`}
buttonContent={
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type={actionTypeRegistered.iconClass} size="m" />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="s">
<h5>
<FormattedMessage
defaultMessage="Action: {actionConnectorName}"
id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle"
values={{
actionConnectorName: actionConnector.name,
}}
/>
</h5>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
}
extraAction={
<EuiButtonIcon
iconType="cross"
color="danger"
className="euiAccordionForm__extraAction"
aria-label={i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.accordion.deleteIconAriaLabel',
{
defaultMessage: 'Delete',
}
)}
onClick={() => {
const updatedActions = actions.filter(
(item: AlertAction) => item.id !== actionItem.id
);
setAlertProperty(updatedActions);
setIsAddActionPanelOpen(
updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === 0
);
setActiveActionItem(undefined);
}}
/>
}
paddingSize="l"
>
const accordionContent = checkEnabledResult.isEnabled ? (
<Fragment>
<EuiFlexGroup component="div">
<EuiFlexItem>
<EuiFormRow
@ -287,6 +251,86 @@ export const ActionForm = ({
defaultMessage={defaultActionMessage ?? undefined}
/>
) : null}
</Fragment>
) : (
checkEnabledResult.messageCard
);
return (
<EuiAccordion
initialIsOpen={true}
key={index}
id={index.toString()}
className="actAccordionActionForm"
buttonContentClassName="actAccordionActionForm__button"
data-test-subj={`alertActionAccordion-${defaultActionGroupId}`}
buttonContent={
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type={actionTypeRegistered.iconClass} size="m" />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle size="s">
<h5>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<FormattedMessage
defaultMessage="Action: {actionConnectorName}"
id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle"
values={{
actionConnectorName: actionConnector.name,
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{checkEnabledResult.isEnabled === false && (
<Fragment>
<EuiIconTip
type="alert"
color="danger"
content={i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.actionDisabledTitle',
{
defaultMessage: 'This action is disabled',
}
)}
position="right"
/>
</Fragment>
)}
</EuiFlexItem>
</EuiFlexGroup>
</h5>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
}
extraAction={
<EuiButtonIcon
iconType="cross"
color="danger"
className="actAccordionActionForm__extraAction"
aria-label={i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.accordion.deleteIconAriaLabel',
{
defaultMessage: 'Delete',
}
)}
onClick={() => {
const updatedActions = actions.filter(
(item: AlertAction) => item.id !== actionItem.id
);
setAlertProperty(updatedActions);
setIsAddActionPanelOpen(
updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === 0
);
setActiveActionItem(undefined);
}}
/>
}
paddingSize="l"
>
{accordionContent}
</EuiAccordion>
);
};
@ -302,8 +346,8 @@ export const ActionForm = ({
initialIsOpen={true}
key={index}
id={index.toString()}
className="euiAccordionForm"
buttonContentClassName="euiAccordionForm__button"
className="actAccordionActionForm"
buttonContentClassName="actAccordionActionForm__button"
data-test-subj={`alertActionAccordion-${defaultActionGroupId}`}
buttonContent={
<EuiFlexGroup gutterSize="s" alignItems="center">
@ -329,7 +373,7 @@ export const ActionForm = ({
<EuiButtonIcon
iconType="cross"
color="danger"
className="euiAccordionForm__extraAction"
className="actAccordionActionForm__extraAction"
aria-label={i18n.translate(
'xpack.triggersActionsUI.sections.alertForm.accordion.deleteIconAriaLabel',
{
@ -427,20 +471,46 @@ export const ActionForm = ({
}
}
const actionTypeNodes = actionTypesIndex
? actionTypeRegistry.list().map(function(item, index) {
return actionTypesIndex[item.id] ? (
let actionTypeNodes: JSX.Element[] | null = null;
let hasDisabledByLicenseActionTypes = false;
if (actionTypesIndex) {
actionTypeNodes = actionTypeRegistry
.list()
.filter(
item => actionTypesIndex[item.id] && actionTypesIndex[item.id].enabledInConfig === true
)
.sort((a, b) => actionTypeCompare(actionTypesIndex[a.id], actionTypesIndex[b.id]))
.map(function(item, index) {
const actionType = actionTypesIndex[item.id];
const checkEnabledResult = checkActionTypeEnabled(actionTypesIndex[item.id]);
if (!actionType.enabledInLicense) {
hasDisabledByLicenseActionTypes = true;
}
const keyPadItem = (
<EuiKeyPadMenuItem
key={index}
isDisabled={!checkEnabledResult.isEnabled}
data-test-subj={`${item.id}-ActionTypeSelectOption`}
label={actionTypesIndex[item.id].name}
onClick={() => addActionType(item)}
>
<EuiIcon size="xl" type={item.iconClass} />
</EuiKeyPadMenuItem>
) : null;
})
: null;
);
return (
<Fragment key={`keypad-${item.id}`}>
{checkEnabledResult.isEnabled && keyPadItem}
{checkEnabledResult.isEnabled === false && (
<EuiToolTip position="top" content={checkEnabledResult.message}>
{keyPadItem}
</EuiToolTip>
)}
</Fragment>
);
});
}
return (
<Fragment>
@ -467,14 +537,36 @@ export const ActionForm = ({
) : null}
{isAddActionPanelOpen ? (
<Fragment>
<EuiTitle size="xs">
<h5 id="alertActionTypeTitle">
<FormattedMessage
defaultMessage="Actions: Select an action type"
id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeTitle"
/>
</h5>
</EuiTitle>
<EuiFlexGroup id="alertActionTypeTitle" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h5>
<FormattedMessage
defaultMessage="Actions: Select an action type"
id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeTitle"
/>
</h5>
</EuiTitle>
</EuiFlexItem>
{hasDisabledByLicenseActionTypes && (
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h5>
<EuiLink
href={VIEW_LICENSE_OPTIONS_LINK}
target="_blank"
className="actActionForm__getMoreActionsLink"
>
<FormattedMessage
defaultMessage="Get more actions"
id="xpack.triggersActionsUI.sections.actionForm.getMoreActionsTitle"
/>
</EuiLink>
</h5>
</EuiTitle>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer />
<EuiFlexGroup gutterSize="s" wrap>
{isLoadingActionTypes ? (

View file

@ -77,6 +77,9 @@ describe('connector_add_flyout', () => {
id: actionType.id,
enabled: true,
name: 'Test',
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
},
]}
/>
@ -85,4 +88,107 @@ describe('connector_add_flyout', () => {
expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeTruthy();
});
it(`doesn't renders action types that are disabled via config`, () => {
const onActionTypeChange = jest.fn();
const actionType = {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
},
validateParams: (): ValidationResult => {
const validationResult = { errors: {} };
return validationResult;
},
actionConnectorFields: null,
actionParamsFields: null,
};
actionTypeRegistry.get.mockReturnValueOnce(actionType);
const wrapper = mountWithIntl(
<ActionsConnectorsContextProvider
value={{
http: deps!.http,
actionTypeRegistry: deps!.actionTypeRegistry,
capabilities: deps!.capabilities,
toastNotifications: deps!.toastNotifications,
reloadConnectors: () => {
return new Promise<void>(() => {});
},
}}
>
<ActionTypeMenu
onActionTypeChange={onActionTypeChange}
actionTypes={[
{
id: actionType.id,
enabled: false,
name: 'Test',
enabledInConfig: false,
enabledInLicense: true,
minimumLicenseRequired: 'gold',
},
]}
/>
</ActionsConnectorsContextProvider>
);
expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeFalsy();
});
it(`renders action types as disabled when disabled by license`, () => {
const onActionTypeChange = jest.fn();
const actionType = {
id: 'my-action-type',
iconClass: 'test',
selectMessage: 'test',
validateConnector: (): ValidationResult => {
return { errors: {} };
},
validateParams: (): ValidationResult => {
const validationResult = { errors: {} };
return validationResult;
},
actionConnectorFields: null,
actionParamsFields: null,
};
actionTypeRegistry.get.mockReturnValueOnce(actionType);
const wrapper = mountWithIntl(
<ActionsConnectorsContextProvider
value={{
http: deps!.http,
actionTypeRegistry: deps!.actionTypeRegistry,
capabilities: deps!.capabilities,
toastNotifications: deps!.toastNotifications,
reloadConnectors: () => {
return new Promise<void>(() => {});
},
}}
>
<ActionTypeMenu
onActionTypeChange={onActionTypeChange}
actionTypes={[
{
id: actionType.id,
enabled: false,
name: 'Test',
enabledInConfig: true,
enabledInLicense: false,
minimumLicenseRequired: 'gold',
},
]}
/>
</ActionsConnectorsContextProvider>
);
const element = wrapper.find('[data-test-subj="my-action-type-card"]');
expect(element.exists()).toBeTruthy();
expect(element.first().prop('betaBadgeLabel')).toEqual('Upgrade');
expect(element.first().prop('betaBadgeTooltipContent')).toEqual(
'This connector is disabled because it requires a gold license.'
);
});
});

View file

@ -4,18 +4,25 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useState } from 'react';
import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid } from '@elastic/eui';
import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ActionType, ActionTypeIndex } from '../../../types';
import { loadActionTypes } from '../../lib/action_connector_api';
import { useActionsConnectorsContext } from '../../context/actions_connectors_context';
import { actionTypeCompare } from '../../lib/action_type_compare';
import { checkActionTypeEnabled } from '../../lib/check_action_type_enabled';
interface Props {
onActionTypeChange: (actionType: ActionType) => void;
actionTypes?: ActionType[];
setHasActionsDisabledByLicense?: (value: boolean) => void;
}
export const ActionTypeMenu = ({ onActionTypeChange, actionTypes }: Props) => {
export const ActionTypeMenu = ({
onActionTypeChange,
actionTypes,
setHasActionsDisabledByLicense,
}: Props) => {
const { http, toastNotifications, actionTypeRegistry } = useActionsConnectorsContext();
const [actionTypesIndex, setActionTypesIndex] = useState<ActionTypeIndex | undefined>(undefined);
@ -28,6 +35,12 @@ export const ActionTypeMenu = ({ onActionTypeChange, actionTypes }: Props) => {
index[actionTypeItem.id] = actionTypeItem;
}
setActionTypesIndex(index);
if (setHasActionsDisabledByLicense) {
const hasActionsDisabledByLicense = availableActionTypes.some(
action => !index[action.id].enabledInLicense
);
setHasActionsDisabledByLicense(hasActionsDisabledByLicense);
}
} catch (e) {
if (toastNotifications) {
toastNotifications.addDanger({
@ -43,33 +56,54 @@ export const ActionTypeMenu = ({ onActionTypeChange, actionTypes }: Props) => {
}, []);
const registeredActionTypes = Object.entries(actionTypesIndex ?? [])
.filter(([index]) => actionTypeRegistry.has(index))
.map(([index, actionType]) => {
const actionTypeModel = actionTypeRegistry.get(index);
.filter(([id, details]) => actionTypeRegistry.has(id) && details.enabledInConfig === true)
.map(([id, actionType]) => {
const actionTypeModel = actionTypeRegistry.get(id);
return {
iconClass: actionTypeModel ? actionTypeModel.iconClass : '',
selectMessage: actionTypeModel ? actionTypeModel.selectMessage : '',
actionType,
name: actionType.name,
typeName: index.replace('.', ''),
typeName: id.replace('.', ''),
};
});
const cardNodes = registeredActionTypes
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) => actionTypeCompare(a.actionType, b.actionType))
.map((item, index) => {
return (
<EuiFlexItem key={index}>
<EuiCard
data-test-subj={`${item.actionType.id}-card`}
icon={<EuiIcon size="xl" type={item.iconClass} />}
title={item.name}
description={item.selectMessage}
onClick={() => onActionTypeChange(item.actionType)}
/>
</EuiFlexItem>
const checkEnabledResult = checkActionTypeEnabled(item.actionType);
const card = (
<EuiCard
titleSize="xs"
data-test-subj={`${item.actionType.id}-card`}
icon={<EuiIcon size="l" type={item.iconClass} />}
title={item.name}
description={item.selectMessage}
isDisabled={!checkEnabledResult.isEnabled}
onClick={() => onActionTypeChange(item.actionType)}
betaBadgeLabel={
checkEnabledResult.isEnabled
? undefined
: i18n.translate(
'xpack.triggersActionsUI.sections.actionsConnectorsList.upgradeBadge',
{ defaultMessage: 'Upgrade' }
)
}
betaBadgeTooltipContent={
checkEnabledResult.isEnabled ? undefined : checkEnabledResult.message
}
/>
);
return <EuiFlexItem key={index}>{card}</EuiFlexItem>;
});
return <EuiFlexGrid columns={2}>{cardNodes}</EuiFlexGrid>;
return (
<div className="actConnectorsListGrid">
<EuiSpacer size="s" />
<EuiFlexGrid gutterSize="xl" columns={3}>
{cardNodes}
</EuiFlexGrid>
</div>
);
};

View file

@ -79,6 +79,9 @@ describe('connector_add_flyout', () => {
id: actionType.id,
enabled: true,
name: 'Test',
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
},
]}
/>

View file

@ -18,6 +18,9 @@ import {
EuiButton,
EuiFlyoutBody,
EuiBetaBadge,
EuiCallOut,
EuiLink,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ActionTypeMenu } from './action_type_menu';
@ -27,6 +30,7 @@ import { connectorReducer } from './connector_reducer';
import { hasSaveActionsCapability } from '../../lib/capabilities';
import { createActionConnector } from '../../lib/action_connector_api';
import { useActionsConnectorsContext } from '../../context/actions_connectors_context';
import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants';
export interface ConnectorAddFlyoutProps {
addFlyoutVisible: boolean;
@ -48,6 +52,7 @@ export const ConnectorAddFlyout = ({
reloadConnectors,
} = useActionsConnectorsContext();
const [actionType, setActionType] = useState<ActionType | undefined>(undefined);
const [hasActionsDisabledByLicense, setHasActionsDisabledByLicense] = useState<boolean>(false);
// hooks
const initialConnector = {
@ -86,7 +91,11 @@ export const ConnectorAddFlyout = ({
let actionTypeModel;
if (!actionType) {
currentForm = (
<ActionTypeMenu onActionTypeChange={onActionTypeChange} actionTypes={actionTypes} />
<ActionTypeMenu
onActionTypeChange={onActionTypeChange}
actionTypes={actionTypes}
setHasActionsDisabledByLicense={setHasActionsDisabledByLicense}
/>
);
} else {
actionTypeModel = actionTypeRegistry.get(actionType.id);
@ -204,7 +213,11 @@ export const ConnectorAddFlyout = ({
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody>{currentForm}</EuiFlyoutBody>
<EuiFlyoutBody
banner={!actionType && hasActionsDisabledByLicense && upgradeYourLicenseCallOut}
>
{currentForm}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
@ -252,3 +265,24 @@ export const ConnectorAddFlyout = ({
</EuiFlyout>
);
};
const upgradeYourLicenseCallOut = (
<EuiCallOut
title={i18n.translate(
'xpack.triggersActionsUI.sections.actionConnectorAdd.upgradeYourPlanBannerTitle',
{ defaultMessage: 'Upgrade your plan to access more connector types' }
)}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionConnectorAdd.upgradeYourPlanBannerMessage"
defaultMessage="With an upgraded license, you have the option to connect to more 3rd party services."
/>
<EuiSpacer size="xs" />
<EuiLink href={VIEW_LICENSE_OPTIONS_LINK} target="_blank">
<FormattedMessage
id="xpack.triggersActionsUI.sections.actionConnectorAdd.upgradeYourPlanBannerLinkTitle"
defaultMessage="Upgrade now"
/>
</EuiLink>
</EuiCallOut>
);

View file

@ -8,7 +8,7 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { ConnectorAddModal } from './connector_add_modal';
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
import { ValidationResult } from '../../../types';
import { ValidationResult, ActionType } from '../../../types';
import { ActionsConnectorsContextValue } from '../../context/actions_connectors_context';
const actionTypeRegistry = actionTypeRegistryMock.create();
@ -54,10 +54,13 @@ describe('connector_add_modal', () => {
actionTypeRegistry.get.mockReturnValueOnce(actionTypeModel);
actionTypeRegistry.has.mockReturnValue(true);
const actionType = {
const actionType: ActionType = {
id: 'my-action-type',
name: 'test',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
};
const wrapper = deps

View file

@ -1,3 +1,15 @@
.actConnectorsList__logo + .actConnectorsList__logo {
margin-left: $euiSize;
}
.actConnectorsList__tableRowDisabled {
background-color: $euiColorLightestShade;
.actConnectorsList__tableCellDisabled {
color: $euiColorDarkShade;
}
.euiLink + .euiIcon {
margin-left: $euiSizeXS;
}
}

View file

@ -136,10 +136,12 @@ describe('actions_connectors_list component with items', () => {
{
id: 'test',
name: 'Test',
enabled: true,
},
{
id: 'test2',
name: 'Test2',
enabled: true,
},
]);
@ -375,6 +377,117 @@ describe('actions_connectors_list with show only capability', () => {
});
});
describe('actions_connectors_list component with disabled items', () => {
let wrapper: ReactWrapper<any>;
beforeAll(async () => {
const { loadAllActions, loadActionTypes } = jest.requireMock(
'../../../lib/action_connector_api'
);
loadAllActions.mockResolvedValueOnce({
page: 1,
perPage: 10000,
total: 2,
data: [
{
id: '1',
actionTypeId: 'test',
description: 'My test',
referencedByCount: 1,
config: {},
},
{
id: '2',
actionTypeId: 'test2',
description: 'My test 2',
referencedByCount: 1,
config: {},
},
],
});
loadActionTypes.mockResolvedValueOnce([
{
id: 'test',
name: 'Test',
enabled: false,
enabledInConfig: false,
enabledInLicense: true,
},
{
id: 'test2',
name: 'Test2',
enabled: false,
enabledInConfig: true,
enabledInLicense: false,
},
]);
const mockes = coreMock.createSetup();
const [
{
chrome,
docLinks,
application: { capabilities, navigateToApp },
},
] = await mockes.getStartServices();
const deps = {
chrome,
docLinks,
dataPlugin: dataPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
toastNotifications: mockes.notifications.toasts,
injectedMetadata: mockes.injectedMetadata,
http: mockes.http,
uiSettings: mockes.uiSettings,
navigateToApp,
capabilities: {
...capabilities,
siem: {
'actions:show': true,
'actions:save': true,
'actions:delete': true,
},
},
setBreadcrumbs: jest.fn(),
actionTypeRegistry: {
get() {
return null;
},
} as any,
alertTypeRegistry: {} as any,
};
await act(async () => {
wrapper = mountWithIntl(
<AppContextProvider appDeps={deps}>
<ActionsConnectorsList />
</AppContextProvider>
);
});
await waitForRender(wrapper);
expect(loadAllActions).toHaveBeenCalled();
});
it('renders table of connectors', () => {
expect(wrapper.find('EuiInMemoryTable')).toHaveLength(1);
expect(wrapper.find('EuiTableRow')).toHaveLength(2);
expect(
wrapper
.find('EuiTableRow')
.at(0)
.prop('className')
).toEqual('actConnectorsList__tableRowDisabled');
expect(
wrapper
.find('EuiTableRow')
.at(1)
.prop('className')
).toEqual('actConnectorsList__tableRowDisabled');
});
});
async function waitForRender(wrapper: ReactWrapper<any, any>) {
await Promise.resolve();
await Promise.resolve();

View file

@ -15,17 +15,19 @@ import {
EuiTitle,
EuiLink,
EuiLoadingSpinner,
EuiIconTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { useAppDependencies } from '../../../app_context';
import { loadAllActions, loadActionTypes } from '../../../lib/action_connector_api';
import { ActionConnector, ActionConnectorTableItem, ActionTypeIndex } from '../../../../types';
import { ConnectorAddFlyout, ConnectorEditFlyout } from '../../action_connector_form';
import { hasDeleteActionsCapability, hasSaveActionsCapability } from '../../../lib/capabilities';
import { DeleteConnectorsModal } from '../../../components/delete_connectors_modal';
import { ActionsConnectorsContextProvider } from '../../../context/actions_connectors_context';
import { checkActionTypeEnabled } from '../../../lib/check_action_type_enabled';
import './actions_connectors_list.scss';
import { ActionConnector, ActionConnectorTableItem, ActionTypeIndex } from '../../../../types';
export const ActionsConnectorsList: React.FunctionComponent = () => {
const { http, toastNotifications, capabilities, actionTypeRegistry } = useAppDependencies();
@ -139,11 +141,33 @@ export const ActionsConnectorsList: React.FunctionComponent = () => {
sortable: false,
truncateText: true,
render: (value: string, item: ActionConnectorTableItem) => {
return (
<EuiLink data-test-subj={`edit${item.id}`} onClick={() => editItem(item)} key={item.id}>
const checkEnabledResult = checkActionTypeEnabled(
actionTypesIndex && actionTypesIndex[item.actionTypeId]
);
const link = (
<EuiLink
data-test-subj={`edit${item.id}`}
onClick={() => editItem(item)}
key={item.id}
disabled={actionTypesIndex ? !actionTypesIndex[item.actionTypeId].enabled : true}
>
{value}
</EuiLink>
);
return checkEnabledResult.isEnabled ? (
link
) : (
<Fragment>
{link}
<EuiIconTip
type="questionInCircle"
content={checkEnabledResult.message}
position="right"
/>
</Fragment>
);
},
},
{
@ -211,11 +235,19 @@ export const ActionsConnectorsList: React.FunctionComponent = () => {
sorting={true}
itemId="id"
columns={actionsTableColumns}
rowProps={() => ({
rowProps={(item: ActionConnectorTableItem) => ({
className:
!actionTypesIndex || !actionTypesIndex[item.actionTypeId].enabled
? 'actConnectorsList__tableRowDisabled'
: '',
'data-test-subj': 'connectors-row',
})}
cellProps={() => ({
cellProps={(item: ActionConnectorTableItem) => ({
'data-test-subj': 'cell',
className:
!actionTypesIndex || !actionTypesIndex[item.actionTypeId].enabled
? 'actConnectorsList__tableCellDisabled'
: '',
})}
data-test-subj="actionsTable"
pagination={true}

View file

@ -124,6 +124,9 @@ describe('alert_details', () => {
id: '.server-log',
name: 'Server log',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
},
];
@ -173,11 +176,17 @@ describe('alert_details', () => {
id: '.server-log',
name: 'Server log',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
},
{
id: '.email',
name: 'Send email',
enabled: true,
enabledInConfig: true,
enabledInLicense: true,
minimumLicenseRequired: 'basic',
},
];

View file

@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback, useReducer, useState } from 'react';
import React, { Fragment, useCallback, useReducer, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiTitle,
@ -17,6 +17,8 @@ import {
EuiFlyoutBody,
EuiPortal,
EuiBetaBadge,
EuiCallOut,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useAlertsContext } from '../../context/alerts_context';
@ -38,6 +40,7 @@ export const AlertEdit = ({
}: AlertEditProps) => {
const [{ alert }, dispatch] = useReducer(alertReducer, { alert: initialAlert });
const [isSaving, setIsSaving] = useState<boolean>(false);
const [hasActionsDisabled, setHasActionsDisabled] = useState<boolean>(false);
const {
reloadAlerts,
@ -141,7 +144,27 @@ export const AlertEdit = ({
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<AlertForm alert={alert} dispatch={dispatch} errors={errors} canChangeTrigger={false} />
{hasActionsDisabled && (
<Fragment>
<EuiCallOut
size="s"
color="danger"
iconType="alert"
title={i18n.translate(
'xpack.triggersActionsUI.sections.alertEdit.disabledActionsWarningTitle',
{ defaultMessage: 'This alert has actions that are disabled' }
)}
/>
<EuiSpacer />
</Fragment>
)}
<AlertForm
alert={alert}
dispatch={dispatch}
errors={errors}
canChangeTrigger={false}
setHasActionsDisabled={setHasActionsDisabled}
/>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">

View file

@ -74,9 +74,16 @@ interface AlertFormProps {
dispatch: React.Dispatch<AlertReducerAction>;
errors: IErrorObject;
canChangeTrigger?: boolean; // to hide Change trigger button
setHasActionsDisabled?: (value: boolean) => void;
}
export const AlertForm = ({ alert, canChangeTrigger = true, dispatch, errors }: AlertFormProps) => {
export const AlertForm = ({
alert,
canChangeTrigger = true,
dispatch,
errors,
setHasActionsDisabled,
}: AlertFormProps) => {
const alertsContext = useAlertsContext();
const { http, toastNotifications, alertTypeRegistry, actionTypeRegistry } = alertsContext;
@ -218,6 +225,7 @@ export const AlertForm = ({ alert, canChangeTrigger = true, dispatch, errors }:
{defaultActionGroupId ? (
<ActionForm
actions={alert.actions}
setHasActionsDisabled={setHasActionsDisabled}
messageVariables={
alertTypesIndex && alertTypesIndex[alert.alertTypeId]
? actionVariablesFromAlertType(alertTypesIndex[alert.alertTypeId]).map(av => av.name)

View file

@ -216,31 +216,25 @@ export const AlertsList: React.FunctionComponent = () => {
'data-test-subj': 'alertsTableCell-interval',
},
{
field: '',
name: '',
width: '50px',
actions: canSave
? [
{
render: (item: AlertTableItem) => {
return alertTypeRegistry.has(item.alertTypeId) ? (
<EuiLink
data-test-subj="alertsTableCell-editLink"
color="primary"
onClick={() => editItem(item)}
>
<FormattedMessage
defaultMessage="Edit"
id="xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.editLinkTitle"
/>
</EuiLink>
) : (
<></>
);
},
},
]
: [],
render(item: AlertTableItem) {
if (!canSave || !alertTypeRegistry.has(item.alertTypeId)) {
return;
}
return (
<EuiLink
data-test-subj="alertsTableCell-editLink"
color="primary"
onClick={() => editItem(item)}
>
<FormattedMessage
defaultMessage="Edit"
id="xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.editLinkTitle"
/>
</EuiLink>
);
},
},
{
name: '',

View file

@ -7,3 +7,5 @@
export { COMPARATORS, builtInComparators } from './comparators';
export { AGGREGATION_TYPES, builtInAggregationTypes } from './aggregation_types';
export { builtInGroupByTypes } from './group_by_types';
export const VIEW_LICENSE_OPTIONS_LINK = 'https://www.elastic.co/subscriptions';

View file

@ -11,7 +11,7 @@ export { AlertsContextProvider } from './application/context/alerts_context';
export { ActionsConnectorsContextProvider } from './application/context/actions_connectors_context';
export { AlertAdd } from './application/sections/alert_form';
export { ActionForm } from './application/sections/action_connector_form';
export { AlertAction, Alert, AlertTypeModel } from './types';
export { AlertAction, Alert, AlertTypeModel, ActionType } from './types';
export {
ConnectorAddFlyout,
ConnectorEditFlyout,

View file

@ -13,6 +13,7 @@ const onlyNotInCoverageTests = [
require.resolve('../test/functional/config_security_basic.js'),
require.resolve('../test/api_integration/config_security_basic.js'),
require.resolve('../test/api_integration/config.js'),
require.resolve('../test/alerting_api_integration/basic/config.ts'),
require.resolve('../test/alerting_api_integration/spaces_only/config.ts'),
require.resolve('../test/alerting_api_integration/security_and_spaces/config.ts'),
require.resolve('../test/detection_engine_api_integration/security_and_spaces/config.ts'),

View file

@ -0,0 +1,14 @@
/*
* 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 { createTestConfig } from '../common/config';
// eslint-disable-next-line import/no-default-export
export default createTestConfig('basic', {
disabledPlugins: [],
license: 'basic',
ssl: true,
});

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 { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function emailTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('create email action', () => {
it('should return 403 when creating an email action', async () => {
await supertest
.post('/api/action')
.set('kbn-xsrf', 'foo')
.send({
name: 'An email action',
actionTypeId: '.email',
config: {
service: '__json',
from: 'bob@example.com',
},
secrets: {
user: 'bob',
password: 'supersecret',
},
})
.expect(403, {
statusCode: 403,
error: 'Forbidden',
message:
'Action type .email is disabled because your basic license does not support it. Please upgrade your license.',
});
});
});
}

View file

@ -0,0 +1,30 @@
/*
* 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 { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function indexTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('index action', () => {
it('should return 200 when creating an index action', async () => {
// create action with no config
await supertest
.post('/api/action')
.set('kbn-xsrf', 'foo')
.send({
name: 'An index action',
actionTypeId: '.index',
config: {
index: 'foo',
},
secrets: {},
})
.expect(200);
});
});
}

View file

@ -0,0 +1,36 @@
/*
* 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 { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function pagerdutyTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('pagerduty action', () => {
it('should return 403 when creating a pagerduty action', async () => {
await supertest
.post('/api/action')
.set('kbn-xsrf', 'foo')
.send({
name: 'A pagerduty action',
actionTypeId: '.pagerduty',
config: {
apiUrl: 'http://localhost',
},
secrets: {
routingKey: 'pager-duty-routing-key',
},
})
.expect(403, {
statusCode: 403,
error: 'Forbidden',
message:
'Action type .pagerduty is disabled because your basic license does not support it. Please upgrade your license.',
});
});
});
}

View file

@ -0,0 +1,28 @@
/*
* 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 { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function serverLogTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('server-log action', () => {
after(() => esArchiver.unload('empty_kibana'));
it('should return 200 when creating a server-log action', async () => {
await supertest
.post('/api/action')
.set('kbn-xsrf', 'foo')
.send({
name: 'A server.log action',
actionTypeId: '.server-log',
})
.expect(200);
});
});
}

View file

@ -0,0 +1,83 @@
/*
* 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 { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
getExternalServiceSimulatorPath,
ExternalServiceSimulator,
} from '../../../../common/fixtures/plugins/actions';
// node ../scripts/functional_test_runner.js --grep "Actions.servicenddd" --config=test/alerting_api_integration/security_and_spaces/config.ts
const mapping = [
{
source: 'title',
target: 'description',
actionType: 'nothing',
},
{
source: 'description',
target: 'short_description',
actionType: 'nothing',
},
{
source: 'comments',
target: 'comments',
actionType: 'nothing',
},
];
// eslint-disable-next-line import/no-default-export
export default function servicenowTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
const mockServiceNow = {
config: {
apiUrl: 'www.servicenowisinkibanaactions.com',
casesConfiguration: { mapping: [...mapping] },
},
secrets: {
password: 'elastic',
username: 'changeme',
},
params: {
comments: 'hello cool service now incident',
short_description: 'this is a cool service now incident',
},
};
describe('servicenow', () => {
let servicenowSimulatorURL: string = '<could not determine kibana url>';
// need to wait for kibanaServer to settle ...
before(() => {
servicenowSimulatorURL = kibanaServer.resolveUrl(
getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW)
);
});
it('should return 403 when creating a servicenow action', async () => {
await supertest
.post('/api/action')
.set('kbn-xsrf', 'foo')
.send({
name: 'A servicenow action',
actionTypeId: '.servicenow',
config: {
apiUrl: servicenowSimulatorURL,
casesConfiguration: { ...mockServiceNow.config.casesConfiguration },
},
secrets: mockServiceNow.secrets,
})
.expect(403, {
statusCode: 403,
error: 'Forbidden',
message:
'Action type .servicenow is disabled because your basic license does not support it. Please upgrade your license.',
});
});
});
}

View file

@ -0,0 +1,48 @@
/*
* 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 { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
getExternalServiceSimulatorPath,
ExternalServiceSimulator,
} from '../../../../common/fixtures/plugins/actions';
// eslint-disable-next-line import/no-default-export
export default function slackTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
describe('slack action', () => {
let slackSimulatorURL: string = '<could not determine kibana url>';
// need to wait for kibanaServer to settle ...
before(() => {
slackSimulatorURL = kibanaServer.resolveUrl(
getExternalServiceSimulatorPath(ExternalServiceSimulator.SLACK)
);
});
it('should return 403 when creating a slack action', async () => {
await supertest
.post('/api/action')
.set('kbn-xsrf', 'foo')
.send({
name: 'A slack action',
actionTypeId: '.slack',
secrets: {
webhookUrl: slackSimulatorURL,
},
})
.expect(403, {
statusCode: 403,
error: 'Forbidden',
message:
'Action type .slack is disabled because your basic license does not support it. Please upgrade your license.',
});
});
});
}

View file

@ -0,0 +1,51 @@
/*
* 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 { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
getExternalServiceSimulatorPath,
ExternalServiceSimulator,
} from '../../../../common/fixtures/plugins/actions';
// eslint-disable-next-line import/no-default-export
export default function webhookTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
describe('webhook action', () => {
let webhookSimulatorURL: string = '<could not determine kibana url>';
// need to wait for kibanaServer to settle ...
before(() => {
webhookSimulatorURL = kibanaServer.resolveUrl(
getExternalServiceSimulatorPath(ExternalServiceSimulator.WEBHOOK)
);
});
it('should return 403 when creating a webhook action', async () => {
await supertest
.post('/api/action')
.set('kbn-xsrf', 'test')
.send({
name: 'A generic Webhook action',
actionTypeId: '.webhook',
secrets: {
user: 'username',
password: 'mypassphrase',
},
config: {
url: webhookSimulatorURL,
},
})
.expect(403, {
statusCode: 403,
error: 'Forbidden',
message:
'Action type .webhook is disabled because your basic license does not support it. Please upgrade your license.',
});
});
});
}

View file

@ -0,0 +1,20 @@
/*
* 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 { FtrProviderContext } from '../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function actionsTests({ loadTestFile }: FtrProviderContext) {
describe('Actions', () => {
loadTestFile(require.resolve('./builtin_action_types/email'));
loadTestFile(require.resolve('./builtin_action_types/es_index'));
loadTestFile(require.resolve('./builtin_action_types/pagerduty'));
loadTestFile(require.resolve('./builtin_action_types/server_log'));
loadTestFile(require.resolve('./builtin_action_types/servicenow'));
loadTestFile(require.resolve('./builtin_action_types/slack'));
loadTestFile(require.resolve('./builtin_action_types/webhook'));
});
}

View file

@ -0,0 +1,19 @@
/*
* 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 { FtrProviderContext } from '../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function alertingApiIntegrationTests({
loadTestFile,
getService,
}: FtrProviderContext) {
describe('alerting api integration basic license', function() {
this.tags('ciGroup3');
loadTestFile(require.resolve('./actions'));
});
}

View file

@ -62,7 +62,8 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
ssl,
serverArgs: [
`xpack.license.self_generated.type=${license}`,
`xpack.security.enabled=${!disabledPlugins.includes('security') && license === 'trial'}`,
`xpack.security.enabled=${!disabledPlugins.includes('security') &&
['trial', 'basic'].includes(license)}`,
],
},
kbnTestServer: {

View file

@ -43,6 +43,7 @@ export default function(kibana: any) {
const notEnabledActionType: ActionType = {
id: 'test.not-enabled',
name: 'Test: Not Enabled',
minimumLicenseRequired: 'gold',
async executor() {
return { status: 'ok', actionId: '' };
},

View file

@ -42,6 +42,7 @@ export default function(kibana: any) {
const noopActionType: ActionType = {
id: 'test.noop',
name: 'Test: Noop',
minimumLicenseRequired: 'gold',
async executor() {
return { status: 'ok', actionId: '' };
},
@ -49,6 +50,7 @@ export default function(kibana: any) {
const indexRecordActionType: ActionType = {
id: 'test.index-record',
name: 'Test: Index Record',
minimumLicenseRequired: 'gold',
validate: {
params: schema.object({
index: schema.string(),
@ -80,6 +82,7 @@ export default function(kibana: any) {
const failingActionType: ActionType = {
id: 'test.failing',
name: 'Test: Failing',
minimumLicenseRequired: 'gold',
validate: {
params: schema.object({
index: schema.string(),
@ -104,6 +107,7 @@ export default function(kibana: any) {
const rateLimitedActionType: ActionType = {
id: 'test.rate-limit',
name: 'Test: Rate Limit',
minimumLicenseRequired: 'gold',
maxAttempts: 2,
validate: {
params: schema.object({
@ -133,6 +137,7 @@ export default function(kibana: any) {
const authorizationActionType: ActionType = {
id: 'test.authorization',
name: 'Test: Authorization',
minimumLicenseRequired: 'gold',
validate: {
params: schema.object({
callClusterAuthorizationIndex: schema.string(),

View file

@ -205,10 +205,10 @@ export default function createActionTests({ getService }: FtrProviderContext) {
break;
case 'superuser at space1':
case 'space_1_all at space1':
expect(response.statusCode).to.eql(400);
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
statusCode: 400,
error: 'Bad Request',
statusCode: 403,
error: 'Forbidden',
message:
'action type "test.not-enabled" is not enabled in the Kibana config xpack.actions.enabledActionTypes',
});

Some files were not shown because too many files have changed in this diff Show more