diff --git a/x-pack/index.js b/x-pack/index.js index ef18b9016c803..46eda5a59976c 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -41,6 +41,7 @@ import { uptime } from './plugins/uptime'; import { ossTelemetry } from './plugins/oss_telemetry'; import { encryptedSavedObjects } from './plugins/encrypted_saved_objects'; import { snapshotRestore } from './plugins/snapshot_restore'; +import { actions } from './plugins/actions'; module.exports = function (kibana) { return [ @@ -81,5 +82,6 @@ module.exports = function (kibana) { ossTelemetry(kibana), encryptedSavedObjects(kibana), snapshotRestore(kibana), + actions(kibana), ]; }; diff --git a/x-pack/plugins/actions/index.ts b/x-pack/plugins/actions/index.ts new file mode 100644 index 0000000000000..a2e0a387d63e1 --- /dev/null +++ b/x-pack/plugins/actions/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 { Root } from 'joi'; +import mappings from './mappings.json'; +import { init } from './server'; + +export { ActionsPlugin, ActionsClient } from './server'; + +export function actions(kibana: any) { + return new kibana.Plugin({ + id: 'actions', + configPrefix: 'xpack.actions', + require: ['kibana', 'elasticsearch', 'encrypted_saved_objects'], + config(Joi: Root) { + return Joi.object() + .keys({ + enabled: Joi.boolean().default(true), + }) + .default(); + }, + init, + uiExports: { + mappings, + }, + }); +} diff --git a/x-pack/plugins/actions/mappings.json b/x-pack/plugins/actions/mappings.json new file mode 100644 index 0000000000000..e76612ca56c48 --- /dev/null +++ b/x-pack/plugins/actions/mappings.json @@ -0,0 +1,19 @@ +{ + "action": { + "properties": { + "description": { + "type": "text" + }, + "actionTypeId": { + "type": "keyword" + }, + "actionTypeConfig": { + "enabled": false, + "type": "object" + }, + "actionTypeConfigSecrets": { + "type": "binary" + } + } + } +} diff --git a/x-pack/plugins/actions/server/__jest__/action_type_service.test.ts b/x-pack/plugins/actions/server/__jest__/action_type_service.test.ts new file mode 100644 index 0000000000000..413193202a10b --- /dev/null +++ b/x-pack/plugins/actions/server/__jest__/action_type_service.test.ts @@ -0,0 +1,331 @@ +/* + * 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 Joi from 'joi'; +import { ActionTypeService } from '../action_type_service'; + +describe('register()', () => { + test('able to register action types', () => { + const executor = jest.fn(); + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + executor, + }); + expect(actionTypeService.has('my-action-type')).toEqual(true); + }); + + test('throws error if action type already registered', () => { + const executor = jest.fn(); + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + executor, + }); + expect(() => + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + executor, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Action type \\"my-action-type\\" is already registered."` + ); + }); +}); + +describe('get()', () => { + test('returns action type', () => { + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + async executor() {}, + }); + const actionType = actionTypeService.get('my-action-type'); + expect(actionType).toMatchInlineSnapshot(` +Object { + "executor": [Function], + "id": "my-action-type", + "name": "My action type", +} +`); + }); + + test(`throws an error when action type doesn't exist`, () => { + const actionTypeService = new ActionTypeService(); + expect(() => actionTypeService.get('my-action-type')).toThrowErrorMatchingInlineSnapshot( + `"Action type \\"my-action-type\\" is not registered."` + ); + }); +}); + +describe('getUnencryptedAttributes()', () => { + test('returns empty array when unencryptedAttributes is undefined', () => { + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + async executor() {}, + }); + const result = actionTypeService.getUnencryptedAttributes('my-action-type'); + expect(result).toEqual([]); + }); + + test('returns values inside unencryptedAttributes array when it exists', () => { + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + unencryptedAttributes: ['a', 'b', 'c'], + async executor() {}, + }); + const result = actionTypeService.getUnencryptedAttributes('my-action-type'); + expect(result).toEqual(['a', 'b', 'c']); + }); +}); + +describe('list()', () => { + test('returns list of action types', () => { + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + async executor() {}, + }); + const actionTypes = actionTypeService.list(); + expect(actionTypes).toEqual([ + { + id: 'my-action-type', + name: 'My action type', + }, + ]); + }); +}); + +describe('validateParams()', () => { + test('should pass when validation not defined', () => { + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + async executor() {}, + }); + actionTypeService.validateParams('my-action-type', {}); + }); + + test('should validate and pass when params is valid', () => { + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + validate: { + params: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + async executor() {}, + }); + actionTypeService.validateParams('my-action-type', { param1: 'value' }); + }); + + test('should validate and throw error when params is invalid', () => { + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + validate: { + params: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + async executor() {}, + }); + expect(() => + actionTypeService.validateParams('my-action-type', {}) + ).toThrowErrorMatchingInlineSnapshot( + `"child \\"param1\\" fails because [\\"param1\\" is required]"` + ); + }); +}); + +describe('validateActionTypeConfig()', () => { + test('should pass when validation not defined', () => { + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + async executor() {}, + }); + actionTypeService.validateActionTypeConfig('my-action-type', {}); + }); + + test('should validate and pass when actionTypeConfig is valid', () => { + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + validate: { + actionTypeConfig: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + async executor() {}, + }); + actionTypeService.validateActionTypeConfig('my-action-type', { param1: 'value' }); + }); + + test('should validate and throw error when actionTypeConfig is invalid', () => { + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + validate: { + actionTypeConfig: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + async executor() {}, + }); + expect(() => + actionTypeService.validateActionTypeConfig('my-action-type', {}) + ).toThrowErrorMatchingInlineSnapshot( + `"child \\"param1\\" fails because [\\"param1\\" is required]"` + ); + }); +}); + +describe('has()', () => { + test('returns false for unregistered action types', () => { + const actionTypeService = new ActionTypeService(); + expect(actionTypeService.has('my-action-type')).toEqual(false); + }); + + test('returns true after registering an action type', () => { + const executor = jest.fn(); + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + executor, + }); + expect(actionTypeService.has('my-action-type')); + }); +}); + +describe('execute()', () => { + test('calls the executor with proper params', async () => { + const executor = jest.fn().mockResolvedValueOnce({ success: true }); + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + executor, + }); + await actionTypeService.execute({ + id: 'my-action-type', + actionTypeConfig: { foo: true }, + params: { bar: false }, + }); + expect(executor).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Object { + "actionTypeConfig": Object { + "foo": true, + }, + "params": Object { + "bar": false, + }, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); + + test('validates params', async () => { + const executor = jest.fn().mockResolvedValueOnce({ success: true }); + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + executor, + validate: { + params: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + }); + await expect( + actionTypeService.execute({ + id: 'my-action-type', + actionTypeConfig: {}, + params: {}, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"child \\"param1\\" fails because [\\"param1\\" is required]"` + ); + }); + + test('validates actionTypeConfig', async () => { + const executor = jest.fn().mockResolvedValueOnce({ success: true }); + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + executor, + validate: { + actionTypeConfig: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + }); + await expect( + actionTypeService.execute({ + id: 'my-action-type', + actionTypeConfig: {}, + params: {}, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"child \\"param1\\" fails because [\\"param1\\" is required]"` + ); + }); + + test('throws error if action type not registered', async () => { + const actionTypeService = new ActionTypeService(); + await expect( + actionTypeService.execute({ + id: 'my-action-type', + actionTypeConfig: { foo: true }, + params: { bar: false }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Action type \\"my-action-type\\" is not registered."` + ); + }); +}); diff --git a/x-pack/plugins/actions/server/__jest__/actions_client.test.ts b/x-pack/plugins/actions/server/__jest__/actions_client.test.ts new file mode 100644 index 0000000000000..6e9fb8fa842b7 --- /dev/null +++ b/x-pack/plugins/actions/server/__jest__/actions_client.test.ts @@ -0,0 +1,434 @@ +/* + * 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 Joi from 'joi'; +import { ActionTypeService } from '../action_type_service'; +import { ActionsClient } from '../actions_client'; + +const savedObjectsClient = { + errors: {} as any, + bulkCreate: jest.fn(), + bulkGet: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + get: jest.fn(), + update: jest.fn(), +}; + +beforeEach(() => jest.resetAllMocks()); + +describe('create()', () => { + test('creates an action with all given properties', async () => { + const expectedResult = Symbol(); + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + async executor() {}, + }); + const actionService = new ActionsClient({ + actionTypeService, + savedObjectsClient, + }); + savedObjectsClient.create.mockResolvedValueOnce(expectedResult); + const result = await actionService.create({ + data: { + description: 'my description', + actionTypeId: 'my-action-type', + actionTypeConfig: {}, + }, + options: { + migrationVersion: {}, + references: [], + }, + }); + expect(result).toEqual(expectedResult); + expect(savedObjectsClient.create).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + "action", + Object { + "actionTypeConfig": Object {}, + "actionTypeConfigSecrets": Object {}, + "actionTypeId": "my-action-type", + "description": "my description", + }, + Object { + "migrationVersion": Object {}, + "references": Array [], + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); + + test('validates actionTypeConfig', async () => { + const actionTypeService = new ActionTypeService(); + const actionService = new ActionsClient({ + actionTypeService, + savedObjectsClient, + }); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + validate: { + actionTypeConfig: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + async executor() {}, + }); + await expect( + actionService.create({ + data: { + description: 'my description', + actionTypeId: 'my-action-type', + actionTypeConfig: {}, + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"child \\"param1\\" fails because [\\"param1\\" is required]"` + ); + }); + + test(`throws an error when an action type doesn't exist`, async () => { + const actionTypeService = new ActionTypeService(); + const actionService = new ActionsClient({ + actionTypeService, + savedObjectsClient, + }); + await expect( + actionService.create({ + data: { + description: 'my description', + actionTypeId: 'unregistered-action-type', + actionTypeConfig: {}, + }, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Action type \\"unregistered-action-type\\" is not registered."` + ); + }); + + test('encrypts action type options unless specified not to', async () => { + const expectedResult = Symbol(); + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + unencryptedAttributes: ['a', 'c'], + async executor() {}, + }); + const actionService = new ActionsClient({ + actionTypeService, + savedObjectsClient, + }); + savedObjectsClient.create.mockResolvedValueOnce(expectedResult); + const result = await actionService.create({ + data: { + description: 'my description', + actionTypeId: 'my-action-type', + actionTypeConfig: { + a: true, + b: true, + c: true, + }, + }, + }); + expect(result).toEqual(expectedResult); + expect(savedObjectsClient.create).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + "action", + Object { + "actionTypeConfig": Object { + "a": true, + "c": true, + }, + "actionTypeConfigSecrets": Object { + "b": true, + }, + "actionTypeId": "my-action-type", + "description": "my description", + }, + undefined, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); +}); + +describe('get()', () => { + test('calls savedObjectsClient with id', async () => { + const expectedResult = Symbol(); + const actionTypeService = new ActionTypeService(); + const actionService = new ActionsClient({ + actionTypeService, + savedObjectsClient, + }); + savedObjectsClient.get.mockResolvedValueOnce(expectedResult); + const result = await actionService.get({ id: '1' }); + expect(result).toEqual(expectedResult); + expect(savedObjectsClient.get).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + "action", + "1", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); +}); + +describe('find()', () => { + test('calls savedObjectsClient with parameters', async () => { + const expectedResult = Symbol(); + const actionTypeService = new ActionTypeService(); + const actionService = new ActionsClient({ + actionTypeService, + savedObjectsClient, + }); + savedObjectsClient.find.mockResolvedValueOnce(expectedResult); + const result = await actionService.find({}); + expect(result).toEqual(expectedResult); + expect(savedObjectsClient.find).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Object { + "type": "action", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); +}); + +describe('delete()', () => { + test('calls savedObjectsClient with id', async () => { + const expectedResult = Symbol(); + const actionTypeService = new ActionTypeService(); + const actionService = new ActionsClient({ + actionTypeService, + savedObjectsClient, + }); + savedObjectsClient.delete.mockResolvedValueOnce(expectedResult); + const result = await actionService.delete({ id: '1' }); + expect(result).toEqual(expectedResult); + expect(savedObjectsClient.delete).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + "action", + "1", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); +}); + +describe('update()', () => { + test('updates an action with all given properties', async () => { + const expectedResult = Symbol(); + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + async executor() {}, + }); + const actionService = new ActionsClient({ + actionTypeService, + savedObjectsClient, + }); + savedObjectsClient.update.mockResolvedValueOnce(expectedResult); + const result = await actionService.update({ + id: 'my-action', + data: { + description: 'my description', + actionTypeId: 'my-action-type', + actionTypeConfig: {}, + }, + options: {}, + }); + expect(result).toEqual(expectedResult); + expect(savedObjectsClient.update).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + "action", + "my-action", + Object { + "actionTypeConfig": Object {}, + "actionTypeConfigSecrets": Object {}, + "actionTypeId": "my-action-type", + "description": "my description", + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); + + test('validates actionTypeConfig', async () => { + const actionTypeService = new ActionTypeService(); + const actionService = new ActionsClient({ + actionTypeService, + savedObjectsClient, + }); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + validate: { + actionTypeConfig: Joi.object() + .keys({ + param1: Joi.string().required(), + }) + .required(), + }, + async executor() {}, + }); + await expect( + actionService.update({ + id: 'my-action', + data: { + description: 'my description', + actionTypeId: 'my-action-type', + actionTypeConfig: {}, + }, + options: {}, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"child \\"param1\\" fails because [\\"param1\\" is required]"` + ); + }); + + test(`throws an error when action type doesn't exist`, async () => { + const actionTypeService = new ActionTypeService(); + const actionService = new ActionsClient({ + actionTypeService, + savedObjectsClient, + }); + await expect( + actionService.update({ + id: 'my-action', + data: { + description: 'my description', + actionTypeId: 'unregistered-action-type', + actionTypeConfig: {}, + }, + options: {}, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Action type \\"unregistered-action-type\\" is not registered."` + ); + }); + + test('encrypts action type options unless specified not to', async () => { + const expectedResult = Symbol(); + const actionTypeService = new ActionTypeService(); + actionTypeService.register({ + id: 'my-action-type', + name: 'My action type', + unencryptedAttributes: ['a', 'c'], + async executor() {}, + }); + const actionService = new ActionsClient({ + actionTypeService, + savedObjectsClient, + }); + savedObjectsClient.update.mockResolvedValueOnce(expectedResult); + const result = await actionService.update({ + id: 'my-action', + data: { + description: 'my description', + actionTypeId: 'my-action-type', + actionTypeConfig: { + a: true, + b: true, + c: true, + }, + }, + options: {}, + }); + expect(result).toEqual(expectedResult); + expect(savedObjectsClient.update).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + "action", + "my-action", + Object { + "actionTypeConfig": Object { + "a": true, + "c": true, + }, + "actionTypeConfigSecrets": Object { + "b": true, + }, + "actionTypeId": "my-action-type", + "description": "my description", + }, + Object {}, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); +}); diff --git a/x-pack/plugins/actions/server/__jest__/create_fire_function.test.ts b/x-pack/plugins/actions/server/__jest__/create_fire_function.test.ts new file mode 100644 index 0000000000000..7cc73796ea2e9 --- /dev/null +++ b/x-pack/plugins/actions/server/__jest__/create_fire_function.test.ts @@ -0,0 +1,148 @@ +/* + * 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 { ActionTypeService } from '../action_type_service'; +import { createFireFunction } from '../create_fire_function'; + +const mockEncryptedSavedObjects = { + isEncryptionError: jest.fn(), + registerType: jest.fn(), + getDecryptedAsInternalUser: jest.fn(), +}; + +describe('fire()', () => { + test('fires an action with all given parameters', async () => { + const actionTypeService = new ActionTypeService(); + const fireFn = createFireFunction({ + actionTypeService, + encryptedSavedObjectsPlugin: mockEncryptedSavedObjects, + }); + const mockActionType = jest.fn().mockResolvedValueOnce({ success: true }); + actionTypeService.register({ + id: 'mock', + name: 'Mock', + executor: mockActionType, + }); + mockEncryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: 'mock-action', + attributes: { + actionTypeId: 'mock', + actionTypeConfigSecrets: { + foo: true, + }, + }, + }); + const result = await fireFn({ + id: 'mock-action', + params: { baz: false }, + }); + expect(result).toEqual({ success: true }); + expect(mockActionType).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Object { + "actionTypeConfig": Object { + "foo": true, + }, + "params": Object { + "baz": false, + }, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + expect(mockEncryptedSavedObjects.getDecryptedAsInternalUser.mock.calls).toEqual([ + ['action', 'mock-action', { namespace: undefined }], + ]); + }); + + test(`throws an error when the action type isn't registered`, async () => { + const actionTypeService = new ActionTypeService(); + const fireFn = createFireFunction({ + actionTypeService, + encryptedSavedObjectsPlugin: mockEncryptedSavedObjects, + }); + mockEncryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: 'mock-action', + attributes: { + actionTypeId: 'non-registered-action-type', + actionTypeConfigSecrets: { + foo: true, + }, + }, + }); + await expect( + fireFn({ id: 'mock-action', params: { baz: false } }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Action type \\"non-registered-action-type\\" is not registered."` + ); + }); + + test('merges encrypted and unencrypted attributes', async () => { + const actionTypeService = new ActionTypeService(); + const fireFn = createFireFunction({ + actionTypeService, + encryptedSavedObjectsPlugin: mockEncryptedSavedObjects, + }); + const mockActionType = jest.fn().mockResolvedValueOnce({ success: true }); + actionTypeService.register({ + id: 'mock', + name: 'Mock', + unencryptedAttributes: ['a', 'c'], + executor: mockActionType, + }); + mockEncryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: 'mock-action', + attributes: { + actionTypeId: 'mock', + actionTypeConfig: { + a: true, + c: true, + }, + actionTypeConfigSecrets: { + b: true, + }, + }, + }); + const result = await fireFn({ + id: 'mock-action', + params: { baz: false }, + }); + expect(result).toEqual({ success: true }); + expect(mockActionType).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Object { + "actionTypeConfig": Object { + "a": true, + "b": true, + "c": true, + }, + "params": Object { + "baz": false, + }, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); + }); +}); diff --git a/x-pack/plugins/actions/server/action_type_service.ts b/x-pack/plugins/actions/server/action_type_service.ts new file mode 100644 index 0000000000000..a83edcf98b7a9 --- /dev/null +++ b/x-pack/plugins/actions/server/action_type_service.ts @@ -0,0 +1,131 @@ +/* + * 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 Boom from 'boom'; +import { i18n } from '@kbn/i18n'; + +interface ExecutorOptions { + actionTypeConfig: Record; + params: Record; +} + +interface ActionType { + id: string; + name: string; + unencryptedAttributes?: string[]; + validate?: { + params?: Record; + actionTypeConfig?: Record; + }; + executor({ actionTypeConfig, params }: ExecutorOptions): Promise; +} + +interface ExecuteOptions { + id: string; + actionTypeConfig: Record; + params: Record; +} + +export class ActionTypeService { + private actionTypes: Record = {}; + + /** + * Returns if the action type service has the given action type registered + */ + public has(id: string) { + return !!this.actionTypes[id]; + } + + /** + * Registers an action type to the action type service + */ + public register(actionType: ActionType) { + if (this.has(actionType.id)) { + throw new Error( + i18n.translate('xpack.actions.actionTypeService.register.duplicateActionTypeError', { + defaultMessage: 'Action type "{id}" is already registered.', + values: { + id: actionType.id, + }, + }) + ); + } + this.actionTypes[actionType.id] = actionType; + } + + /** + * Returns an action type, throws if not registered + */ + public get(id: string) { + if (!this.has(id)) { + throw Boom.badRequest( + i18n.translate('xpack.actions.actionTypeService.get.missingActionTypeError', { + defaultMessage: 'Action type "{id}" is not registered.', + values: { + id, + }, + }) + ); + } + return this.actionTypes[id]; + } + + /** + * Returns attributes to be treated as unencrypted + */ + public getUnencryptedAttributes(id: string) { + const actionType = this.get(id); + return actionType.unencryptedAttributes || []; + } + + /** + * Returns a list of registered action types [{ id, name }] + */ + public list() { + return Object.entries(this.actionTypes).map(([actionTypeId, actionType]) => ({ + id: actionTypeId, + name: actionType.name, + })); + } + + /** + * Throws an error if params are invalid for given action type + */ + public validateParams(id: string, params: Record) { + const actionType = this.get(id); + const validator = actionType.validate && actionType.validate.params; + if (validator) { + const { error } = validator.validate(params); + if (error) { + throw error; + } + } + } + + /** + * Throws an error if actionTypeConfig is invalid for given action type + */ + public validateActionTypeConfig(id: string, actionTypeConfig: Record) { + const actionType = this.get(id); + const validator = actionType.validate && actionType.validate.actionTypeConfig; + if (validator) { + const { error } = validator.validate(actionTypeConfig); + if (error) { + throw error; + } + } + } + + /** + * Executes an action type based on given parameters + */ + public async execute({ id, actionTypeConfig, params }: ExecuteOptions) { + const actionType = this.get(id); + this.validateActionTypeConfig(id, actionTypeConfig); + this.validateParams(id, params); + return await actionType.executor({ actionTypeConfig, params }); + } +} diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts new file mode 100644 index 0000000000000..0bbaf858bfce9 --- /dev/null +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -0,0 +1,132 @@ +/* + * 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 { SavedObjectsClient } from 'src/legacy/server/saved_objects'; +import { ActionTypeService } from './action_type_service'; +import { SavedObjectReference } from './types'; + +interface Action { + description: string; + actionTypeId: string; + actionTypeConfig: Record; +} + +interface CreateOptions { + data: Action; + options?: { + migrationVersion?: Record; + references?: SavedObjectReference[]; + }; +} + +interface FindOptions { + options?: { + perPage?: number; + page?: number; + search?: string; + defaultSearchOperator?: 'AND' | 'OR'; + searchFields?: string[]; + sortField?: string; + hasReference?: { + type: string; + id: string; + }; + fields?: string[]; + }; +} + +interface ConstructorOptions { + actionTypeService: ActionTypeService; + savedObjectsClient: SavedObjectsClient; +} + +interface UpdateOptions { + id: string; + data: Action; + options: { version?: string; references?: SavedObjectReference[] }; +} + +export class ActionsClient { + private savedObjectsClient: SavedObjectsClient; + private actionTypeService: ActionTypeService; + + constructor({ actionTypeService, savedObjectsClient }: ConstructorOptions) { + this.actionTypeService = actionTypeService; + this.savedObjectsClient = savedObjectsClient; + } + + /** + * Create an action + */ + public async create({ data, options }: CreateOptions) { + const { actionTypeId } = data; + this.actionTypeService.validateActionTypeConfig(actionTypeId, data.actionTypeConfig); + const actionWithSplitActionTypeConfig = this.moveEncryptedAttributesToSecrets(data); + return await this.savedObjectsClient.create('action', actionWithSplitActionTypeConfig, options); + } + + /** + * Get an action + */ + public async get({ id }: { id: string }) { + return await this.savedObjectsClient.get('action', id); + } + + /** + * Find actions + */ + public async find({ options = {} }: FindOptions) { + return await this.savedObjectsClient.find({ + ...options, + type: 'action', + }); + } + + /** + * Delete action + */ + public async delete({ id }: { id: string }) { + return await this.savedObjectsClient.delete('action', id); + } + + /** + * Update action + */ + public async update({ id, data, options = {} }: UpdateOptions) { + const { actionTypeId } = data; + // Throws an error if action type is invalid + this.actionTypeService.get(actionTypeId); + if (data.actionTypeConfig) { + this.actionTypeService.validateActionTypeConfig(actionTypeId, data.actionTypeConfig); + data = this.moveEncryptedAttributesToSecrets(data); + } + return await this.savedObjectsClient.update('action', id, data, options); + } + + /** + * Set actionTypeConfigSecrets values on a given action + */ + private moveEncryptedAttributesToSecrets(action: Action) { + const unencryptedAttributes = this.actionTypeService.getUnencryptedAttributes( + action.actionTypeId + ); + const config = { ...action.actionTypeConfig }; + const configSecrets: Record = {}; + for (const key of Object.keys(config)) { + if (unencryptedAttributes.includes(key)) { + continue; + } + configSecrets[key] = config[key]; + delete config[key]; + } + return { + ...action, + // Important these overwrite attributes in data for encryption purposes + actionTypeConfig: config, + actionTypeConfigSecrets: configSecrets, + }; + } +} diff --git a/x-pack/plugins/actions/server/create_fire_function.ts b/x-pack/plugins/actions/server/create_fire_function.ts new file mode 100644 index 0000000000000..8ca138dbead13 --- /dev/null +++ b/x-pack/plugins/actions/server/create_fire_function.ts @@ -0,0 +1,39 @@ +/* + * 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 { ActionTypeService } from './action_type_service'; +import { EncryptedSavedObjectsPlugin } from '../../encrypted_saved_objects'; + +interface CreateFireFunctionOptions { + actionTypeService: ActionTypeService; + encryptedSavedObjectsPlugin: EncryptedSavedObjectsPlugin; +} + +interface FireOptions { + id: string; + params: Record; + namespace?: string; +} + +export function createFireFunction({ + actionTypeService, + encryptedSavedObjectsPlugin, +}: CreateFireFunctionOptions) { + return async function fire({ id, params, namespace }: FireOptions) { + const action = await encryptedSavedObjectsPlugin.getDecryptedAsInternalUser('action', id, { + namespace, + }); + const mergedActionTypeConfig = { + ...action.attributes.actionTypeConfig, + ...action.attributes.actionTypeConfigSecrets, + }; + return await actionTypeService.execute({ + id: action.attributes.actionTypeId, + actionTypeConfig: mergedActionTypeConfig, + params, + }); + }; +} diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts new file mode 100644 index 0000000000000..bb5f94b843514 --- /dev/null +++ b/x-pack/plugins/actions/server/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export { init } from './init'; +export { ActionsPlugin } from './types'; +export { ActionsClient } from './actions_client'; diff --git a/x-pack/plugins/actions/server/init.ts b/x-pack/plugins/actions/server/init.ts new file mode 100644 index 0000000000000..74b92059c36fc --- /dev/null +++ b/x-pack/plugins/actions/server/init.ts @@ -0,0 +1,62 @@ +/* + * 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 { Legacy } from 'kibana'; +import { ActionsClient } from './actions_client'; +import { ActionTypeService } from './action_type_service'; +import { createFireFunction } from './create_fire_function'; +import { + createRoute, + deleteRoute, + findRoute, + getRoute, + updateRoute, + listActionTypesRoute, +} from './routes'; + +export function init(server: Legacy.Server) { + const actionsEnabled = server.config().get('xpack.actions.enabled'); + + if (!actionsEnabled) { + server.log(['info', 'actions'], 'Actions app disabled by configuration'); + return; + } + + // Encrypted attributes + server.plugins.encrypted_saved_objects!.registerType({ + type: 'action', + attributesToEncrypt: new Set(['actionTypeConfigSecrets']), + }); + + const actionTypeService = new ActionTypeService(); + + // Routes + createRoute(server); + deleteRoute(server); + getRoute(server); + findRoute(server); + updateRoute(server); + listActionTypesRoute(server); + + const fireFn = createFireFunction({ + actionTypeService, + encryptedSavedObjectsPlugin: server.plugins.encrypted_saved_objects!, + }); + + // Expose service to server + server.decorate('request', 'getActionsClient', function() { + const request = this; + const savedObjectsClient = request.getSavedObjectsClient(); + const actionsClient = new ActionsClient({ + savedObjectsClient, + actionTypeService, + }); + return actionsClient; + }); + server.expose('fire', fireFn); + server.expose('registerType', actionTypeService.register.bind(actionTypeService)); + server.expose('listTypes', actionTypeService.list.bind(actionTypeService)); +} diff --git a/x-pack/plugins/actions/server/routes/__jest__/_mock_server.ts b/x-pack/plugins/actions/server/routes/__jest__/_mock_server.ts new file mode 100644 index 0000000000000..3a10c49203e25 --- /dev/null +++ b/x-pack/plugins/actions/server/routes/__jest__/_mock_server.ts @@ -0,0 +1,66 @@ +/* + * 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 Hapi from 'hapi'; + +const defaultConfig = { + 'kibana.index': '.kibana', +}; + +export function createMockServer(config: Record = defaultConfig) { + const server = new Hapi.Server({ + port: 0, + }); + + const savedObjectsClient = { + errors: {} as any, + bulkCreate: jest.fn(), + bulkGet: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + get: jest.fn(), + update: jest.fn(), + }; + + const actionsClient = { + create: jest.fn(), + get: jest.fn(), + find: jest.fn(), + delete: jest.fn(), + update: jest.fn(), + fire: jest.fn(), + }; + + const actionTypeService = { + registerType: jest.fn(), + listTypes: jest.fn(), + }; + + server.config = () => { + return { + get(key: string) { + return config[key]; + }, + has(key: string) { + return config.hasOwnProperty(key); + }, + }; + }; + + server.register({ + name: 'actions', + register(pluginServer: Hapi.Server) { + pluginServer.expose('registerType', actionTypeService.registerType); + pluginServer.expose('listTypes', actionTypeService.listTypes); + }, + }); + + server.decorate('request', 'getSavedObjectsClient', () => savedObjectsClient); + server.decorate('request', 'getActionsClient', () => actionsClient); + + return { server, savedObjectsClient, actionsClient, actionTypeService }; +} diff --git a/x-pack/plugins/actions/server/routes/__jest__/create.test.ts b/x-pack/plugins/actions/server/routes/__jest__/create.test.ts new file mode 100644 index 0000000000000..a1e0321918d91 --- /dev/null +++ b/x-pack/plugins/actions/server/routes/__jest__/create.test.ts @@ -0,0 +1,80 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { createRoute } from '../create'; + +const { server, actionsClient } = createMockServer(); +createRoute(server); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +it('creates an action with proper parameters', async () => { + const request = { + method: 'POST', + url: '/api/action', + payload: { + attributes: { + description: 'My description', + actionTypeId: 'abc', + actionTypeConfig: { foo: true }, + }, + migrationVersion: { + abc: '1.2.3', + }, + references: [ + { + name: 'ref_0', + type: 'bcd', + id: '234', + }, + ], + }, + }; + + actionsClient.create.mockResolvedValueOnce({ success: true }); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual({ success: true }); + expect(actionsClient.create).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Object { + "data": Object { + "actionTypeConfig": Object { + "foo": true, + }, + "actionTypeId": "abc", + "description": "My description", + }, + "options": Object { + "migrationVersion": Object { + "abc": "1.2.3", + }, + "references": Array [ + Object { + "id": "234", + "name": "ref_0", + "type": "bcd", + }, + ], + }, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); +}); diff --git a/x-pack/plugins/actions/server/routes/__jest__/delete.test.ts b/x-pack/plugins/actions/server/routes/__jest__/delete.test.ts new file mode 100644 index 0000000000000..29269680cc2ef --- /dev/null +++ b/x-pack/plugins/actions/server/routes/__jest__/delete.test.ts @@ -0,0 +1,45 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { deleteRoute } from '../delete'; + +const { server, actionsClient } = createMockServer(); +deleteRoute(server); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +it('deletes an action with proper parameters', async () => { + const request = { + method: 'DELETE', + url: '/api/action/1', + }; + + actionsClient.delete.mockResolvedValueOnce({ success: true }); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual({ success: true }); + expect(actionsClient.delete).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Object { + "id": "1", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); +}); diff --git a/x-pack/plugins/actions/server/routes/__jest__/find.test.ts b/x-pack/plugins/actions/server/routes/__jest__/find.test.ts new file mode 100644 index 0000000000000..00b8e5c8c33bd --- /dev/null +++ b/x-pack/plugins/actions/server/routes/__jest__/find.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { findRoute } from '../find'; + +const { server, actionsClient } = createMockServer(); +findRoute(server); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +it('sends proper arguments to action find function', async () => { + const request = { + method: 'GET', + url: + '/api/action/_find?' + + 'per_page=1&' + + 'page=1&' + + 'search=text*&' + + 'default_search_operator=AND&' + + 'search_fields=description&' + + 'sort_field=description&' + + 'fields=description', + }; + + actionsClient.find.mockResolvedValueOnce({ success: true }); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual({ success: true }); + expect(actionsClient.find).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Object { + "options": Object { + "defaultSearchOperator": "AND", + "fields": Array [ + "description", + ], + "hasReference": undefined, + "page": 1, + "perPage": 1, + "search": "text*", + "searchFields": Array [ + "description", + ], + "sortField": "description", + }, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); +}); diff --git a/x-pack/plugins/actions/server/routes/__jest__/get.test.ts b/x-pack/plugins/actions/server/routes/__jest__/get.test.ts new file mode 100644 index 0000000000000..36e37b1d3688e --- /dev/null +++ b/x-pack/plugins/actions/server/routes/__jest__/get.test.ts @@ -0,0 +1,45 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { getRoute } from '../get'; + +const { server, actionsClient } = createMockServer(); +getRoute(server); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +it('calls get with proper parameters', async () => { + const request = { + method: 'GET', + url: '/api/action/1', + }; + + actionsClient.get.mockResolvedValueOnce({ success: true }); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual({ success: true }); + expect(actionsClient.get).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Object { + "id": "1", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); +}); diff --git a/x-pack/plugins/actions/server/routes/__jest__/list_action_types.test.ts b/x-pack/plugins/actions/server/routes/__jest__/list_action_types.test.ts new file mode 100644 index 0000000000000..3f3baf5d43ec6 --- /dev/null +++ b/x-pack/plugins/actions/server/routes/__jest__/list_action_types.test.ts @@ -0,0 +1,41 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { listActionTypesRoute } from '../list_action_types'; + +const { server, actionTypeService } = createMockServer(); +listActionTypesRoute(server); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +it('calls the list function', async () => { + const request = { + method: 'GET', + url: '/api/action/types', + }; + + actionTypeService.listTypes.mockResolvedValueOnce({ success: true }); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual({ success: true }); + expect(actionTypeService.listTypes).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); +}); diff --git a/x-pack/plugins/actions/server/routes/__jest__/update.test.ts b/x-pack/plugins/actions/server/routes/__jest__/update.test.ts new file mode 100644 index 0000000000000..59e75b240ec2c --- /dev/null +++ b/x-pack/plugins/actions/server/routes/__jest__/update.test.ts @@ -0,0 +1,77 @@ +/* + * 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 { createMockServer } from './_mock_server'; +import { updateRoute } from '../update'; + +const { server, actionsClient } = createMockServer(); +updateRoute(server); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +it('calls the update function with proper parameters', async () => { + const request = { + method: 'PUT', + url: '/api/action/1', + payload: { + attributes: { + description: 'My description', + actionTypeId: 'abc', + actionTypeConfig: { foo: true }, + }, + version: '2', + references: [ + { + name: 'ref_0', + type: 'bcd', + id: '234', + }, + ], + }, + }; + + actionsClient.update.mockResolvedValueOnce({ success: true }); + const { payload, statusCode } = await server.inject(request); + expect(statusCode).toBe(200); + const response = JSON.parse(payload); + expect(response).toEqual({ success: true }); + expect(actionsClient.update).toMatchInlineSnapshot(` +[MockFunction] { + "calls": Array [ + Array [ + Object { + "data": Object { + "actionTypeConfig": Object { + "foo": true, + }, + "actionTypeId": "abc", + "description": "My description", + }, + "id": "1", + "options": Object { + "references": Array [ + Object { + "id": "234", + "name": "ref_0", + "type": "bcd", + }, + ], + "version": "2", + }, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], +} +`); +}); diff --git a/x-pack/plugins/actions/server/routes/create.ts b/x-pack/plugins/actions/server/routes/create.ts new file mode 100644 index 0000000000000..011c929c16b0e --- /dev/null +++ b/x-pack/plugins/actions/server/routes/create.ts @@ -0,0 +1,71 @@ +/* + * 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 Joi from 'joi'; +import Hapi from 'hapi'; +import { WithoutQueryAndParams, SavedObjectReference } from '../types'; + +interface CreateRequest extends WithoutQueryAndParams { + query: { + overwrite: boolean; + }; + params: { + id?: string; + }; + payload: { + attributes: { + description: string; + actionTypeId: string; + actionTypeConfig: Record; + }; + migrationVersion?: Record; + references: SavedObjectReference[]; + }; +} + +export function createRoute(server: Hapi.Server) { + server.route({ + method: 'POST', + path: `/api/action`, + options: { + validate: { + options: { + abortEarly: false, + }, + payload: Joi.object().keys({ + attributes: Joi.object() + .keys({ + description: Joi.string().required(), + actionTypeId: Joi.string().required(), + actionTypeConfig: Joi.object(), + }) + .required(), + migrationVersion: Joi.object().optional(), + references: Joi.array() + .items( + Joi.object().keys({ + name: Joi.string().required(), + type: Joi.string().required(), + id: Joi.string().required(), + }) + ) + .default([]), + }), + }, + }, + async handler(request: CreateRequest) { + const actionsClient = request.getActionsClient(); + + return await actionsClient.create({ + data: request.payload.attributes, + options: { + migrationVersion: request.payload.migrationVersion, + references: request.payload.references, + }, + }); + }, + }); +} diff --git a/x-pack/plugins/actions/server/routes/delete.ts b/x-pack/plugins/actions/server/routes/delete.ts new file mode 100644 index 0000000000000..4b32386f75b4d --- /dev/null +++ b/x-pack/plugins/actions/server/routes/delete.ts @@ -0,0 +1,35 @@ +/* + * 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 Hapi from 'hapi'; +import Joi from 'joi'; + +interface DeleteRequest extends Hapi.Request { + params: { + id: string; + }; +} + +export function deleteRoute(server: Hapi.Server) { + server.route({ + method: 'DELETE', + path: `/api/action/{id}`, + options: { + validate: { + params: Joi.object() + .keys({ + id: Joi.string().required(), + }) + .required(), + }, + }, + async handler(request: DeleteRequest) { + const { id } = request.params; + const actionsClient = request.getActionsClient(); + return await actionsClient.delete({ id }); + }, + }); +} diff --git a/x-pack/plugins/actions/server/routes/find.ts b/x-pack/plugins/actions/server/routes/find.ts new file mode 100644 index 0000000000000..39f09d1007f0c --- /dev/null +++ b/x-pack/plugins/actions/server/routes/find.ts @@ -0,0 +1,82 @@ +/* + * 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 Joi from 'joi'; +import Hapi from 'hapi'; + +import { WithoutQueryAndParams } from '../types'; + +interface FindRequest extends WithoutQueryAndParams { + query: { + per_page: number; + page: number; + search?: string; + default_search_operator: 'AND' | 'OR'; + search_fields?: string[]; + sort_field?: string; + has_reference?: { + type: string; + id: string; + }; + fields?: string[]; + }; +} + +export function findRoute(server: Hapi.Server) { + server.route({ + method: 'GET', + path: `/api/action/_find`, + options: { + validate: { + query: Joi.object() + .keys({ + per_page: Joi.number() + .min(0) + .default(20), + page: Joi.number() + .min(1) + .default(1), + search: Joi.string() + .allow('') + .optional(), + default_search_operator: Joi.string() + .valid('OR', 'AND') + .default('OR'), + search_fields: Joi.array() + .items(Joi.string()) + .single(), + sort_field: Joi.string(), + has_reference: Joi.object() + .keys({ + type: Joi.string().required(), + id: Joi.string().required(), + }) + .optional(), + fields: Joi.array() + .items(Joi.string()) + .single(), + }) + .default(), + }, + }, + async handler(request: FindRequest) { + const query = request.query; + const actionsClient = request.getActionsClient(); + return await actionsClient.find({ + options: { + perPage: query.per_page, + page: query.page, + search: query.search, + defaultSearchOperator: query.default_search_operator, + searchFields: query.search_fields, + sortField: query.sort_field, + hasReference: query.has_reference, + fields: query.fields, + }, + }); + }, + }); +} diff --git a/x-pack/plugins/actions/server/routes/get.ts b/x-pack/plugins/actions/server/routes/get.ts new file mode 100644 index 0000000000000..5a8e617d36331 --- /dev/null +++ b/x-pack/plugins/actions/server/routes/get.ts @@ -0,0 +1,35 @@ +/* + * 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 Joi from 'joi'; +import Hapi from 'hapi'; + +interface GetRequest extends Hapi.Request { + params: { + id: string; + }; +} + +export function getRoute(server: Hapi.Server) { + server.route({ + method: 'GET', + path: `/api/action/{id}`, + options: { + validate: { + params: Joi.object() + .keys({ + id: Joi.string().required(), + }) + .required(), + }, + }, + async handler(request: GetRequest) { + const { id } = request.params; + const actionsClient = request.getActionsClient(); + return await actionsClient.get({ id }); + }, + }); +} diff --git a/x-pack/plugins/actions/server/routes/index.ts b/x-pack/plugins/actions/server/routes/index.ts new file mode 100644 index 0000000000000..7ed6dd222fe00 --- /dev/null +++ b/x-pack/plugins/actions/server/routes/index.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export { createRoute } from './create'; +export { deleteRoute } from './delete'; +export { findRoute } from './find'; +export { getRoute } from './get'; +export { updateRoute } from './update'; +export { listActionTypesRoute } from './list_action_types'; diff --git a/x-pack/plugins/actions/server/routes/list_action_types.ts b/x-pack/plugins/actions/server/routes/list_action_types.ts new file mode 100644 index 0000000000000..9ff04af72beaa --- /dev/null +++ b/x-pack/plugins/actions/server/routes/list_action_types.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 Hapi from 'hapi'; + +export function listActionTypesRoute(server: Hapi.Server) { + server.route({ + method: 'GET', + path: `/api/action/types`, + async handler(request: Hapi.Request) { + return request.server.plugins.actions!.listTypes(); + }, + }); +} diff --git a/x-pack/plugins/actions/server/routes/update.ts b/x-pack/plugins/actions/server/routes/update.ts new file mode 100644 index 0000000000000..6948f77ea2137 --- /dev/null +++ b/x-pack/plugins/actions/server/routes/update.ts @@ -0,0 +1,73 @@ +/* + * 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 Joi from 'joi'; +import Hapi from 'hapi'; + +import { SavedObjectReference } from '../types'; + +interface UpdateRequest extends Hapi.Request { + payload: { + attributes: { + description: string; + actionTypeId: string; + actionTypeConfig: Record; + }; + version?: string; + references: SavedObjectReference[]; + }; +} + +export function updateRoute(server: Hapi.Server) { + server.route({ + method: 'PUT', + path: `/api/action/{id}`, + options: { + validate: { + options: { + abortEarly: false, + }, + params: Joi.object() + .keys({ + id: Joi.string().required(), + }) + .required(), + payload: Joi.object() + .keys({ + attributes: Joi.object() + .keys({ + description: Joi.string().required(), + actionTypeId: Joi.string().required(), + actionTypeConfig: Joi.object(), + }) + .required(), + version: Joi.string(), + references: Joi.array() + .items( + Joi.object().keys({ + name: Joi.string().required(), + type: Joi.string().required(), + id: Joi.string().required(), + }) + ) + .default([]), + }) + .required(), + }, + }, + async handler(request: UpdateRequest) { + const { id } = request.params; + const { attributes, version, references } = request.payload; + const options = { version, references }; + const actionsClient = request.getActionsClient(); + return await actionsClient.update({ + id, + data: attributes, + options, + }); + }, + }); +} diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts new file mode 100644 index 0000000000000..02282d25a9e56 --- /dev/null +++ b/x-pack/plugins/actions/server/types.ts @@ -0,0 +1,21 @@ +/* + * 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 { ActionTypeService } from './action_type_service'; + +export type WithoutQueryAndParams = Pick>; + +export interface SavedObjectReference { + name: string; + type: string; + id: string; +} + +export interface ActionsPlugin { + registerType: ActionTypeService['register']; + listTypes: ActionTypeService['list']; + fire: ({ id, params }: { id: string; params: Record }) => Promise; +} diff --git a/x-pack/test/api_integration/apis/actions/create.ts b/x-pack/test/api_integration/apis/actions/create.ts new file mode 100644 index 0000000000000..41b72860d1ca4 --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/create.ts @@ -0,0 +1,140 @@ +/* + * 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 expect from '@kbn/expect'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function createActionTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + + describe('create', () => { + it('should return 200 when creating an action', async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'My action', + actionTypeId: 'test', + actionTypeConfig: {}, + }, + }) + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + type: 'action', + id: resp.body.id, + attributes: { + description: 'My action', + actionTypeId: 'test', + actionTypeConfig: {}, + }, + references: [], + updated_at: resp.body.updated_at, + version: resp.body.version, + }); + expect(typeof resp.body.id).to.be('string'); + }); + }); + + it('should not return encrypted attributes', async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'My action', + actionTypeId: 'test', + actionTypeConfig: { + unencrypted: 'unencrypted text', + encrypted: 'something encrypted', + }, + }, + }) + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + type: 'action', + id: resp.body.id, + attributes: { + description: 'My action', + actionTypeId: 'test', + actionTypeConfig: { + unencrypted: 'unencrypted text', + }, + }, + references: [], + updated_at: resp.body.updated_at, + version: resp.body.version, + }); + expect(typeof resp.body.id).to.be('string'); + }); + }); + + it(`should return 400 when action type isn't registered`, async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'My action', + actionTypeId: 'unregistered-action-type', + actionTypeConfig: {}, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'Action type "unregistered-action-type" is not registered.', + }); + }); + }); + + it('should return 400 when payload is empty and invalid', async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({}) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'child "attributes" fails because ["attributes" is required]', + validation: { + source: 'payload', + keys: ['attributes'], + }, + }); + }); + }); + + it('should return 400 when payload attributes are empty and invalid', async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + attributes: {}, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'child "attributes" fails because [child "description" fails because ["description" is required], child "actionTypeId" fails because ["actionTypeId" is required]]', + validation: { + source: 'payload', + keys: ['attributes.description', 'attributes.actionTypeId'], + }, + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/actions/delete.ts b/x-pack/test/api_integration/apis/actions/delete.ts new file mode 100644 index 0000000000000..d88fbdb36e7e8 --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/delete.ts @@ -0,0 +1,44 @@ +/* + * 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 expect from '@kbn/expect'; + +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function deleteActionTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('delete', () => { + beforeEach(() => esArchiver.load('alerting/basic')); + afterEach(() => esArchiver.unload('alerting/basic')); + + it('should return 200 when deleting an action', async () => { + await supertest + .delete('/api/action/1') + .set('kbn-xsrf', 'foo') + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({}); + }); + }); + + it(`should return 404 when action doesn't exist`, async () => { + await supertest + .delete('/api/action/2') + .set('kbn-xsrf', 'foo') + .expect(404) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Saved object [action/2] not found', + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/actions/find.ts b/x-pack/test/api_integration/apis/actions/find.ts new file mode 100644 index 0000000000000..5ba3bd09c8c40 --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/find.ts @@ -0,0 +1,71 @@ +/* + * 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 expect from '@kbn/expect'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function findActionTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('find', () => { + before(() => esArchiver.load('alerting/basic')); + after(() => esArchiver.unload('alerting/basic')); + + it('should return 200 with individual responses', async () => { + await supertest + .get('/api/action/_find?fields=description') + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + id: '1', + type: 'action', + version: resp.body.saved_objects[0].version, + references: [], + attributes: { + description: 'My description', + }, + }, + ], + }); + }); + }); + + it('should not return encrypted attributes', async () => { + await supertest + .get('/api/action/_find') + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + id: '1', + type: 'action', + version: resp.body.saved_objects[0].version, + references: [], + attributes: { + description: 'My description', + actionTypeId: 'test', + actionTypeConfig: { + unencrypted: 'unencrypted text', + }, + }, + }, + ], + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/actions/get.ts b/x-pack/test/api_integration/apis/actions/get.ts new file mode 100644 index 0000000000000..ebfb8686fb33f --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/get.ts @@ -0,0 +1,53 @@ +/* + * 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 expect from '@kbn/expect'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function getActionTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('get', () => { + before(() => esArchiver.load('alerting/basic')); + after(() => esArchiver.unload('alerting/basic')); + + it('should return 200 when finding a record and not return encrypted attributes', async () => { + await supertest + .get('/api/action/1') + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + id: '1', + type: 'action', + references: [], + version: resp.body.version, + attributes: { + actionTypeId: 'test', + description: 'My description', + actionTypeConfig: { + unencrypted: 'unencrypted text', + }, + }, + }); + }); + }); + + it('should return 404 when not finding a record', async () => { + await supertest + .get('/api/action/2') + .expect(404) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Saved object [action/2] not found', + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/actions/index.ts b/x-pack/test/api_integration/apis/actions/index.ts new file mode 100644 index 0000000000000..41b8ea8d8793c --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/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 { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function alertingTests({ loadTestFile }: KibanaFunctionalTestDefaultProviders) { + describe('Actions', () => { + loadTestFile(require.resolve('./create')); + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./list_action_types')); + loadTestFile(require.resolve('./update')); + }); +} diff --git a/x-pack/test/api_integration/apis/actions/list_action_types.ts b/x-pack/test/api_integration/apis/actions/list_action_types.ts new file mode 100644 index 0000000000000..29e5a3e4e4b33 --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/list_action_types.ts @@ -0,0 +1,31 @@ +/* + * 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 expect from '@kbn/expect'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function listActionTypesTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + + describe('list_action_types', () => { + it('should return 200 with list of action types containing defaults', async () => { + await supertest + .get('/api/action/types') + .expect(200) + .then((resp: any) => { + function createActionTypeMatcher(id: string, name: string) { + return (actionType: { id: string; name: string }) => { + return actionType.id === id && actionType.name === name; + }; + } + // Check for values explicitly in order to avoid this test failing each time plugins register + // a new action type + expect(resp.body.some(createActionTypeMatcher('test', 'Test'))).to.be(true); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/actions/update.ts b/x-pack/test/api_integration/apis/actions/update.ts new file mode 100644 index 0000000000000..e7a880c128f11 --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/update.ts @@ -0,0 +1,197 @@ +/* + * 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 expect from '@kbn/expect'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function updateActionTests({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('update', () => { + beforeEach(() => esArchiver.load('alerting/basic')); + afterEach(() => esArchiver.unload('alerting/basic')); + + it('should return 200 when updating a document', async () => { + await supertest + .put('/api/action/1') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + actionTypeId: 'test', + description: 'My description updated', + actionTypeConfig: {}, + }, + }) + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + id: '1', + type: 'action', + references: [], + version: resp.body.version, + updated_at: resp.body.updated_at, + attributes: { + actionTypeId: 'test', + description: 'My description updated', + actionTypeConfig: {}, + }, + }); + }); + }); + + it('should support partial updates', async () => { + await supertest + .put('/api/action/1') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + actionTypeId: 'test', + description: 'My description updated again', + }, + }) + .expect(200); + await supertest + .get('/api/action/1') + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + id: '1', + type: 'action', + references: [], + version: resp.body.version, + updated_at: resp.body.updated_at, + attributes: { + actionTypeId: 'test', + description: 'My description updated again', + actionTypeConfig: { + unencrypted: 'unencrypted text', + }, + }, + }); + }); + }); + + it('should not be able to pass null to actionTypeConfig', async () => { + await supertest + .put('/api/action/1') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + actionTypeId: 'test', + description: 'My description updated', + actionTypeConfig: null, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'child "attributes" fails because [child "actionTypeConfig" fails because ["actionTypeConfig" must be an object]]', + validation: { + source: 'payload', + keys: ['attributes.actionTypeConfig'], + }, + }); + }); + }); + + it('should not return encrypted attributes', async () => { + await supertest + .put('/api/action/1') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + actionTypeId: 'test', + description: 'My description updated', + actionTypeConfig: { + unencrypted: 'unencrypted text', + encrypted: 'something encrypted', + }, + }, + }) + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + id: '1', + type: 'action', + references: [], + version: resp.body.version, + updated_at: resp.body.updated_at, + attributes: { + actionTypeId: 'test', + description: 'My description updated', + actionTypeConfig: { + unencrypted: 'unencrypted text', + }, + }, + }); + }); + }); + + it('should return 404 when updating a non existing document', async () => { + await supertest + .put('/api/action/2') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + actionTypeId: 'test', + description: 'My description updated', + actionTypeConfig: {}, + }, + }) + .expect(404) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Saved object [action/2] not found', + }); + }); + }); + + it('should return 400 when payload is empty and invalid', async () => { + await supertest + .put('/api/action/1') + .set('kbn-xsrf', 'foo') + .send({}) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'child "attributes" fails because ["attributes" is required]', + validation: { source: 'payload', keys: ['attributes'] }, + }); + }); + }); + + it('should return 400 when payload attributes are empty and invalid', async () => { + await supertest + .put('/api/action/1') + .set('kbn-xsrf', 'foo') + .send({ + attributes: {}, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'child "attributes" fails because [child "description" fails because ["description" is required], child "actionTypeId" fails because ["actionTypeId" is required]]', + validation: { + source: 'payload', + keys: ['attributes.description', 'attributes.actionTypeId'], + }, + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/index.js b/x-pack/test/api_integration/apis/index.js index 3bde650b83599..950c8277a5e0c 100644 --- a/x-pack/test/api_integration/apis/index.js +++ b/x-pack/test/api_integration/apis/index.js @@ -8,6 +8,7 @@ export default function ({ loadTestFile }) { describe('apis', function () { this.tags('ciGroup6'); + loadTestFile(require.resolve('./actions')); loadTestFile(require.resolve('./es')); loadTestFile(require.resolve('./security')); loadTestFile(require.resolve('./monitoring')); diff --git a/x-pack/test/api_integration/config.js b/x-pack/test/api_integration/config.js index eafad98a0e655..5107598cb0a1e 100644 --- a/x-pack/test/api_integration/config.js +++ b/x-pack/test/api_integration/config.js @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import path from 'path'; import { EsProvider, EsSupertestWithoutAuthProvider, @@ -62,6 +63,7 @@ export default async function ({ readConfigFile }) { serverArgs: [ ...xPackFunctionalTestsConfig.get('kbnTestServer.serverArgs'), '--optimize.enabled=false', + `--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'alerts')}`, ], }, esTestCluster: { diff --git a/x-pack/test/api_integration/fixtures/plugins/alerts/index.ts b/x-pack/test/api_integration/fixtures/plugins/alerts/index.ts new file mode 100644 index 0000000000000..115c91f3acc62 --- /dev/null +++ b/x-pack/test/api_integration/fixtures/plugins/alerts/index.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +// eslint-disable-next-line import/no-default-export +export default function(kibana: any) { + return new kibana.Plugin({ + require: ['actions'], + name: 'alerts', + init(server: any) { + server.plugins.actions.registerType({ + id: 'test', + name: 'Test', + unencryptedAttributes: ['unencrypted'], + async executor({ actionTypeConfig, params }: { actionTypeConfig: any; params: any }) { + return { success: true, actionTypeConfig, params }; + }, + }); + }, + }); +} diff --git a/x-pack/test/api_integration/fixtures/plugins/alerts/package.json b/x-pack/test/api_integration/fixtures/plugins/alerts/package.json new file mode 100644 index 0000000000000..836fa09855d8f --- /dev/null +++ b/x-pack/test/api_integration/fixtures/plugins/alerts/package.json @@ -0,0 +1,7 @@ +{ + "name": "alerts", + "version": "0.0.0", + "kibana": { + "version": "kibana" + } +} diff --git a/x-pack/test/functional/es_archives/alerting/basic/data.json b/x-pack/test/functional/es_archives/alerting/basic/data.json new file mode 100644 index 0000000000000..e1f889a551164 --- /dev/null +++ b/x-pack/test/functional/es_archives/alerting/basic/data.json @@ -0,0 +1,18 @@ +{ + "value": { + "id": "action:1", + "index": ".kibana", + "source": { + "action": { + "description": "My description", + "actionTypeId": "test", + "actionTypeConfig": { + "unencrypted" : "unencrypted text" + }, + "actionTypeConfigSecrets": "eCd4Z7Y3L6dVGNgpxHIOQR5rmP2UlxdUk7dFNAPLPmM96UqnyQqW0oFUWjox9SsvJtcN1Rdpst7HVxXRUPYs6XP0Dk7xcdNszXSYYnq5DcjEi+bhGPe3Ce00srY7NCMc0rZiTBn1KfW2xuaWl8qy1kYCiu+hq7eKgnJ/YIU9Fg==" + }, + "type": "action", + "migrationVersion": {} + } + } +} diff --git a/x-pack/test/functional/es_archives/alerting/basic/mappings.json b/x-pack/test/functional/es_archives/alerting/basic/mappings.json new file mode 100644 index 0000000000000..0113e064db3ab --- /dev/null +++ b/x-pack/test/functional/es_archives/alerting/basic/mappings.json @@ -0,0 +1,100 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-1", + "number_of_replicas": "0" + } + }, + "mappings": { + "doc": { + "dynamic": "strict", + "properties": { + "spaceId": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "index-pattern": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "space": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "action": { + "properties": { + "description": { + "type": "text" + }, + "actionTypeId": { + "type": "keyword" + }, + "actionTypeConfig": { + "dynamic": "true", + "type": "object" + }, + "actionTypeConfigSecrets": { + "type": "binary" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + } + } + } + } + } +} diff --git a/x-pack/test/plugin_api_integration/config.js b/x-pack/test/plugin_api_integration/config.js index 1af76e5d134d2..cfb056bf685a3 100644 --- a/x-pack/test/plugin_api_integration/config.js +++ b/x-pack/test/plugin_api_integration/config.js @@ -19,6 +19,7 @@ export default async function ({ readConfigFile }) { testFiles: [ require.resolve('./test_suites/task_manager'), require.resolve('./test_suites/encrypted_saved_objects'), + require.resolve('./test_suites/actions'), ], services: { retry: kibanaFunctionalConfig.get('services.retry'), diff --git a/x-pack/test/plugin_api_integration/plugins/actions/index.ts b/x-pack/test/plugin_api_integration/plugins/actions/index.ts new file mode 100644 index 0000000000000..68296b06ed1d0 --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/actions/index.ts @@ -0,0 +1,42 @@ +/* + * 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 Joi from 'joi'; +import { Legacy } from 'kibana'; + +// eslint-disable-next-line import/no-default-export +export default function actionsPlugin(kibana: any) { + return new kibana.Plugin({ + id: 'actions-test', + require: ['actions'], + init(server: Legacy.Server) { + server.route({ + method: 'POST', + path: '/api/action/{id}/fire', + options: { + validate: { + params: Joi.object() + .keys({ + id: Joi.string().required(), + }) + .required(), + payload: Joi.object() + .keys({ + params: Joi.object(), + }) + .required(), + }, + }, + async handler(request: any) { + return await request.server.plugins.actions.fire({ + id: request.params.id, + params: request.payload.params, + }); + }, + }); + }, + }); +} diff --git a/x-pack/test/plugin_api_integration/plugins/actions/package.json b/x-pack/test/plugin_api_integration/plugins/actions/package.json new file mode 100644 index 0000000000000..d6dbd425a8026 --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/actions/package.json @@ -0,0 +1,4 @@ +{ + "name": "actions-test", + "version": "kibana" + } \ No newline at end of file diff --git a/x-pack/test/plugin_api_integration/test_suites/actions/actions.ts b/x-pack/test/plugin_api_integration/test_suites/actions/actions.ts new file mode 100644 index 0000000000000..5e0a253a5f1a4 --- /dev/null +++ b/x-pack/test/plugin_api_integration/test_suites/actions/actions.ts @@ -0,0 +1,69 @@ +/* + * 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 expect from '@kbn/expect'; +import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService }: KibanaFunctionalTestDefaultProviders) { + const supertest = getService('supertest'); + + describe('encrypted attributes', () => { + after(async () => { + const { body: findResult } = await supertest.get('/api/action/_find').expect(200); + await Promise.all( + findResult.saved_objects.map(({ id }: { id: string }) => { + return supertest + .delete(`/api/action/${id}`) + .set('kbn-xsrf', 'foo') + .expect(200); + }) + ); + }); + + it('decrypts attributes and joins on actionTypeConfig when firing', async () => { + // Create an action + const { body: createdAction } = await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'My description', + actionTypeId: 'test', + actionTypeConfig: { + unencrypted: 'not encrypted value', + encrypted: 'encrypted by default value', + }, + }, + }) + .expect(200); + + await supertest + .post(`/api/action/${createdAction.id}/fire`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + foo: true, + bar: false, + }, + }) + .expect(200) + .then((resp: any) => { + expect(resp.body).to.eql({ + success: true, + actionTypeConfig: { + unencrypted: 'not encrypted value', + encrypted: 'encrypted by default value', + }, + params: { + foo: true, + bar: false, + }, + }); + }); + }); + }); +} diff --git a/x-pack/test/plugin_api_integration/test_suites/actions/index.ts b/x-pack/test/plugin_api_integration/test_suites/actions/index.ts new file mode 100644 index 0000000000000..f123cb366cec5 --- /dev/null +++ b/x-pack/test/plugin_api_integration/test_suites/actions/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 { KibanaFunctionalTestDefaultProviders } from '../../../types/providers'; + +// eslint-disable-next-line import/no-default-export +export default function({ loadTestFile }: KibanaFunctionalTestDefaultProviders) { + describe('actions', function actionsSuite() { + this.tags('ciGroup2'); + loadTestFile(require.resolve('./actions')); + }); +} diff --git a/x-pack/test/ui_capabilities/common/saved_objects_management_builder.ts b/x-pack/test/ui_capabilities/common/saved_objects_management_builder.ts index 3beae32d5f93b..f856128669920 100644 --- a/x-pack/test/ui_capabilities/common/saved_objects_management_builder.ts +++ b/x-pack/test/ui_capabilities/common/saved_objects_management_builder.ts @@ -56,6 +56,7 @@ export class SavedObjectsManagementBuilder { 'siem-ui-timeline', 'siem-ui-timeline-note', 'siem-ui-timeline-pinned-event', + 'action', ]; } diff --git a/x-pack/typings/hapi.d.ts b/x-pack/typings/hapi.d.ts index 9fedea3e541ad..16a4b640292ee 100644 --- a/x-pack/typings/hapi.d.ts +++ b/x-pack/typings/hapi.d.ts @@ -10,12 +10,17 @@ import { CloudPlugin } from '../plugins/cloud'; import { EncryptedSavedObjectsPlugin } from '../plugins/encrypted_saved_objects'; import { XPackMainPlugin } from '../plugins/xpack_main/xpack_main'; import { SecurityPlugin } from '../plugins/security'; +import { ActionsPlugin, ActionsClient } from '../plugins/actions'; declare module 'hapi' { + interface Request { + getActionsClient: () => ActionsClient; + } interface PluginProperties { cloud?: CloudPlugin; xpack_main: XPackMainPlugin; security?: SecurityPlugin; encrypted_saved_objects?: EncryptedSavedObjectsPlugin; + actions?: ActionsPlugin; } }