diff --git a/x-pack/legacy/plugins/actions/README.md b/x-pack/legacy/plugins/actions/README.md index 23804773abd7db..0dca7d2e12f06b 100644 --- a/x-pack/legacy/plugins/actions/README.md +++ b/x-pack/legacy/plugins/actions/README.md @@ -1,17 +1,21 @@ # Kibana actions -The Kibana actions plugin provides a common place to execute actions. You can: +The Kibana actions plugin provides a framework to create executable actions. You can: -- Register an action type -- View a list of registered action types -- Fire an action either manually or by using an alert -- Perform CRUD on actions with encrypted configurations +- Register an action type and associate a JavaScript function to run when actions + are executed. +- Get a list of registered action types +- Create an action from an action type and encrypted configuration object. +- Get a list of actions that have been created. +- Execute an action, passing it a parameter object. +- Perform CRUD operations on actions. ## Terminology -**Action Type**: A programatically defined integration with another service, with an expected set of configuration and parameters properties. +**Action Type**: A programatically defined integration with another service, with an expected set of configuration and parameters properties, typically defined with a schema. Plugins can add new +action types. -**Action**: A user-defined configuration that satisfies an action type's expected configuration. +**Action**: A configuration object associated with an action type, that is ready to be executed. The configuration is persisted via Saved Objects, and some/none/all of the configuration properties can be stored encrypted. ## Usage @@ -32,10 +36,12 @@ The following table describes the properties of the `options` object. |id|Unique identifier for the action type. For convention, ids starting with `.` are reserved for built in action types. We recommend using a convention like `.mySpecialAction` for your action types.|string| |name|A user-friendly name for the action type. These will be displayed in dropdowns when chosing action types.|string| |unencryptedAttributes|A list of opt-out attributes that don't need to be encrypted. These attributes won't need to be re-entered on import / export when the feature becomes available. These attributes will also be readable / displayed when it comes to a table / edit screen.|array of strings| -|validate.params|When developing an action type, it needs to accept parameters to know what to do with the action. (Example to, from, subject, body of an email). Use joi object validation if you would like `params` to be validated before being passed to the executor.

Technically, the value of this property should have a property named `validate()` which is a function that takes a params object to validate and returns an object `{error, value}`, where error is a validation error, and value is the sanitized version of the input object.|Joi schema| -|validate.config|Similar to params, a config is required when creating an action (for example host, port, username, and password of an email server). Use the joi object validation if you would like the config to be validated before being passed to the executor.|Joi schema| +|validate.params|When developing an action type, it needs to accept parameters to know what to do with the action. (Example to, from, subject, body of an email). See the current built-in email action type for an example of the state-of-the-art validation.

