diff --git a/.sass-lint.yml b/.sass-lint.yml index 9c64c1e5eea56..dd7bc0576692b 100644 --- a/.sass-lint.yml +++ b/.sass-lint.yml @@ -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' diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx index cbc3be6d144a2..c8ef6e32595d0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx @@ -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', }, ]; diff --git a/x-pack/plugins/actions/common/types.ts b/x-pack/plugins/actions/common/types.ts index fbd7404a2f15e..f3042a701211f 100644 --- a/x-pack/plugins/actions/common/types.ts +++ b/x-pack/plugins/actions/common/types.ts @@ -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 { diff --git a/x-pack/plugins/actions/server/action_type_registry.mock.ts b/x-pack/plugins/actions/server/action_type_registry.mock.ts index 5589a15932ecf..6a806d1fa531c 100644 --- a/x-pack/plugins/actions/server/action_type_registry.mock.ts +++ b/x-pack/plugins/actions/server/action_type_registry.mock.ts @@ -13,6 +13,7 @@ const createActionTypeRegistryMock = () => { get: jest.fn(), list: jest.fn(), ensureActionTypeEnabled: jest.fn(), + isActionTypeEnabled: jest.fn(), }; return mocked; }; diff --git a/x-pack/plugins/actions/server/action_type_registry.test.ts b/x-pack/plugins/actions/server/action_type_registry.test.ts index bced8841138f2..26bd68adfc4b6 100644 --- a/x-pack/plugins/actions/server/action_type_registry.test.ts +++ b/x-pack/plugins/actions/server/action_type_registry.test.ts @@ -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; +let mockedActionsConfig: jest.Mocked; +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"`); + }); +}); diff --git a/x-pack/plugins/actions/server/action_type_registry.ts b/x-pack/plugins/actions/server/action_type_registry.ts index 42e0ee9f523e1..c1d979feacc1d 100644 --- a/x-pack/plugins/actions/server/action_type_registry.ts +++ b/x-pack/plugins/actions/server/action_type_registry.ts @@ -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 = 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, })); } } diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index cafad6313d2e4..0df07ad58fb9e 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -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; 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"`); + }); }); diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index a06048953b62c..129829850f9c1 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -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, diff --git a/x-pack/plugins/actions/server/actions_config.mock.ts b/x-pack/plugins/actions/server/actions_config.mock.ts index b4e0324f9fead..addd35ae4f5f3 100644 --- a/x-pack/plugins/actions/server/actions_config.mock.ts +++ b/x-pack/plugins/actions/server/actions_config.mock.ts @@ -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 = { + 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, }; diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index e589969c50e54..64d1fd7fe90ac 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -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'); } }, }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 4ad4fe96f3447..0bd3992de30e6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -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; -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, }, }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.ts b/x-pack/plugins/actions/server/builtin_action_types/email.ts index b209e7bbca6f7..16e0168a7deb9 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.ts @@ -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', }), diff --git a/x-pack/plugins/actions/server/builtin_action_types/es_index.ts b/x-pack/plugins/actions/server/builtin_action_types/es_index.ts index b1fe5e3af2d11..b86f0029b5383 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/es_index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/es_index.ts @@ -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', }), diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts index db6375fe18193..ac21905ede11c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts @@ -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 }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts index caa183d665e09..514c9759d7b56 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.test.ts @@ -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`); }, diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts index 62f46d3d62503..2b607d0dd41ba 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -96,6 +96,7 @@ export function getActionType({ }): ActionType { return { id: '.pagerduty', + minimumLicenseRequired: 'gold', name: i18n.translate('xpack.actions.builtin.pagerdutyTitle', { defaultMessage: 'PagerDuty', }), diff --git a/x-pack/plugins/actions/server/builtin_action_types/server_log.ts b/x-pack/plugins/actions/server/builtin_action_types/server_log.ts index 01355f2a34f92..bf8a3d8032cc5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/server_log.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/server_log.ts @@ -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', }), diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts index 8ee81c5e76451..9a7f340c1135a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts @@ -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`); }, diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index f844bef6441ee..a63c2fd3a6ceb 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -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), diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts index 919f0800c291c..49b0b84e9dbb5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts @@ -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 {}, - 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(), }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.ts index 3a351853c1e46..e51ef3f67bd65 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.ts @@ -50,6 +50,7 @@ export function getActionType({ }): ActionType { return { id: '.slack', + minimumLicenseRequired: 'gold', name: i18n.translate('xpack.actions.builtin.slackTitle', { defaultMessage: 'Slack', }), diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index d8f75de781841..03658b3b1dd85 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -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(), }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts index e275deace0dcc..6173edc2df15a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts @@ -66,6 +66,7 @@ export function getActionType({ }): ActionType { return { id: '.webhook', + minimumLicenseRequired: 'gold', name: i18n.translate('xpack.actions.builtin.webhookTitle', { defaultMessage: 'Webhook', }), diff --git a/x-pack/plugins/actions/server/create_execute_function.test.ts b/x-pack/plugins/actions/server/create_execute_function.test.ts index 6d2a234639532..68c3967359ff4 100644 --- a/x-pack/plugins/actions/server/create_execute_function.test.ts +++ b/x-pack/plugins/actions/server/create_execute_function.test.ts @@ -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"`); + }); }); diff --git a/x-pack/plugins/actions/server/create_execute_function.ts b/x-pack/plugins/actions/server/create_execute_function.ts index 5316e833f33d9..4bbcda4cba7fc 100644 --- a/x-pack/plugins/actions/server/create_execute_function.ts +++ b/x-pack/plugins/actions/server/create_execute_function.ts @@ -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('action', id); + + actionTypeRegistry.ensureActionTypeEnabled(actionSavedObject.attributes.actionTypeId); + const actionTaskParamsRecord = await savedObjectsClient.create('action_task_params', { actionId: id, params, diff --git a/x-pack/plugins/actions/server/lib/action_executor.test.ts b/x-pack/plugins/actions/server/lib/action_executor.test.ts index 6ab5b812161c3..bbcb0457fc1d1 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.test.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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 () => { diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index e42a69812b7da..af0353247d99f 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -82,11 +82,7 @@ export class ActionExecutor { attributes: { actionTypeId, config, name }, } = await services.savedObjectsClient.get('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 diff --git a/x-pack/plugins/actions/server/lib/errors/action_type_disabled.ts b/x-pack/plugins/actions/server/lib/errors/action_type_disabled.ts new file mode 100644 index 0000000000000..fb15125fa6957 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/errors/action_type_disabled.ts @@ -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 } }); + } +} diff --git a/x-pack/plugins/actions/server/lib/errors/index.ts b/x-pack/plugins/actions/server/lib/errors/index.ts new file mode 100644 index 0000000000000..79c6d53c403ff --- /dev/null +++ b/x-pack/plugins/actions/server/lib/errors/index.ts @@ -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'; diff --git a/x-pack/plugins/actions/server/lib/errors/types.ts b/x-pack/plugins/actions/server/lib/errors/types.ts new file mode 100644 index 0000000000000..949dc348265ae --- /dev/null +++ b/x-pack/plugins/actions/server/lib/errors/types.ts @@ -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; +} diff --git a/x-pack/plugins/actions/server/lib/index.ts b/x-pack/plugins/actions/server/lib/index.ts index 0667e0548646e..f03b6de1fc5fb 100644 --- a/x-pack/plugins/actions/server/lib/index.ts +++ b/x-pack/plugins/actions/server/lib/index.ts @@ -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'; diff --git a/x-pack/plugins/actions/server/lib/license_state.mock.ts b/x-pack/plugins/actions/server/lib/license_state.mock.ts index f36f3a9eaeade..72a21f878a150 100644 --- a/x-pack/plugins/actions/server/lib/license_state.mock.ts +++ b/x-pack/plugins/actions/server/lib/license_state.mock.ts @@ -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 = { + 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, }; diff --git a/x-pack/plugins/actions/server/lib/license_state.test.ts b/x-pack/plugins/actions/server/lib/license_state.test.ts index dbb70857dad5c..ba1fbcb83464a 100644 --- a/x-pack/plugins/actions/server/lib/license_state.test.ts +++ b/x-pack/plugins/actions/server/lib/license_state.test.ts @@ -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; + 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; + 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); + }); +}); diff --git a/x-pack/plugins/actions/server/lib/license_state.ts b/x-pack/plugins/actions/server/lib/license_state.ts index 7b25e55ac0ba1..9d87818805dcf 100644 --- a/x-pack/plugins/actions/server/lib/license_state.ts +++ b/x-pack/plugins/actions/server/lib/license_state.ts @@ -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; 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) { 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 { diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts index 6be5e1f79ee82..43882cef21170 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts @@ -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); + } +}); diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.ts index c78b43f4ef3ba..e2a6128aea203 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.ts @@ -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) diff --git a/x-pack/plugins/actions/server/lib/validate_with_schema.test.ts b/x-pack/plugins/actions/server/lib/validate_with_schema.test.ts index 28122c72baf65..b7d408985ed9f 100644 --- a/x-pack/plugins/actions/server/lib/validate_with_schema.test.ts +++ b/x-pack/plugins/actions/server/lib/validate_with_schema.test.ts @@ -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, diff --git a/x-pack/plugins/actions/server/lib/license_api_access.ts b/x-pack/plugins/actions/server/lib/verify_api_access.ts similarity index 81% rename from x-pack/plugins/actions/server/lib/license_api_access.ts rename to x-pack/plugins/actions/server/lib/verify_api_access.ts index 2e650ebf5eb17..2055c66865c4e 100644 --- a/x-pack/plugins/actions/server/lib/license_api_access.ts +++ b/x-pack/plugins/actions/server/lib/verify_api_access.ts @@ -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) { diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index 1f68d8d4a3a69..75396f2aad897 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -19,6 +19,7 @@ const createSetupMock = () => { const createStartMock = () => { const mock: jest.Mocked = { execute: jest.fn(), + isActionTypeEnabled: jest.fn(), getActionsClientWithRequest: jest.fn().mockResolvedValue(actionsClientMock.create()), }; return mock; diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts index f55a5ca172144..383f84590fbc6 100644 --- a/x-pack/plugins/actions/server/plugin.test.ts +++ b/x-pack/plugins/actions/server/plugin.test.ts @@ -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 = ({ @@ -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; diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 10826ce795757..c6c4f377ab618 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -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; getActionsClientWithRequest(request: KibanaRequest): Promise>; } @@ -91,7 +92,7 @@ export class ActionsPlugin implements Plugin, 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, Plugi } public async setup(core: CoreSetup, plugins: ActionsPluginsSetup): Promise { + this.licenseState = new LicenseState(plugins.licensing.license$); this.isESOUsingEphemeralEncryptionKey = plugins.encryptedSavedObjects.usingEphemeralEncryptionKey; @@ -156,6 +158,7 @@ export class ActionsPlugin implements Plugin, Plugi taskRunnerFactory, taskManager: plugins.taskManager, actionsConfigUtils, + licenseState: this.licenseState, }); this.taskRunnerFactory = taskRunnerFactory; this.actionTypeRegistry = actionTypeRegistry; @@ -190,7 +193,6 @@ export class ActionsPlugin implements Plugin, 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, 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, Plugi taskRunnerFactory!.initialize({ logger, + actionTypeRegistry: actionTypeRegistry!, encryptedSavedObjectsPlugin: plugins.encryptedSavedObjects, getBasePath: this.getBasePath, spaceIdToNamespace: this.spaceIdToNamespace, @@ -238,10 +251,14 @@ export class ActionsPlugin implements Plugin, 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) { diff --git a/x-pack/plugins/actions/server/routes/create.test.ts b/x-pack/plugins/actions/server/routes/create.test.ts index 6f7ebf2735edd..22cf0dd7f8ace 100644 --- a/x-pack/plugins/actions/server/routes/create.test.ts +++ b/x-pack/plugins/actions/server/routes/create.test.ts @@ -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' } }); + }); }); diff --git a/x-pack/plugins/actions/server/routes/create.ts b/x-pack/plugins/actions/server/routes/create.ts index 2150dc4076449..0456fa8667de3 100644 --- a/x-pack/plugins/actions/server/routes/create.ts +++ b/x-pack/plugins/actions/server/routes/create.ts @@ -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; + } }) ); }; diff --git a/x-pack/plugins/actions/server/routes/delete.test.ts b/x-pack/plugins/actions/server/routes/delete.test.ts index e44f325413428..6fb526628cb02 100644 --- a/x-pack/plugins/actions/server/routes/delete.test.ts +++ b/x-pack/plugins/actions/server/routes/delete.test.ts @@ -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(() => { diff --git a/x-pack/plugins/actions/server/routes/delete.ts b/x-pack/plugins/actions/server/routes/delete.ts index 8508137b97750..6635133f318b1 100644 --- a/x-pack/plugins/actions/server/routes/delete.ts +++ b/x-pack/plugins/actions/server/routes/delete.ts @@ -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}`, diff --git a/x-pack/plugins/actions/server/routes/execute.test.ts b/x-pack/plugins/actions/server/routes/execute.test.ts index d8b57b2fb849a..3a3ed1257f576 100644 --- a/x-pack/plugins/actions/server/routes/execute.test.ts +++ b/x-pack/plugins/actions/server/routes/execute.test.ts @@ -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; + + executeActionRoute(router, licenseState, actionExecutor); + + const [, handler] = router.post.mock.calls[0]; + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); + }); }); diff --git a/x-pack/plugins/actions/server/routes/execute.ts b/x-pack/plugins/actions/server/routes/execute.ts index afccee3b5e70e..78693b5bfcf23 100644 --- a/x-pack/plugins/actions/server/routes/execute.ts +++ b/x-pack/plugins/actions/server/routes/execute.ts @@ -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; + } }) ); }; diff --git a/x-pack/plugins/actions/server/routes/find.test.ts b/x-pack/plugins/actions/server/routes/find.test.ts index b51130b2640aa..1b130421fa71f 100644 --- a/x-pack/plugins/actions/server/routes/find.test.ts +++ b/x-pack/plugins/actions/server/routes/find.test.ts @@ -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(() => { diff --git a/x-pack/plugins/actions/server/routes/find.ts b/x-pack/plugins/actions/server/routes/find.ts index 820dd32d710ae..700e70c65d5df 100644 --- a/x-pack/plugins/actions/server/routes/find.ts +++ b/x-pack/plugins/actions/server/routes/find.ts @@ -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`, diff --git a/x-pack/plugins/actions/server/routes/get.test.ts b/x-pack/plugins/actions/server/routes/get.test.ts index 8762a68b192f2..f4e834a5b767c 100644 --- a/x-pack/plugins/actions/server/routes/get.test.ts +++ b/x-pack/plugins/actions/server/routes/get.test.ts @@ -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(() => { diff --git a/x-pack/plugins/actions/server/routes/get.ts b/x-pack/plugins/actions/server/routes/get.ts index 836f46bfe55fd..e3c93299614bd 100644 --- a/x-pack/plugins/actions/server/routes/get.ts +++ b/x-pack/plugins/actions/server/routes/get.ts @@ -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}`, diff --git a/x-pack/plugins/actions/server/routes/list_action_types.test.ts b/x-pack/plugins/actions/server/routes/list_action_types.test.ts index e983b8d1f2f84..76fb636a75be7 100644 --- a/x-pack/plugins/actions/server/routes/list_action_types.test.ts +++ b/x-pack/plugins/actions/server/routes/list_action_types.test.ts @@ -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(() => { diff --git a/x-pack/plugins/actions/server/routes/list_action_types.ts b/x-pack/plugins/actions/server/routes/list_action_types.ts index 46f62e3a9c8bb..6f2b8f86e1fb2 100644 --- a/x-pack/plugins/actions/server/routes/list_action_types.ts +++ b/x-pack/plugins/actions/server/routes/list_action_types.ts @@ -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`, diff --git a/x-pack/plugins/actions/server/routes/update.test.ts b/x-pack/plugins/actions/server/routes/update.test.ts index 1090193761395..161fb4398af1d 100644 --- a/x-pack/plugins/actions/server/routes/update.test.ts +++ b/x-pack/plugins/actions/server/routes/update.test.ts @@ -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' } }); + }); }); diff --git a/x-pack/plugins/actions/server/routes/update.ts b/x-pack/plugins/actions/server/routes/update.ts index 315695382b2d9..692693f010665 100644 --- a/x-pack/plugins/actions/server/routes/update.ts +++ b/x-pack/plugins/actions/server/routes/update.ts @@ -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; + } }) ); }; diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index 635c0829e02c3..999e739e77060 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -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 = Pick>; 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; diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 8d54432f7d9c3..58807b42dc278 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -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, }); diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts index 0fb1fa98249ef..5bd8382f0a4b2 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts @@ -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==", diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts index 5acb171209ea6..5d14f4adc709e 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts @@ -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` + ); + } } }; } diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index d1bc0de3ae0e2..5f4669f64f09d 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -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 = { + const taskRunnerFactoryInitializerParams: jest.Mocked & { + actionsPlugin: jest.Mocked; + } = { 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==", diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index 5c8acfb58a92a..42768a80a4ccf 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -119,7 +119,7 @@ export class TaskRunner { alertName, tags, logger: this.logger, - executeAction: this.context.executeAction, + actionsPlugin: this.context.actionsPlugin, apiKey, actions: actionsWithIds, spaceId, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts index f885b0bdbd046..fc34cacba2818 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -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 = { getServices: jest.fn().mockReturnValue(services), - executeAction: jest.fn(), + actionsPlugin: actionsMock.createStart(), encryptedSavedObjectsPlugin, logger: loggingServiceMock.create().get(), spaceIdToNamespace: jest.fn().mockReturnValue(undefined), diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts index c598b0f52f197..3bad4e475ff49 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -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; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.test.ts index 62e7b1cf022bb..ee68b7e269c34 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api.test.ts @@ -25,6 +25,9 @@ describe('loadActionTypes', () => { id: 'test', name: 'Test', enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', }, ]; http.get.mockResolvedValueOnce(resolvedValue); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_type_compare.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_type_compare.test.ts new file mode 100644 index 0000000000000..9ce50cf47560a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_type_compare.test.ts @@ -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]); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_type_compare.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_type_compare.ts new file mode 100644 index 0000000000000..d18cb21b3a0fe --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_type_compare.ts @@ -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); +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss new file mode 100644 index 0000000000000..32ab1bd7b1821 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss @@ -0,0 +1,9 @@ +.actCheckActionTypeEnabled__disabledActionWarningCard { + background-color: $euiColorLightestShade; +} + +.actAccordionActionForm { + .euiCard { + box-shadow: none; + } +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.test.tsx new file mode 100644 index 0000000000000..eb51bb8ac5098 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.test.tsx @@ -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": + + + + , + } + `); +}); + +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": , + } + `); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.tsx b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.tsx new file mode 100644 index 0000000000000..7691c3741468c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.tsx @@ -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: ( + + + + } + /> + ), + }; + } + + if (actionType?.enabledInConfig === false) { + return { + isEnabled: false, + message: i18n.translate( + 'xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByConfigMessage', + { defaultMessage: 'This connector is disabled by the Kibana configuration.' } + ), + messageCard: ( + + ), + }; + } + + return { isEnabled: true }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index caed0caefe109..89d37c4d00a11 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -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; @@ -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(); }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 64be161fc90b3..18bc6ad8810a0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -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(false); const [activeActionItem, setActiveActionItem] = useState( @@ -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 ( - - - - - - -
- -
-
-
- - } - extraAction={ - { - 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 ? ( + ) : null} + + ) : ( + checkEnabledResult.messageCard + ); + + return ( + + + + + + +
+ + + + + + {checkEnabledResult.isEnabled === false && ( + + + + )} + + +
+
+
+ + } + extraAction={ + { + 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}
); }; @@ -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={ @@ -329,7 +373,7 @@ export const ActionForm = ({ 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 = ( addActionType(item)} > - ) : null; - }) - : null; + ); + + return ( + + {checkEnabledResult.isEnabled && keyPadItem} + {checkEnabledResult.isEnabled === false && ( + + {keyPadItem} + + )} + + ); + }); + } return ( @@ -467,14 +537,36 @@ export const ActionForm = ({ ) : null} {isAddActionPanelOpen ? ( - -
- -
-
+ + + +
+ +
+
+
+ {hasDisabledByLicenseActionTypes && ( + + +
+ + + +
+
+
+ )} +
{isLoadingActionTypes ? ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx index 4f098165033e7..84d5269337b9e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.test.tsx @@ -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( + { + return new Promise(() => {}); + }, + }} + > + + + ); + + 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( + { + return new Promise(() => {}); + }, + }} + > + + + ); + + 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.' + ); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx index a63665a68fb6b..2dd5e413faf9c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx @@ -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(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 ( - - } - title={item.name} - description={item.selectMessage} - onClick={() => onActionTypeChange(item.actionType)} - /> - + const checkEnabledResult = checkActionTypeEnabled(item.actionType); + const card = ( + } + 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 {card}; }); - return {cardNodes}; + return ( +
+ + + {cardNodes} + +
+ ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx index cf0edbe422495..c25cae832006a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.test.tsx @@ -79,6 +79,9 @@ describe('connector_add_flyout', () => { id: actionType.id, enabled: true, name: 'Test', + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', }, ]} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index 9aea2419ec619..665eeca43acb4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -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(undefined); + const [hasActionsDisabledByLicense, setHasActionsDisabledByLicense] = useState(false); // hooks const initialConnector = { @@ -86,7 +91,11 @@ export const ConnectorAddFlyout = ({ let actionTypeModel; if (!actionType) { currentForm = ( - + ); } else { actionTypeModel = actionTypeRegistry.get(actionType.id); @@ -204,7 +213,11 @@ export const ConnectorAddFlyout = ({
- {currentForm} + + {currentForm} + @@ -252,3 +265,24 @@ export const ConnectorAddFlyout = ({ ); }; + +const upgradeYourLicenseCallOut = ( + + + + + + + +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx index 31d801bb340f3..d2e3739c1cd22 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx @@ -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 diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.scss b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.scss index 7a824aaeaa8d8..3d65b8a799b1b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.scss +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.scss @@ -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; + } +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx index 9187836d52462..9331fe1704694 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx @@ -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; + + 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( + + + + ); + }); + + 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) { await Promise.resolve(); await Promise.resolve(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index 9444b31a8b78f..c023f9087d70e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -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 ( - editItem(item)} key={item.id}> + const checkEnabledResult = checkActionTypeEnabled( + actionTypesIndex && actionTypesIndex[item.actionTypeId] + ); + + const link = ( + editItem(item)} + key={item.id} + disabled={actionTypesIndex ? !actionTypesIndex[item.actionTypeId].enabled : true} + > {value} ); + + return checkEnabledResult.isEnabled ? ( + link + ) : ( + + {link} + + + ); }, }, { @@ -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} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index 92b3e4eb9679f..f025b0396f04d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -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', }, ]; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx index ac3951cfa98de..cd368193e5fa4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx @@ -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(false); + const [hasActionsDisabled, setHasActionsDisabled] = useState(false); const { reloadAlerts, @@ -141,7 +144,27 @@ export const AlertEdit = ({ - + {hasActionsDisabled && ( + + + + + )} + diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 1fa620c5394a1..dd561a653dfc3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -70,9 +70,16 @@ interface AlertFormProps { dispatch: React.Dispatch; 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; @@ -214,6 +221,7 @@ export const AlertForm = ({ alert, canChangeTrigger = true, dispatch, errors }: {defaultActionGroupId ? ( av.name) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index c409dead7c850..6371775c46094 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -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) ? ( - editItem(item)} - > - - - ) : ( - <> - ); - }, - }, - ] - : [], + render(item: AlertTableItem) { + if (!canSave || !alertTypeRegistry.has(item.alertTypeId)) { + return; + } + return ( + editItem(item)} + > + + + ); + }, }, { name: '', diff --git a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts index 816dc894ab9ec..a2a1657a1f4cc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/constants/index.ts @@ -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'; diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 668a8802d1461..342401c4778d8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -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, diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index c1f8047c8a5cc..06ee0b91c8a5d 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -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'), diff --git a/x-pack/test/alerting_api_integration/basic/config.ts b/x-pack/test/alerting_api_integration/basic/config.ts new file mode 100644 index 0000000000000..f9c248ec3d56f --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/config.ts @@ -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, +}); diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/email.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/email.ts new file mode 100644 index 0000000000000..f22fe0e3bc1e7 --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/email.ts @@ -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.', + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/es_index.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/es_index.ts new file mode 100644 index 0000000000000..ec07f6ff44df6 --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/es_index.ts @@ -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); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/pagerduty.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/pagerduty.ts new file mode 100644 index 0000000000000..e261cf15d05ae --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/pagerduty.ts @@ -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.', + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/server_log.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/server_log.ts new file mode 100644 index 0000000000000..686f4a0086fa0 --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/server_log.ts @@ -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); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/servicenow.ts new file mode 100644 index 0000000000000..a7551ad7e2fad --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/servicenow.ts @@ -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 = ''; + + // 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.', + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/slack.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/slack.ts new file mode 100644 index 0000000000000..46258e41d5d69 --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/slack.ts @@ -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 = ''; + + // 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.', + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/webhook.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/webhook.ts new file mode 100644 index 0000000000000..338610e9243a4 --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/builtin_action_types/webhook.ts @@ -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 = ''; + + // 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.', + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts new file mode 100644 index 0000000000000..1788a12afebf2 --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/index.ts @@ -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')); + }); +} diff --git a/x-pack/test/alerting_api_integration/basic/tests/index.ts b/x-pack/test/alerting_api_integration/basic/tests/index.ts new file mode 100644 index 0000000000000..2aa5ddee11047 --- /dev/null +++ b/x-pack/test/alerting_api_integration/basic/tests/index.ts @@ -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')); + }); +} diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index eb03aafc03d08..5fb1afa7d584f 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -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: { diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts index aeec07aba906c..acd14e8a2bf7b 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts @@ -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: '' }; }, diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts index 58f7a49720007..9b4a2d14de9ea 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/index.ts @@ -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(), diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/create.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/create.ts index 1ce9a6ba3a040..43a3861491467 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/create.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/create.ts @@ -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', }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/config.ts b/x-pack/test/alerting_api_integration/spaces_only/config.ts index 44603cc95e5e0..c79c26ef68752 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/config.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/config.ts @@ -7,4 +7,4 @@ import { createTestConfig } from '../common/config'; // eslint-disable-next-line import/no-default-export -export default createTestConfig('spaces_only', { disabledPlugins: ['security'], license: 'basic' }); +export default createTestConfig('spaces_only', { disabledPlugins: ['security'], license: 'trial' }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/type_not_enabled.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/type_not_enabled.ts index 7193a80b94498..1388108806c0f 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/type_not_enabled.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/type_not_enabled.ts @@ -29,10 +29,10 @@ export default function typeNotEnabledTests({ getService }: FtrProviderContext) actionTypeId: DISABLED_ACTION_TYPE, }); - 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', }); @@ -46,11 +46,10 @@ export default function typeNotEnabledTests({ getService }: FtrProviderContext) params: {}, }); - expect(response.statusCode).to.eql(200); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - status: 'error', - retry: false, - actionId: PREWRITTEN_ACTION_ID, + statusCode: 403, + error: 'Forbidden', message: 'action type "test.not-enabled" is not enabled in the Kibana config xpack.actions.enabledActionTypes', }); @@ -76,12 +75,12 @@ export default function typeNotEnabledTests({ getService }: FtrProviderContext) name: 'an action created before test.not-enabled was disabled (updated)', }); - expect(responseUpdate.statusCode).to.eql(200); + expect(responseUpdate.statusCode).to.eql(403); expect(responseUpdate.body).to.eql({ - actionTypeId: 'test.not-enabled', - config: {}, - id: 'uuid-actionId', - name: 'an action created before test.not-enabled was disabled (updated)', + statusCode: 403, + error: 'Forbidden', + message: + 'action type "test.not-enabled" is not enabled in the Kibana config xpack.actions.enabledActionTypes', }); const response = await supertest.get(`/api/action/${PREWRITTEN_ACTION_ID}`); @@ -90,7 +89,7 @@ export default function typeNotEnabledTests({ getService }: FtrProviderContext) actionTypeId: 'test.not-enabled', config: {}, id: 'uuid-actionId', - name: 'an action created before test.not-enabled was disabled (updated)', + name: 'an action created before test.not-enabled was disabled', }); });