Technically, the value of this property should have a property named `validate()` which is a function that takes a params object to validate and returns a sanitized version of that object to pass to the execution function. Validation errors should be thrown from the `validate()` function and will be available as an error message|schema / validation function| +|validate.config|Similar to params, a config is required when creating an action (for example host, port, username, and password of an email server). |schema / validation function| |executor|This is where the code of an action type lives. This is a function gets called for executing an action from either alerting or manually by using the exposed function (see firing actions). For full details, see executor section below.|Function| +**Important** - The config object is persisted in ElasticSearch and updated via the ElasticSearch update document API. This API allows "partial updates" - and this can cause issues with the encryption used on specified properties. So, a `validate()` function should return values for all configuration properties, so that partial updates do not occur. Setting property values to `null` rather than `undefined`, or not including a property in the config object, is all you need to do to ensure partial updates won't occur. + ### Executor This is the primary function for an action type. Whenever the action needs to execute, this function will perform the action. It receives a variety of parameters. The following table describes the properties that the executor receives. @@ -52,37 +58,9 @@ This is the primary function for an action type. Whenever the action needs to ex ### Example -Below is an example email action type. The attributes `host` and `port` are configured to be unencrypted by using the `unencryptedAttributes` attribute. +The built-in email action type provides a good example of creating an action type with non-trivial configuration and params: +[x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts](server/builtin_action_types/email.ts) -``` -server.plugins.actions.registerType({ - id: 'smtp', - name: 'Email', - unencryptedAttributes: ['host', 'port'], - validate: { - params: Joi.object() - .keys({ - to: Joi.array().items(Joi.string()).required(), - from: Joi.string().required(), - subject: Joi.string().required(), - body: Joi.string().required(), - }) - .required(), - config: Joi.object() - .keys({ - host: Joi.string().required(), - port: Joi.number().default(465), - username: Joi.string().required(), - password: Joi.string().required(), - }) - .required(), - }, - async executor({ config, params, services }) { - const transporter = nodemailer. createTransport(config); - await transporter.sendMail(params); - }, -}); -``` ## RESTful API @@ -223,7 +201,7 @@ This action type uses [nodemailer](https://nodemailer.com/about/) to send emails Either the property `service` must be provided, or the `host` and `port` properties must be provided. If `service` is provided, `host`, `port` and `secure` are ignored. For more information on the `gmail` service value specifically, see the [nodemailer gmail documentation](https://nodemailer.com/usage/using-gmail/). -The `security` property defaults to `false`. See the [nodemailer TLS documentation](https://nodemailer.com/smtp/#tls-options) for more information. +The `secure` property defaults to `false`. See the [nodemailer TLS documentation](https://nodemailer.com/smtp/#tls-options) for more information. The `from` field can be specified as in typical `"user@host-name"` format, or as `"human name "` format. See the [nodemailer address documentation](https://nodemailer.com/message/addresses/) for more information. diff --git a/x-pack/legacy/plugins/actions/server/actions_client.test.ts b/x-pack/legacy/plugins/actions/server/actions_client.test.ts index da74623f853d4d..a7ed62308ced65 100644 --- a/x-pack/legacy/plugins/actions/server/actions_client.test.ts +++ b/x-pack/legacy/plugins/actions/server/actions_client.test.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import Joi from 'joi'; +import { schema } from '@kbn/config-schema'; + import { ActionTypeRegistry } from './action_type_registry'; import { ActionsClient } from './actions_client'; import { taskManagerMock } from '../../task_manager/task_manager.mock'; @@ -69,20 +70,20 @@ describe('create()', () => { expect(result).toEqual(expectedResult); expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); expect(savedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - "action", - Object { - "actionTypeConfig": Object {}, - "actionTypeConfigSecrets": Object {}, - "actionTypeId": "my-action-type", - "description": "my description", - }, - Object { - "migrationVersion": Object {}, - "references": Array [], - }, -] -`); + Array [ + "action", + Object { + "actionTypeConfig": Object {}, + "actionTypeConfigSecrets": Object {}, + "actionTypeId": "my-action-type", + "description": "my description", + }, + Object { + "migrationVersion": Object {}, + "references": Array [], + }, + ] + `); }); test('validates actionTypeConfig', async () => { @@ -96,11 +97,9 @@ Array [ name: 'My action type', unencryptedAttributes: [], validate: { - config: Joi.object() - .keys({ - param1: Joi.string().required(), - }) - .required(), + config: schema.object({ + param1: schema.string(), + }), }, async executor() {}, }); @@ -113,7 +112,7 @@ Array [ }, }) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"The following actionTypeConfig attributes are invalid: param1 [any.required]"` + `"The actionTypeConfig is invalid: [param1]: expected value of type [string] but got [undefined]"` ); }); @@ -169,22 +168,22 @@ Array [ expect(result).toEqual(expectedResult); expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); expect(savedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - "action", - Object { - "actionTypeConfig": Object { - "a": true, - "c": true, - }, - "actionTypeConfigSecrets": Object { - "b": true, - }, - "actionTypeId": "my-action-type", - "description": "my description", - }, - undefined, -] -`); + Array [ + "action", + Object { + "actionTypeConfig": Object { + "a": true, + "c": true, + }, + "actionTypeConfigSecrets": Object { + "b": true, + }, + "actionTypeId": "my-action-type", + "description": "my description", + }, + undefined, + ] + `); }); }); @@ -206,11 +205,11 @@ describe('get()', () => { expect(result).toEqual(expectedResult); expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - "action", - "1", -] -`); + Array [ + "action", + "1", + ] + `); }); }); @@ -239,12 +238,12 @@ describe('find()', () => { expect(result).toEqual(expectedResult); expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); expect(savedObjectsClient.find.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - Object { - "type": "action", - }, -] -`); + Array [ + Object { + "type": "action", + }, + ] + `); }); }); @@ -261,11 +260,11 @@ describe('delete()', () => { expect(result).toEqual(expectedResult); expect(savedObjectsClient.delete).toHaveBeenCalledTimes(1); expect(savedObjectsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - "action", - "1", -] -`); + Array [ + "action", + "1", + ] + `); }); }); @@ -308,25 +307,25 @@ describe('update()', () => { expect(result).toEqual(expectedResult); expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); expect(savedObjectsClient.update.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - "action", - "my-action", - Object { - "actionTypeConfig": Object {}, - "actionTypeConfigSecrets": Object {}, - "actionTypeId": "my-action-type", - "description": "my description", - }, - Object {}, -] -`); + Array [ + "action", + "my-action", + Object { + "actionTypeConfig": Object {}, + "actionTypeConfigSecrets": Object {}, + "actionTypeId": "my-action-type", + "description": "my description", + }, + Object {}, + ] + `); expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - "action", - "my-action", -] -`); + Array [ + "action", + "my-action", + ] + `); }); test('validates actionTypeConfig', async () => { @@ -340,11 +339,9 @@ Array [ name: 'My action type', unencryptedAttributes: [], validate: { - config: Joi.object() - .keys({ - param1: Joi.string().required(), - }) - .required(), + config: schema.object({ + param1: schema.string(), + }), }, async executor() {}, }); @@ -366,7 +363,7 @@ Array [ options: {}, }) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"The following actionTypeConfig attributes are invalid: param1 [any.required]"` + `"The actionTypeConfig is invalid: [param1]: expected value of type [string] but got [undefined]"` ); }); @@ -412,22 +409,22 @@ Array [ expect(result).toEqual(expectedResult); expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); expect(savedObjectsClient.update.mock.calls[0]).toMatchInlineSnapshot(` -Array [ - "action", - "my-action", - Object { - "actionTypeConfig": Object { - "a": true, - "c": true, - }, - "actionTypeConfigSecrets": Object { - "b": true, - }, - "actionTypeId": "my-action-type", - "description": "my description", - }, - Object {}, -] -`); + Array [ + "action", + "my-action", + Object { + "actionTypeConfig": Object { + "a": true, + "c": true, + }, + "actionTypeConfigSecrets": Object { + "b": true, + }, + "actionTypeId": "my-action-type", + "description": "my description", + }, + Object {}, + ] + `); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts index 992d2d6d49f613..6705f9e02b6918 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts @@ -75,12 +75,21 @@ describe('config validation', () => { password: 'supersecret', from: 'bob@example.com', }; - expect(validateActionTypeConfig(actionType, config)).toEqual(config); + expect(validateActionTypeConfig(actionType, config)).toEqual({ + ...config, + host: null, + port: null, + secure: null, + }); delete config.service; config.host = 'elastic.co'; config.port = 8080; - expect(validateActionTypeConfig(actionType, config)).toEqual(config); + expect(validateActionTypeConfig(actionType, config)).toEqual({ + ...config, + service: null, + secure: null, + }); }); test('config validation fails when config is not valid', () => { @@ -160,7 +169,7 @@ Object { describe('execute()', () => { test('ensure parameters are as expected', async () => { const config: ActionTypeConfigType = { - service: 'a service', + service: '__json', host: 'a host', port: 42, secure: true, @@ -200,11 +209,8 @@ Array [ ], }, "transport": Object { - "host": "a host", "password": "supersecret", - "port": 42, - "secure": true, - "service": "a service", + "service": "__json", "user": "bob", }, }, diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts index 5d19d6ff03c4c9..366b0a5fd5ccac 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf } from '@kbn/config-schema'; +import { schema, TypeOf, Type } from '@kbn/config-schema'; import nodemailerServices from 'nodemailer/lib/well-known/services.json'; import { sendEmail, JSON_TRANSPORT_SERVICE } from './lib/send_email'; @@ -12,111 +12,120 @@ import { ActionType, ActionTypeExecutorOptions } from '../types'; const PORT_MAX = 256 * 256 - 1; -export type ActionTypeConfigType = TypeOf; +function nullableType(type: Type) { + return schema.oneOf([type, schema.literal(null)], { defaultValue: () => null }); +} -const ActionTypeConfig = { - path: 'actionTypeConfig', - schema: schema.object({ - service: schema.maybe(schema.string()), - host: schema.maybe(schema.string()), - port: schema.maybe(schema.number({ min: 1, max: PORT_MAX })), - secure: schema.maybe(schema.boolean()), - user: schema.string(), - password: schema.string(), - from: schema.string(), - }), -}; +// config definition const unencryptedConfigProperties = ['service', 'host', 'port', 'secure', 'from']; -export type ActionParamsType = TypeOf; +export type ActionTypeConfigType = TypeOf; -const ActionParams = { - path: 'actionParams', - schema: schema.object({ - to: schema.arrayOf(schema.string(), { defaultValue: [] }), - cc: schema.arrayOf(schema.string(), { defaultValue: [] }), - bcc: schema.arrayOf(schema.string(), { defaultValue: [] }), - subject: schema.string(), - message: schema.string(), - }), -}; - -function validateConfig(object: any): { error?: Error; value?: ActionTypeConfigType } { - let value; - - try { - value = ActionTypeConfig.schema.validate(object); - } catch (error) { - return { error }; +const ConfigSchema = schema.object( + { + user: schema.string(), + password: schema.string(), + service: nullableType(schema.string()), + host: nullableType(schema.string()), + port: nullableType(schema.number({ min: 1, max: PORT_MAX })), + secure: nullableType(schema.boolean()), + from: schema.string(), + }, + { + validate: validateConfig, } +); - const { service, host, port } = value; +function validateConfig(configObject: any): string | void { + // avoids circular reference ... + const config: ActionTypeConfigType = configObject; // Make sure service is set, or if not, both host/port must be set. // If service is set, host/port are ignored, when the email is sent. - if (service == null) { - if (host == null && port == null) { - return toErrorObject('either [service] or [host]/[port] is required'); + if (config.service == null) { + if (config.host == null && config.port == null) { + return 'either [service] or [host]/[port] is required'; } - if (host == null) { - return toErrorObject('[host] is required if [service] is not provided'); + if (config.host == null) { + return '[host] is required if [service] is not provided'; } - if (port == null) { - return toErrorObject('[port] is required if [service] is not provided'); + if (config.port == null) { + return '[port] is required if [service] is not provided'; } } else { // service is not null - if (!isValidService(service)) { - return toErrorObject(`[service] value "${service}" is not valid`); + if (!isValidService(config.service)) { + return `[service] value "${config.service}" is not valid`; } } - - return { value }; } -function validateParams(object: any): { error?: Error; value?: ActionParamsType } { - let value; +// params definition + +export type ActionParamsType = TypeOf; - try { - value = ActionParams.schema.validate(object); - } catch (error) { - return { error }; +const ParamsSchema = schema.object( + { + to: schema.arrayOf(schema.string(), { defaultValue: [] }), + cc: schema.arrayOf(schema.string(), { defaultValue: [] }), + bcc: schema.arrayOf(schema.string(), { defaultValue: [] }), + subject: schema.string(), + message: schema.string(), + }, + { + validate: validateParams, } +); + +function validateParams(paramsObject: any): string | void { + // avoids circular reference ... + const params: ActionParamsType = paramsObject; - const { to, cc, bcc } = value; + const { to, cc, bcc } = params; const addrs = to.length + cc.length + bcc.length; if (addrs === 0) { - return toErrorObject('no [to], [cc], or [bcc] entries'); + return 'no [to], [cc], or [bcc] entries'; } - - return { value }; } +// action type definition + export const actionType: ActionType = { id: '.email', name: 'email', unencryptedAttributes: unencryptedConfigProperties, validate: { - config: { validate: validateConfig }, - params: { validate: validateParams }, + config: ConfigSchema, + params: ParamsSchema, }, executor, }; -async function executor({ config, params, services }: ActionTypeExecutorOptions): Promise { +// action executor + +async function executor(execOptions: ActionTypeExecutorOptions): Promise { + const config = execOptions.config as ActionTypeConfigType; + const params = execOptions.params as ActionParamsType; + + const transport: any = { + user: config.user, + password: config.password, + }; + + if (config.service !== null) { + transport.service = config.service; + } else { + transport.host = config.host; + transport.port = config.port; + transport.secure = getSecureValue(config.secure, config.port); + } + const sendEmailOptions = { - transport: { - service: config.service, - host: config.host, - port: config.port, - secure: config.secure, - user: config.user, - password: config.password, - }, + transport, routing: { from: config.from, to: params.to, @@ -132,6 +141,8 @@ async function executor({ config, params, services }: ActionTypeExecutorOptions) return await sendEmail(sendEmailOptions); } +// utilities + const ValidServiceNames = getValidServiceNames(); function isValidService(service: string): boolean { @@ -159,6 +170,13 @@ function getValidServiceNames(): Set { return result; } -function toErrorObject(message: string) { - return { error: new Error(message) }; +// Returns the secure value - whether to use TLS or not. +// Respect value if not null | undefined. +// Otherwise, if the port is 465, return true, otherwise return false. +// Based on data here: +// - https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json +function getSecureValue(secure: boolean | null | undefined, port: number | null): boolean { + if (secure != null) return secure; + if (port === 465) return true; + return false; } diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/lib/send_email.ts index 0e4672a80b2d7e..f749ae977a47d8 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -18,12 +18,12 @@ interface SendEmailOptions { // config validation ensures either service is set or host/port are set interface Transport { + user: string; + password: string; service?: string; // see: https://nodemailer.com/smtp/well-known/ host?: string; port?: number; secure?: boolean; // see: https://nodemailer.com/smtp/#tls-options - user: string; - password: string; } interface Routing { diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts index aeaeb84072802d..83f5cf06b79579 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts @@ -85,25 +85,25 @@ describe('validateActionTypeParams()', () => { expect(() => { validateActionTypeParams(actionType, {}); }).toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: child \\"message\\" fails because [\\"message\\" is required]"` + `"The actionParams is invalid: [message]: expected value of type [string] but got [undefined]"` ); expect(() => { validateActionTypeParams(actionType, { message: 1 }); }).toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: child \\"message\\" fails because [\\"message\\" must be a string]"` + `"The actionParams is invalid: [message]: expected value of type [string] but got [number]"` ); expect(() => { validateActionTypeParams(actionType, { message: 'x', tags: 2 }); }).toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: child \\"tags\\" fails because [\\"tags\\" must be an array]"` + `"The actionParams is invalid: [tags]: expected value of type [array] but got [number]"` ); expect(() => { validateActionTypeParams(actionType, { message: 'x', tags: [2] }); }).toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: child \\"tags\\" fails because [\\"tags\\" at position 0 fails because [\\"0\\" must be a string]]"` + `"The actionParams is invalid: [tags.0]: expected value of type [string] but got [number]"` ); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts index c40336f22402b5..d8d45c512a6b9c 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts @@ -4,32 +4,45 @@ * you may not use this file except in compliance with the Elastic License. */ -import Joi from 'joi'; +import { schema, TypeOf } from '@kbn/config-schema'; import { ActionType, ActionTypeExecutorOptions } from '../types'; const DEFAULT_TAGS = ['info', 'alerting']; -const PARAMS_SCHEMA = Joi.object().keys({ - message: Joi.string().required(), - tags: Joi.array() - .items(Joi.string()) - .optional() - .default(DEFAULT_TAGS), +// config definition + +const unencryptedConfigProperties: string[] = []; + +const ConfigSchema = schema.object({}); + +// params definition + +export type ActionParamsType = TypeOf; + +const ParamsSchema = schema.object({ + message: schema.string(), + tags: schema.arrayOf(schema.string(), { defaultValue: DEFAULT_TAGS }), }); +// action type definition + export const actionType: ActionType = { id: '.server-log', name: 'server-log', - unencryptedAttributes: [], + unencryptedAttributes: unencryptedConfigProperties, validate: { - params: PARAMS_SCHEMA, + config: ConfigSchema, + params: ParamsSchema, }, executor, }; -async function executor({ params, services }: ActionTypeExecutorOptions): Promise { - const { message, tags } = params; +// action executor + +async function executor(execOptions: ActionTypeExecutorOptions): Promise { + const params = execOptions.params as ActionParamsType; + const services = execOptions.services; - services.log(tags, message); + services.log(params.tags, params.message); } diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts index 5bbaa8d6a13646..3f26631e9494fe 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts @@ -85,13 +85,13 @@ describe('validateParams()', () => { expect(() => { validateActionTypeParams(actionType, {}); }).toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: child \\"message\\" fails because [\\"message\\" is required]"` + `"The actionParams is invalid: [message]: expected value of type [string] but got [undefined]"` ); expect(() => { validateActionTypeParams(actionType, { message: 1 }); }).toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: child \\"message\\" fails because [\\"message\\" must be a string]"` + `"The actionParams is invalid: [message]: expected value of type [string] but got [number]"` ); }); }); @@ -107,13 +107,13 @@ describe('validateActionTypeConfig()', () => { expect(() => { validateActionTypeConfig(actionType, {}); }).toThrowErrorMatchingInlineSnapshot( - `"The following actionTypeConfig attributes are invalid: webhookUrl [any.required]"` + `"The actionTypeConfig is invalid: [webhookUrl]: expected value of type [string] but got [undefined]"` ); expect(() => { validateActionTypeConfig(actionType, { webhookUrl: 1 }); }).toThrowErrorMatchingInlineSnapshot( - `"The following actionTypeConfig attributes are invalid: webhookUrl [string.base]"` + `"The actionTypeConfig is invalid: [webhookUrl]: expected value of type [string] but got [number]"` ); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts index dcb3f9cbe15fe0..c1344b4f78abea 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts @@ -4,22 +4,30 @@ * you may not use this file except in compliance with the Elastic License. */ -import Joi from 'joi'; +import { schema, TypeOf } from '@kbn/config-schema'; import { IncomingWebhook } from '@slack/webhook'; import { ActionType, ActionTypeExecutorOptions, ExecutorType } from '../types'; -const CONFIG_SCHEMA = Joi.object() - .keys({ - webhookUrl: Joi.string().required(), - }) - .required(); +// config definition -const PARAMS_SCHEMA = Joi.object() - .keys({ - message: Joi.string().required(), - }) - .required(); +const unencryptedConfigProperties: string[] = []; + +export type ActionTypeConfigType = TypeOf; + +const ConfigSchema = schema.object({ + webhookUrl: schema.string(), +}); + +// params definition + +export type ActionParamsType = TypeOf; + +const ParamsSchema = schema.object({ + message: schema.string(), +}); + +// action type definition // customizing executor is only used for tests export function getActionType({ executor }: { executor?: ExecutorType } = {}): ActionType { @@ -28,10 +36,10 @@ export function getActionType({ executor }: { executor?: ExecutorType } = {}): A return { id: '.slack', name: 'slack', - unencryptedAttributes: [], + unencryptedAttributes: unencryptedConfigProperties, validate: { - params: PARAMS_SCHEMA, - config: CONFIG_SCHEMA, + config: ConfigSchema, + params: ParamsSchema, }, executor, }; @@ -40,15 +48,13 @@ export function getActionType({ executor }: { executor?: ExecutorType } = {}): A // the production executor for this action export const actionType = getActionType(); -async function slackExecutor({ - config, - params, - services, -}: ActionTypeExecutorOptions): Promise { - const { webhookUrl } = config; - const { message } = params; +// action executor + +async function slackExecutor(execOptions: ActionTypeExecutorOptions): Promise { + const config = execOptions.config as ActionTypeConfigType; + const params = execOptions.params as ActionParamsType; - const webhook = new IncomingWebhook(webhookUrl); + const webhook = new IncomingWebhook(config.webhookUrl); - return await webhook.send(message); + return await webhook.send(params.message); } diff --git a/x-pack/legacy/plugins/actions/server/lib/execute.test.ts b/x-pack/legacy/plugins/actions/server/lib/execute.test.ts index 4049e133f96bfe..73a5566474a2a9 100644 --- a/x-pack/legacy/plugins/actions/server/lib/execute.test.ts +++ b/x-pack/legacy/plugins/actions/server/lib/execute.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import Joi from 'joi'; +import { schema } from '@kbn/config-schema'; import { execute } from './execute'; import { actionTypeRegistryMock } from '../action_type_registry.mock'; import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks'; @@ -110,11 +110,9 @@ test('throws an error when config is invalid', async () => { name: 'Test', unencryptedAttributes: [], validate: { - config: Joi.object() - .keys({ - param1: Joi.string().required(), - }) - .required(), + config: schema.object({ + param1: schema.string(), + }), }, executor: jest.fn(), }; @@ -131,7 +129,7 @@ test('throws an error when config is invalid', async () => { actionTypeRegistry.get.mockReturnValueOnce(actionType); await expect(execute(executeParams)).rejects.toThrowErrorMatchingInlineSnapshot( - `"The following actionTypeConfig attributes are invalid: param1 [any.required]"` + `"The actionTypeConfig is invalid: [param1]: expected value of type [string] but got [undefined]"` ); }); @@ -141,11 +139,9 @@ test('throws an error when params is invalid', async () => { name: 'Test', unencryptedAttributes: [], validate: { - params: Joi.object() - .keys({ - param1: Joi.string().required(), - }) - .required(), + params: schema.object({ + param1: schema.string(), + }), }, executor: jest.fn(), }; @@ -162,6 +158,6 @@ test('throws an error when params is invalid', async () => { actionTypeRegistry.get.mockReturnValueOnce(actionType); await expect(execute(executeParams)).rejects.toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: child \\"param1\\" fails because [\\"param1\\" is required]"` + `"The actionParams is invalid: [param1]: expected value of type [string] but got [undefined]"` ); }); diff --git a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.test.ts b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.test.ts index ef5016d3fb4685..9ccbf82fb0e4e5 100644 --- a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.test.ts +++ b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import Joi from 'joi'; +import { schema } from '@kbn/config-schema'; import { validateActionTypeConfig } from './validate_action_type_config'; test('should return passed in config when validation not defined', () => { @@ -29,12 +29,10 @@ test('should validate and apply defaults when actionTypeConfig is valid', () => name: 'My action type', unencryptedAttributes: [], validate: { - config: Joi.object() - .keys({ - param1: Joi.string().required(), - param2: Joi.strict().default('default-value'), - }) - .required(), + config: schema.object({ + param1: schema.string(), + param2: schema.string({ defaultValue: 'default-value' }), + }), }, async executor() {}, }, @@ -54,15 +52,11 @@ test('should validate and throw error when actionTypeConfig is invalid', () => { name: 'My action type', unencryptedAttributes: [], validate: { - config: Joi.object() - .keys({ - obj: Joi.object() - .keys({ - param1: Joi.string().required(), - }) - .required(), - }) - .required(), + config: schema.object({ + obj: schema.object({ + param1: schema.string(), + }), + }), }, async executor() {}, }, @@ -71,6 +65,6 @@ test('should validate and throw error when actionTypeConfig is invalid', () => { } ) ).toThrowErrorMatchingInlineSnapshot( - `"The following actionTypeConfig attributes are invalid: obj.param1 [any.required]"` + `"The actionTypeConfig is invalid: [obj.param1]: expected value of type [string] but got [undefined]"` ); }); diff --git a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.ts b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.ts index a0ca51cb556ef7..ce8bc7dba2a9ac 100644 --- a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.ts +++ b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_config.ts @@ -15,17 +15,10 @@ export function validateActionTypeConfig>( if (!validator) { return config; } - const { error, value } = validator.validate(config); - if (error) { - if (error.details == null) { - throw Boom.badRequest(`The actionTypeConfig is invalid: ${error.message}`); - } - const invalidPaths = error.details.map( - (details: any) => `${details.path.join('.')} [${details.type}]` - ); - throw Boom.badRequest( - `The following actionTypeConfig attributes are invalid: ${invalidPaths.join(', ')}` - ); + + try { + return validator.validate(config); + } catch (err) { + throw Boom.badRequest(`The actionTypeConfig is invalid: ${err.message}`); } - return value; } diff --git a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.test.ts b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.test.ts index 71afb28163c1e0..d5c9f9554d8b47 100644 --- a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.test.ts +++ b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import Joi from 'joi'; +import { schema } from '@kbn/config-schema'; import { validateActionTypeParams } from './validate_action_type_params'; test('should return passed in params when validation not defined', () => { @@ -31,12 +31,10 @@ test('should validate and apply defaults when params is valid', () => { name: 'My action type', unencryptedAttributes: [], validate: { - params: Joi.object() - .keys({ - param1: Joi.string().required(), - param2: Joi.string().default('default-value'), - }) - .required(), + params: schema.object({ + param1: schema.string(), + param2: schema.string({ defaultValue: 'default-value' }), + }), }, async executor() {}, }, @@ -56,17 +54,15 @@ test('should validate and throw error when params is invalid', () => { name: 'My action type', unencryptedAttributes: [], validate: { - params: Joi.object() - .keys({ - param1: Joi.string().required(), - }) - .required(), + params: schema.object({ + param1: schema.string(), + }), }, async executor() {}, }, {} ) ).toThrowErrorMatchingInlineSnapshot( - `"The actionParams is invalid: child \\"param1\\" fails because [\\"param1\\" is required]"` + `"The actionParams is invalid: [param1]: expected value of type [string] but got [undefined]"` ); }); diff --git a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.ts b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.ts index d6893dbbfebe28..4d18c27d79faf6 100644 --- a/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.ts +++ b/x-pack/legacy/plugins/actions/server/lib/validate_action_type_params.ts @@ -15,9 +15,9 @@ export function validateActionTypeParams>( if (!validator) { return params; } - const { error, value } = validator.validate(params); - if (error) { - throw Boom.badRequest(`The actionParams is invalid: ${error.message}`); + try { + return validator.validate(params); + } catch (err) { + throw Boom.badRequest(`The actionParams is invalid: ${err.message}`); } - return value; } diff --git a/x-pack/legacy/plugins/actions/server/types.ts b/x-pack/legacy/plugins/actions/server/types.ts index af9ac15d685fc2..5bf72d9d1a56bd 100644 --- a/x-pack/legacy/plugins/actions/server/types.ts +++ b/x-pack/legacy/plugins/actions/server/types.ts @@ -42,8 +42,8 @@ export interface ActionType { name: string; unencryptedAttributes: string[]; validate?: { - params?: any; - config?: any; + params?: { validate: (object: any) => any }; + config?: { validate: (object: any) => any }; }; executor: ExecutorType; } diff --git a/x-pack/test/api_integration/apis/actions/builtin_action_types/email.ts b/x-pack/test/api_integration/apis/actions/builtin_action_types/email.ts index 787f83d4394d39..081bf8b340e05e 100644 --- a/x-pack/test/api_integration/apis/actions/builtin_action_types/email.ts +++ b/x-pack/test/api_integration/apis/actions/builtin_action_types/email.ts @@ -56,6 +56,9 @@ export default function emailTest({ getService }: KibanaFunctionalTestDefaultPro actionTypeConfig: { from: 'bob@example.com', service: '__json', + host: null, + port: null, + secure: null, }, }, references: [], diff --git a/x-pack/test/api_integration/apis/actions/builtin_action_types/slack.ts b/x-pack/test/api_integration/apis/actions/builtin_action_types/slack.ts index 9e2b0d7e6aba25..1470d28b59df35 100644 --- a/x-pack/test/api_integration/apis/actions/builtin_action_types/slack.ts +++ b/x-pack/test/api_integration/apis/actions/builtin_action_types/slack.ts @@ -72,7 +72,7 @@ export default function slackTest({ getService }: KibanaFunctionalTestDefaultPro statusCode: 400, error: 'Bad Request', message: - 'The following actionTypeConfig attributes are invalid: webhookUrl [any.required]', + 'The actionTypeConfig is invalid: [webhookUrl]: expected value of type [string] but got [undefined]', }); }); }); diff --git a/x-pack/test/api_integration/apis/actions/create.ts b/x-pack/test/api_integration/apis/actions/create.ts index 62143991cc3325..2d37682e57c7cf 100644 --- a/x-pack/test/api_integration/apis/actions/create.ts +++ b/x-pack/test/api_integration/apis/actions/create.ts @@ -123,7 +123,7 @@ export default function createActionTests({ getService }: KibanaFunctionalTestDe statusCode: 400, error: 'Bad Request', message: - 'The following actionTypeConfig attributes are invalid: encrypted [any.required]', + 'The actionTypeConfig is invalid: [encrypted]: expected value of type [string] but got [undefined]', }); }); }); diff --git a/x-pack/test/api_integration/apis/actions/manual/pr_40694.js b/x-pack/test/api_integration/apis/actions/manual/pr_40694.js new file mode 100755 index 00000000000000..bf788eff65c719 --- /dev/null +++ b/x-pack/test/api_integration/apis/actions/manual/pr_40694.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +/* + * 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. + */ + +const fetch = require('node-fetch'); + +const KBN_URLBASE = process.env.KBN_URLBASE || 'http://elastic:changeme@localhost:5601'; + +if (require.main === module) main(); + +async function main() { + let response; + + response = await httpPost('api/action', { + attributes: { + actionTypeId: '.email', + description: 'an email action', + actionTypeConfig: { + user: 'elastic', + password: 'changeme', + from: 'patrick.mueller@elastic.co', + host: 'localhost', + port: 80, + secure: false, + } + } + }); + console.log(`result of create: ${JSON.stringify(response, null, 4)}`); + + const actionId = response.id; + + response = await httpGet(`api/action/${actionId}`); + console.log(`action after create: ${JSON.stringify(response, null, 4)}`); + + response = await httpPut(`api/action/${actionId}`, { + attributes: { + description: 'an email action', + actionTypeConfig: { + user: 'elastic', + password: 'changeme', + from: 'patrick.mueller@elastic.co', + service: '__json', + } + } + }); + + console.log(`response from update: ${JSON.stringify(response, null, 4)}`); + + response = await httpGet(`api/action/${actionId}`); + console.log(`action after update: ${JSON.stringify(response, null, 4)}`); + + response = await httpPost(`api/action/${actionId}/_fire`, { + params: { + to: ['patrick.mueller@elastic.co'], + subject: 'the email subject', + message: 'the email message' + } + }); + + console.log(`fire result: ${JSON.stringify(response, null, 4)}`); +} + +async function httpGet(uri) { + const response = await fetch(`${KBN_URLBASE}/${uri}`); + return response.json(); +} + +async function httpPost(uri, body) { + const response = await fetch(`${KBN_URLBASE}/${uri}`, { + method: 'post', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + 'kbn-xsrf': 'what-evs' + } + }); + + return response.json(); +} + +async function httpPut(uri, body) { + const response = await fetch(`${KBN_URLBASE}/${uri}`, { + method: 'put', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + 'kbn-xsrf': 'what-evs' + } + }); + + return response.json(); +} diff --git a/x-pack/test/api_integration/apis/actions/update.ts b/x-pack/test/api_integration/apis/actions/update.ts index 63880cfe6ac8c1..165dc35d69504f 100644 --- a/x-pack/test/api_integration/apis/actions/update.ts +++ b/x-pack/test/api_integration/apis/actions/update.ts @@ -178,9 +178,65 @@ export default function updateActionTests({ getService }: KibanaFunctionalTestDe statusCode: 400, error: 'Bad Request', message: - 'The following actionTypeConfig attributes are invalid: encrypted [any.required]', + 'The actionTypeConfig is invalid: [encrypted]: expected value of type [string] but got [undefined]', }); }); }); + + it(`should allow changing non-secret config properties - create`, async () => { + let emailActionId: string = ''; + + // create the action + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'test email action', + actionTypeId: '.email', + actionTypeConfig: { + user: 'email-user', + password: 'email-password', + from: 'email-from@example.com', + host: 'host-is-ignored-here.example.com', + port: 666, + }, + }, + }) + .expect(200) + .then((resp: any) => { + emailActionId = resp.body.id; + }); + + // add a new config param + await supertest + .put(`/api/action/${emailActionId}`) + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + description: 'a test email action 2', + actionTypeConfig: { + user: 'email-user', + password: 'email-password', + from: 'email-from@example.com', + service: '__json', + }, + }, + }) + .expect(200); + + // fire the action + await supertest + .post(`/api/action/${emailActionId}/_fire`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + to: ['X'], + subject: 'email-subject', + message: 'email-message', + }, + }) + .expect(200); + }); }); } diff --git a/x-pack/test/api_integration/apis/alerting/constants.ts b/x-pack/test/api_integration/apis/alerting/constants.ts index d73977c26ca942..d946111ae8bbb7 100644 --- a/x-pack/test/api_integration/apis/alerting/constants.ts +++ b/x-pack/test/api_integration/apis/alerting/constants.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export const ES_ARCHIVER_ACTION_ID = '19cfba7c-711a-4170-8590-9a99a281e85c'; +import { ES_ARCHIVER_ACTION_ID as ActionArchiverActionId } from '../actions/constants'; + +export const ES_ARCHIVER_ACTION_ID = ActionArchiverActionId; diff --git a/x-pack/test/api_integration/fixtures/plugins/alerts/index.ts b/x-pack/test/api_integration/fixtures/plugins/alerts/index.ts index 806eab42cee633..e8d9cf18a45e2f 100644 --- a/x-pack/test/api_integration/fixtures/plugins/alerts/index.ts +++ b/x-pack/test/api_integration/fixtures/plugins/alerts/index.ts @@ -5,6 +5,7 @@ */ import Joi from 'joi'; +import { schema } from '@kbn/config-schema'; import { AlertExecutorOptions, AlertType } from '../../../../../legacy/plugins/alerting'; import { ActionTypeExecutorOptions, ActionType } from '../../../../../legacy/plugins/actions'; @@ -20,19 +21,15 @@ export default function(kibana: any) { name: 'Test: Index Record', unencryptedAttributes: ['unencrypted'], validate: { - params: Joi.object() - .keys({ - index: Joi.string().required(), - reference: Joi.string().required(), - message: Joi.string().required(), - }) - .required(), - config: Joi.object() - .keys({ - encrypted: Joi.string().required(), - unencrypted: Joi.string().required(), - }) - .required(), + params: schema.object({ + index: schema.string(), + reference: schema.string(), + message: schema.string(), + }), + config: schema.object({ + encrypted: schema.string(), + unencrypted: schema.string(), + }), }, async executor({ config, params, services }: ActionTypeExecutorOptions) { return await services.callCluster('index', { @@ -52,12 +49,10 @@ export default function(kibana: any) { name: 'Test: Failing', unencryptedAttributes: [], validate: { - params: Joi.object() - .keys({ - index: Joi.string().required(), - reference: Joi.string().required(), - }) - .required(), + params: schema.object({ + index: schema.string(), + reference: schema.string(), + }), }, async executor({ config, params, services }: ActionTypeExecutorOptions) { await services.callCluster('index', { diff --git a/x-pack/test/functional/es_archives/actions/README.md b/x-pack/test/functional/es_archives/actions/README.md new file mode 100644 index 00000000000000..89e699aa6a04d3 --- /dev/null +++ b/x-pack/test/functional/es_archives/actions/README.md @@ -0,0 +1,24 @@ +The values of `id` and `actionTypeConfigSecrets` in the `basic/data.json` file +may change over time, and to get the current "correct" value to replace it with, +you can do the following: + + +- add a `process.exit()` in this test, after an action is created: + + https://github.com/elastic/kibana/blob/master/x-pack/test/api_integration/apis/actions/create.ts#L37 + +- figure out what data got put in ES via + + curl http://elastic:changeme@localhost:9220/_search -v | json + +- there should be a new `id` and `actionTypeConfigSecrets` + +- update the following files: + + - `id` and `actionTypeConfigSecrets` + + `x-pack/test/functional/es_archives/actions/basic/data.json` + + - `id` + + `x-pack/test/api_integration/apis/actions/constants.ts`