diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index c3ca0a16df797..d217d26e84836 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -10,8 +10,7 @@ The Kibana actions plugin provides a framework to create executable actions. You - Execute an action, passing it a parameter object. - Perform CRUD operations on actions. ------ - +--- Table of Contents @@ -61,15 +60,18 @@ Table of Contents - [`config`](#config-5) - [`secrets`](#secrets-5) - [`params`](#params-5) + - [ServiceNow](#servicenow) + - [`config`](#config-6) + - [`secrets`](#secrets-6) + - [`params`](#params-6) - [Command Line Utility](#command-line-utility) - ## Terminology -**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 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 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. +**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 @@ -78,36 +80,37 @@ action types. 3. Use alerts to execute actions or execute manually (see firing actions). ## Kibana Actions Configuration + Implemented under the [Actions Config](./server/actions_config.ts). ### Configuration Options Built-In-Actions are configured using the _xpack.actions_ namespoace under _kibana.yml_, and have the following configuration options: -| Namespaced Key | Description | Type | -| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -| _xpack.actions._**enabled** | Feature toggle which enabled Actions in Kibana. | boolean | -| _xpack.actions._**whitelistedHosts** | Which _hostnames_ are whitelisted for the Built-In-Action? This list should contain hostnames of every external service you wish to interact with using Webhooks, Email or any other built in Action. Note that you may use the string "\*" in place of a specific hostname to enable Kibana to target any URL, but keep in mind the potential use of such a feature to execute [SSRF](https://www.owasp.org/index.php/Server_Side_Request_Forgery) attacks from your server. | Array | -| _xpack.actions._**enabledActionTypes** | A list of _actionTypes_ id's that are enabled. A "\*" may be used as an element to indicate all registered actionTypes should be enabled. The actionTypes registered for Kibana are `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, `.webhook`. Default: `["*"]` | Array | +| Namespaced Key | Description | Type | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| _xpack.actions._**enabled** | Feature toggle which enabled Actions in Kibana. | boolean | +| _xpack.actions._**whitelistedHosts** | Which _hostnames_ are whitelisted for the Built-In-Action? This list should contain hostnames of every external service you wish to interact with using Webhooks, Email or any other built in Action. Note that you may use the string "\*" in place of a specific hostname to enable Kibana to target any URL, but keep in mind the potential use of such a feature to execute [SSRF](https://www.owasp.org/index.php/Server_Side_Request_Forgery) attacks from your server. | Array | +| _xpack.actions._**enabledActionTypes** | A list of _actionTypes_ id's that are enabled. A "\*" may be used as an element to indicate all registered actionTypes should be enabled. The actionTypes registered for Kibana are `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, `.webhook`. Default: `["*"]` | Array | #### Whitelisting Built-in Action Types + It is worth noting that the **whitelistedHosts** configuation applies to built-in action types (such as Slack, or PagerDuty) as well. Uniquely, the _PagerDuty Action Type_ has been configured to support the service's Events API (at _https://events.pagerduty.com/v2/enqueue_, which you can read about [here](https://v2.developer.pagerduty.com/docs/events-api-v2)) as a default, but this too, must be included in the whitelist before the PagerDuty action can be used. - ### Configuration Utilities This module provides a Utilities for interacting with the configuration. -| Method | Arguments | Description | Return Type | -| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| isWhitelistedUri | _uri_: The URI you wish to validate is whitelisted | Validates whether the URI is whitelisted. This checks the configuration and validates that the hostname of the URI is in the list of whitelisted Hosts and returns `true` if it is whitelisted. If the configuration says that all URI's are whitelisted (using an "\*") then it will always return `true`. | Boolean | -| isWhitelistedHostname | _hostname_: The Hostname you wish to validate is whitelisted | Validates whether the Hostname is whitelisted. This checks the configuration and validates that the hostname is in the list of whitelisted Hosts and returns `true` if it is whitelisted. If the configuration says that all Hostnames are whitelisted (using an "\*") then it will always return `true`. | Boolean | -| isActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Returns true if the actionType is enabled, otherwise false. | Boolean | -| ensureWhitelistedUri | _uri_: The URI you wish to validate is whitelisted | Validates whether the URI is whitelisted. This checks the configuration and validates that the hostname of the URI is in the list of whitelisted Hosts and throws an error if it is not whitelisted. If the configuration says that all URI's are whitelisted (using an "\*") then it will never throw. | No return value, throws if URI isn't whitelisted | -| ensureWhitelistedHostname | _hostname_: The Hostname you wish to validate is whitelisted | Validates whether the Hostname is whitelisted. This checks the configuration and validates that the hostname is in the list of whitelisted Hosts and throws an error if it is not whitelisted. If the configuration says that all Hostnames are whitelisted (using an "\*") then it will never throw | No return value, throws if Hostname isn't whitelisted | -| ensureActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Throws an error if the actionType is not enabled | No return value, throws if actionType isn't enabled | +| Method | Arguments | Description | Return Type | +| ------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| isWhitelistedUri | _uri_: The URI you wish to validate is whitelisted | Validates whether the URI is whitelisted. This checks the configuration and validates that the hostname of the URI is in the list of whitelisted Hosts and returns `true` if it is whitelisted. If the configuration says that all URI's are whitelisted (using an "\*") then it will always return `true`. | Boolean | +| isWhitelistedHostname | _hostname_: The Hostname you wish to validate is whitelisted | Validates whether the Hostname is whitelisted. This checks the configuration and validates that the hostname is in the list of whitelisted Hosts and returns `true` if it is whitelisted. If the configuration says that all Hostnames are whitelisted (using an "\*") then it will always return `true`. | Boolean | +| isActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Returns true if the actionType is enabled, otherwise false. | Boolean | +| ensureWhitelistedUri | _uri_: The URI you wish to validate is whitelisted | Validates whether the URI is whitelisted. This checks the configuration and validates that the hostname of the URI is in the list of whitelisted Hosts and throws an error if it is not whitelisted. If the configuration says that all URI's are whitelisted (using an "\*") then it will never throw. | No return value, throws if URI isn't whitelisted | +| ensureWhitelistedHostname | _hostname_: The Hostname you wish to validate is whitelisted | Validates whether the Hostname is whitelisted. This checks the configuration and validates that the hostname is in the list of whitelisted Hosts and throws an error if it is not whitelisted. If the configuration says that all Hostnames are whitelisted (using an "\*") then it will never throw | No return value, throws if Hostname isn't whitelisted | +| ensureActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Throws an error if the actionType is not enabled | No return value, throws if actionType isn't enabled | ## Action types @@ -117,38 +120,37 @@ This module provides a Utilities for interacting with the configuration. The following table describes the properties of the `options` object. -|Property|Description|Type| -|---|---|---| -|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). 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| +| Property | Description | Type | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | +| 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). 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. +**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. +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. **executor(options)** -|Property|Description| -|---|---| -|actionId|The action saved object id that the action type is executing for.| -|config|The decrypted configuration given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type.| -|params|Parameters for the execution. These will be given at execution time by either an alert or manually provided when calling the plugin provided execute function.| -|services.callCluster(path, opts)|Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana.

**NOTE**: This currently authenticates as the Kibana internal user, but will change in a future PR.| -|services.savedObjectsClient|This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled).| -|services.log(tags, [data], [timestamp])|Use this to create server logs. (This is the same function as server.log)| +| Property | Description | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| actionId | The action saved object id that the action type is executing for. | +| config | The decrypted configuration given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type. | +| params | Parameters for the execution. These will be given at execution time by either an alert or manually provided when calling the plugin provided execute function. | +| services.callCluster(path, opts) | Use this to do Elasticsearch queries on the cluster Kibana connects to. This function is the same as any other `callCluster` in Kibana.

**NOTE**: This currently authenticates as the Kibana internal user, but will change in a future PR. | +| services.savedObjectsClient | This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.

The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled). | +| services.log(tags, [data], [timestamp]) | Use this to create server logs. (This is the same function as server.log) | ### Example -The built-in email action type provides a good example of creating an action type with non-trivial configuration and params: +The built-in email action type provides a good example of creating an action type with non-trivial configuration and params: [x-pack/plugins/actions/server/builtin_action_types/email.ts](server/builtin_action_types/email.ts) - ## RESTful API Using an action type requires an action to be created that will contain and encrypt configuration for a given action type. See below for CRUD operations using the API. @@ -157,20 +159,20 @@ Using an action type requires an action to be created that will contain and encr Payload: -|Property|Description|Type| -|---|---|---| -|name|A name to reference and search in the future. This value will be used to populate dropdowns.|string| -|actionTypeId|The id value of the action type you want to call when the action executes.|string| -|config|The configuration the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if config validation is defined.|object| -|secrets|The secrets the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if secrets validation is defined.|object| +| Property | Description | Type | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| name | A name to reference and search in the future. This value will be used to populate dropdowns. | string | +| actionTypeId | The id value of the action type you want to call when the action executes. | string | +| config | The configuration the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if config validation is defined. | object | +| secrets | The secrets the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if secrets validation is defined. | object | ### `DELETE /api/action/{id}`: Delete action Params: -|Property|Description|Type| -|---|---|---| -|id|The id of the action you're trying to delete.|string| +| Property | Description | Type | +| -------- | --------------------------------------------- | ------ | +| id | The id of the action you're trying to delete. | string | ### `GET /api/action/_find`: Find actions @@ -182,9 +184,9 @@ See the [saved objects API documentation for find](https://www.elastic.co/guide/ Params: -|Property|Description|Type| -|---|---|---| -|id|The id of the action you're trying to get.|string| +| Property | Description | Type | +| -------- | ------------------------------------------ | ------ | +| id | The id of the action you're trying to get. | string | ### `GET /api/action/types`: List action types @@ -194,31 +196,31 @@ No parameters. Params: -|Property|Description|Type| -|---|---|---| -|id|The id of the action you're trying to update.|string| +| Property | Description | Type | +| -------- | --------------------------------------------- | ------ | +| id | The id of the action you're trying to update. | string | Payload: -|Property|Description|Type| -|---|---|---| -|name|A name to reference and search in the future. This value will be used to populate dropdowns.|string| -|config|The configuration the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if config validation is defined.|object| -|secrets|The secrets the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if secrets validation is defined.|object| +| Property | Description | Type | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| name | A name to reference and search in the future. This value will be used to populate dropdowns. | string | +| config | The configuration the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if config validation is defined. | object | +| secrets | The secrets the action type expects. See related action type to see what attributes are expected. This will also validate against the action type if secrets validation is defined. | object | ### `POST /api/action/{id}/_execute`: Execute action Params: -|Property|Description|Type| -|---|---|---| -|id|The id of the action you're trying to execute.|string| +| Property | Description | Type | +| -------- | ---------------------------------------------- | ------ | +| id | The id of the action you're trying to execute. | string | Payload: -|Property|Description|Type| -|---|---|---| -|params|The parameters the action type requires for the execution.|object| +| Property | Description | Type | +| -------- | ---------------------------------------------------------- | ------ | +| params | The parameters the action type requires for the execution. | object | ## Firing actions @@ -228,12 +230,12 @@ The plugin exposes an execute function that you can use to run actions. The following table describes the properties of the `options` object. -|Property|Description|Type| -|---|---|---| -|id|The id of the action you want to execute.|string| -|params|The `params` value to give the action type executor.|object| -|spaceId|The space id the action is within.|string| -|apiKey|The Elasticsearch API key to use for context. (Note: only required and used when security is enabled).|string| +| Property | Description | Type | +| -------- | ------------------------------------------------------------------------------------------------------ | ------ | +| id | The id of the action you want to execute. | string | +| params | The `params` value to give the action type executor. | object | +| spaceId | The space id the action is within. | string | +| apiKey | The Elasticsearch API key to use for context. (Note: only required and used when security is enabled). | string | ## Example @@ -256,23 +258,25 @@ server.plugins.actions.execute({ Kibana ships with a set of built-in action types: -|Type|Id|Description| -|---|---|---| -|[Server log](#server-log)|`.log`|Logs messages to the Kibana log using `server.log()`| -|[Email](#email)|`.email`|Sends an email using SMTP| -|[Slack](#slack)|`.slack`|Posts a message to a slack channel| -|[Index](#index)|`.index`|Indexes document(s) into Elasticsearch| -|[Webhook](#webhook)|`.webhook`|Send a payload to a web service using HTTP POST or PUT| -|[PagerDuty](#pagerduty)|`.pagerduty`|Trigger, resolve, or acknowlege an incident to a PagerDuty service| +| Type | Id | Description | +| ------------------------- | ------------- | ------------------------------------------------------------------ | +| [Server log](#server-log) | `.log` | Logs messages to the Kibana log using `server.log()` | +| [Email](#email) | `.email` | Sends an email using SMTP | +| [Slack](#slack) | `.slack` | Posts a message to a slack channel | +| [Index](#index) | `.index` | Indexes document(s) into Elasticsearch | +| [Webhook](#webhook) | `.webhook` | Send a payload to a web service using HTTP POST or PUT | +| [PagerDuty](#pagerduty) | `.pagerduty` | Trigger, resolve, or acknowlege an incident to a PagerDuty service | +| [ServiceNow](#servicenow) | `.servicenow` | Create or update an incident to a ServiceNow instance | + +--- ----- ## Server log ID: `.log` The params properties are modelled after the arguments to the [Hapi.server.log()](https://hapijs.com/api#-serverlogtags-data-timestamp) function. -### `config` +### `config` This action has no `config` properties. @@ -282,12 +286,13 @@ This action type has no `secrets` properties. ### `params` -|Property|Description|Type| -|---|---|---| -|message|The message to log.|string| -|tags|Tags associated with the message to log.|string[] _(optional)_| +| Property | Description | Type | +| -------- | ---------------------------------------- | --------------------- | +| message | The message to log. | string | +| tags | Tags associated with the message to log. | string[] _(optional)_ | + +--- ----- ## Email ID: `.email` @@ -296,50 +301,50 @@ This action type uses [nodemailer](https://nodemailer.com/about/) to send emails ### `config` -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/). +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 `secure` 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. +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. -|Property|Description|Type| -|---|---|---| -|service|the name of a [well-known email service provider](https://nodemailer.com/smtp/well-known/)|string _(optional)_| -|host|host name of the service provider|string _(optional)_| -|port|port number of the service provider|number _(optional)_| -|secure|whether to use TLS with the service provider|boolean _(optional)_| -|from|the from address for all emails sent with this action type|string| +| Property | Description | Type | +| -------- | ------------------------------------------------------------------------------------------ | -------------------- | +| service | the name of a [well-known email service provider](https://nodemailer.com/smtp/well-known/) | string _(optional)_ | +| host | host name of the service provider | string _(optional)_ | +| port | port number of the service provider | number _(optional)_ | +| secure | whether to use TLS with the service provider | boolean _(optional)_ | +| from | the from address for all emails sent with this action type | string | ### `secrets` -|Property|Description|Type| -|---|---|---| -|user|userid to use with the service provider|string| -|password|password to use with the service provider|string| +| Property | Description | Type | +| -------- | ----------------------------------------- | ------ | +| user | userid to use with the service provider | string | +| password | password to use with the service provider | string | ### `params` There must be at least one entry in the `to`, `cc` and `bcc` arrays. -The message text will be sent as both plain text and html text. Additional function may be provided later. +The message text will be sent as both plain text and html text. Additional function may be provided later. The `to`, `cc`, and `bcc` array entries can be in the same format as the `from` property described in the config object above. -|Property|Description|Type| -|---|---|---| -|to|list of to addressees|string[] _(optional)_| -|cc|list of cc addressees|string[] _(optional)_| -|bcc|list of bcc addressees|string[] _(optional)_| -|subject|the subject line of the email|string| -|message|the message text|string| +| Property | Description | Type | +| -------- | ----------------------------- | --------------------- | +| to | list of to addressees | string[] _(optional)_ | +| cc | list of cc addressees | string[] _(optional)_ | +| bcc | list of bcc addressees | string[] _(optional)_ | +| subject | the subject line of the email | string | +| message | the message text | string | ----- +--- ## Slack ID: `.slack` -This action type interfaces with the [Slack Incoming Webhooks feature](https://api.slack.com/incoming-webhooks). Currently the params property `message` will be used as the `text` property of the Slack incoming message. Additional function may be provided later. +This action type interfaces with the [Slack Incoming Webhooks feature](https://api.slack.com/incoming-webhooks). Currently the params property `message` will be used as the `text` property of the Slack incoming message. Additional function may be provided later. ### `config` @@ -347,29 +352,29 @@ This action type has no `config` properties. ### `secrets` -|Property|Description|Type| -|---|---|---| -|webhookUrl|the url of the Slack incoming webhook|string| +| Property | Description | Type | +| ---------- | ------------------------------------- | ------ | +| webhookUrl | the url of the Slack incoming webhook | string | ### `params` -|Property|Description|Type| -|---|---|---| -|message|the message text|string| +| Property | Description | Type | +| -------- | ---------------- | ------ | +| message | the message text | string | ----- +--- ## Index ID: `.index` -The config and params properties are modelled after the [Watcher Index Action](https://www.elastic.co/guide/en/elasticsearch/reference/master/actions-index.html). The index can be set in the config or params, and if set in config, then the index set in the params will be ignored. +The config and params properties are modelled after the [Watcher Index Action](https://www.elastic.co/guide/en/elasticsearch/reference/master/actions-index.html). The index can be set in the config or params, and if set in config, then the index set in the params will be ignored. ### `config` -|Property|Description|Type| -|---|---|---| -|index|The Elasticsearch index to index into.|string _(optional)_| +| Property | Description | Type | +| -------- | -------------------------------------- | ------------------- | +| index | The Elasticsearch index to index into. | string _(optional)_ | ### `secrets` @@ -377,81 +382,114 @@ This action type has no `secrets` properties. ### `params` -|Property|Description|Type| -|---|---|---| -|index|The Elasticsearch index to index into.|string _(optional)_| -|doc_id|The optional _id of the document.|string _(optional)_| -|execution_time_field|The field that will store/index the action execution time.|string _(optional)_| -|refresh|Setting of the refresh policy for the write request|boolean _(optional)_| -|body|The documument body/bodies to index.|object or object[]| +| Property | Description | Type | +| -------------------- | ---------------------------------------------------------- | -------------------- | +| index | The Elasticsearch index to index into. | string _(optional)_ | +| doc_id | The optional \_id of the document. | string _(optional)_ | +| execution_time_field | The field that will store/index the action execution time. | string _(optional)_ | +| refresh | Setting of the refresh policy for the write request | boolean _(optional)_ | +| body | The documument body/bodies to index. | object or object[] | + +--- ----- ## Webhook ID: `.webhook` The webhook action uses [axios](https://github.com/axios/axios) to send a POST or PUT request to a web service. -### `config` +### `config` -|Property|Description|Type| -|---|---|---| -|url|Request URL|string| -|method|HTTP request method, either `post`_(default)_ or `put`|string _(optional)_| -|headers|Key-value pairs of the headers to send with the request|object, keys and values are strings _(optional)_| +| Property | Description | Type | +| -------- | ------------------------------------------------------- | ------------------------------------------------ | +| url | Request URL | string | +| method | HTTP request method, either `post`_(default)_ or `put` | string _(optional)_ | +| headers | Key-value pairs of the headers to send with the request | object, keys and values are strings _(optional)_ | -### `secrets` +### `secrets` -|Property|Description|Type| -|---|---|---| -|user|Username for HTTP Basic authentication|string _(optional)_| -|password|Password for HTTP Basic authentication|string _(optional)_| +| Property | Description | Type | +| -------- | -------------------------------------- | ------------------- | +| user | Username for HTTP Basic authentication | string _(optional)_ | +| password | Password for HTTP Basic authentication | string _(optional)_ | -### `params` +### `params` -|Property|Description|Type| -|---|---|---| -|body|The HTTP request body|string _(optional)_| +| Property | Description | Type | +| -------- | --------------------- | ------------------- | +| body | The HTTP request body | string _(optional)_ | ----- +--- ## PagerDuty -ID: `.pagerduty` +ID: `.pagerduty` -The PagerDuty action uses the [V2 Events API](https://v2.developer.pagerduty.com/docs/events-api-v2) to trigger, acknowlege, and resolve PagerDuty alerts. +The PagerDuty action uses the [V2 Events API](https://v2.developer.pagerduty.com/docs/events-api-v2) to trigger, acknowlege, and resolve PagerDuty alerts. -### `config` +### `config` -|Property|Description|Type| -|---|---|---| -|apiUrl|PagerDuty event URL. Defaults to `https://events.pagerduty.com/v2/enqueue`|string _(optional)_| +| Property | Description | Type | +| -------- | -------------------------------------------------------------------------- | ------------------- | +| apiUrl | PagerDuty event URL. Defaults to `https://events.pagerduty.com/v2/enqueue` | string _(optional)_ | ### `secrets` -|Property|Description|Type| -|---|---|---| -|routingKey|This is the 32 character PagerDuty Integration Key for an integration on a service or on a global ruleset.|string| +| Property | Description | Type | +| ---------- | ---------------------------------------------------------------------------------------------------------- | ------ | +| routingKey | This is the 32 character PagerDuty Integration Key for an integration on a service or on a global ruleset. | string | -### `params` +### `params` -|Property|Description|Type| -|---|---|---| -|eventAction|One of `trigger` _(default)_, `resolve`, or `acknowlege`. See [event action](https://v2.developer.pagerduty.com/docs/events-api-v2#event-action) for more details.| string _(optional)_| -|dedupKey|All actions sharing this key will be associated with the same PagerDuty alert. Used to correlate trigger and resolution. Defaults to `action:`. The maximum length is **255** characters. See [alert deduplication](https://v2.developer.pagerduty.com/docs/events-api-v2#alert-de-duplication) for details. | string _(optional)_| -|summary|A text summary of the event, defaults to `No summary provided`. The maximum length is **1024** characters. | string _(optional)_| -|source|The affected system, preferably a hostname or fully qualified domain name. Defaults to `Kibana Action `.| string _(optional)_| -|severity|The perceived severity of on the affected system. This can be one of `critical`, `error`, `warning` or `info`_(default)_.| string _(optional)_| -|timestamp|An [ISO-8601 format date-time](https://v2.developer.pagerduty.com/v2/docs/types#datetime), indicating the time the event was detected or generated.| string _(optional)_| -|component|The component of the source machine that is responsible for the event, for example `mysql` or `eth0`.| string _(optional)_| -|group|Logical grouping of components of a service, for example `app-stack`.| string _(optional)_| -|class|The class/type of the event, for example `ping failure` or `cpu load`.| string _(optional)_| +| Property | Description | Type | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | +| eventAction | One of `trigger` _(default)_, `resolve`, or `acknowlege`. See [event action](https://v2.developer.pagerduty.com/docs/events-api-v2#event-action) for more details. | string _(optional)_ | +| dedupKey | All actions sharing this key will be associated with the same PagerDuty alert. Used to correlate trigger and resolution. Defaults to `action:`. The maximum length is **255** characters. See [alert deduplication](https://v2.developer.pagerduty.com/docs/events-api-v2#alert-de-duplication) for details. | string _(optional)_ | +| summary | A text summary of the event, defaults to `No summary provided`. The maximum length is **1024** characters. | string _(optional)_ | +| source | The affected system, preferably a hostname or fully qualified domain name. Defaults to `Kibana Action `. | string _(optional)_ | +| severity | The perceived severity of on the affected system. This can be one of `critical`, `error`, `warning` or `info`_(default)_. | string _(optional)_ | +| timestamp | An [ISO-8601 format date-time](https://v2.developer.pagerduty.com/v2/docs/types#datetime), indicating the time the event was detected or generated. | string _(optional)_ | +| component | The component of the source machine that is responsible for the event, for example `mysql` or `eth0`. | string _(optional)_ | +| group | Logical grouping of components of a service, for example `app-stack`. | string _(optional)_ | +| class | The class/type of the event, for example `ping failure` or `cpu load`. | string _(optional)_ | For more details see [PagerDuty v2 event parameters](https://v2.developer.pagerduty.com/v2/docs/send-an-event-events-api-v2). +--- + +## ServiceNow + +ID: `.servicenow` + +The ServiceNow action uses the [V2 Table API](https://developer.servicenow.com/app.do#!/rest_api_doc?v=orlando&id=c_TableAPI) to create and update ServiceNow incidents. + +### `config` + +| Property | Description | Type | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| apiUrl | ServiceNow instance URL. | string | +| casesConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the ServiceNow field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'short_description', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in ServiceNow and will be overwrite on each update. | object | + +### `secrets` + +| Property | Description | Type | +| -------- | -------------------------------------- | ------ | +| username | Username for HTTP Basic authentication | string | +| password | Password for HTTP Basic authentication | string | + +### `params` + +| Property | Description | Type | +| ----------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| caseId | The case id | string | +| title | The title of the case | string _(optional)_ | +| description | The description of the case | string _(optional)_ | +| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ | +| incidentID | The id of the incident in ServiceNow . If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | + # Command Line Utility -The [`kbn-action`](https://github.com/pmuellr/kbn-action) tool can be used to send HTTP requests to the Actions plugin. For instance, to create a Slack action from the `.slack` Action Type, use the following command: +The [`kbn-action`](https://github.com/pmuellr/kbn-action) tool can be used to send HTTP requests to the Actions plugin. For instance, to create a Slack action from the `.slack` Action Type, use the following command: ```console $ kbn-action create .slack "post to slack" '{"webhookUrl": "https://hooks.slack.com/services/T0000/B0000/XXXX"}' @@ -467,4 +505,4 @@ $ kbn-action create .slack "post to slack" '{"webhookUrl": "https://hooks.slack. "updated_at": "2019-06-26T17:55:42.728Z", "version": "WzMsMV0=" } -``` \ No newline at end of file +``` diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/post_servicenow.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/post_servicenow.ts deleted file mode 100644 index cfd3a9d70dc93..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/post_servicenow.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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 axios, { AxiosResponse } from 'axios'; -import { Services } from '../../types'; -import { ParamsType, SecretsType } from '../servicenow'; - -interface PostServiceNowOptions { - apiUrl: string; - data: ParamsType; - headers: Record; - services?: Services; - secrets: SecretsType; -} - -// post an event to serviceNow -export async function postServiceNow(options: PostServiceNowOptions): Promise { - const { apiUrl, data, headers, secrets } = options; - const axiosOptions = { - headers, - validateStatus: () => true, - auth: secrets, - }; - return axios.post(`${apiUrl}/api/now/v1/table/incident`, data, axiosOptions); -} diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow.test.ts deleted file mode 100644 index 9ae96cb23a5c3..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -/* - * 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. - */ - -jest.mock('./lib/post_servicenow', () => ({ - postServiceNow: jest.fn(), -})); - -import { getActionType } from './servicenow'; -import { ActionType, Services, ActionTypeExecutorOptions } from '../types'; -import { validateConfig, validateSecrets, validateParams } from '../lib'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; -import { postServiceNow } from './lib/post_servicenow'; -import { createActionTypeRegistry } from './index.test'; -import { configUtilsMock } from '../actions_config.mock'; - -const postServiceNowMock = postServiceNow as jest.Mock; - -const ACTION_TYPE_ID = '.servicenow'; - -const services: Services = { - callCluster: async (path: string, opts: any) => {}, - savedObjectsClient: savedObjectsClientMock.create(), -}; - -let actionType: ActionType; - -const mockServiceNow = { - config: { - apiUrl: 'www.servicenowisinkibanaactions.com', - }, - secrets: { - password: 'secret-password', - username: 'secret-username', - }, - params: { - comments: 'hello cool service now incident', - short_description: 'this is a cool service now incident', - }, -}; - -beforeAll(() => { - const { actionTypeRegistry } = createActionTypeRegistry(); - actionType = actionTypeRegistry.get(ACTION_TYPE_ID); -}); - -describe('get()', () => { - test('should return correct action type', () => { - expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('ServiceNow'); - }); -}); - -describe('validateConfig()', () => { - test('should validate and pass when config is valid', () => { - const { config } = mockServiceNow; - expect(validateConfig(actionType, config)).toEqual(config); - }); - - test('should validate and throw error when config is invalid', () => { - expect(() => { - validateConfig(actionType, { shouldNotBeHere: true }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]"` - ); - }); - - test('should validate and pass when the servicenow url is whitelisted', () => { - actionType = getActionType({ - configurationUtilities: { - ...configUtilsMock, - ensureWhitelistedUri: url => { - expect(url).toEqual('https://events.servicenow.com/v2/enqueue'); - }, - }, - }); - - expect( - validateConfig(actionType, { apiUrl: 'https://events.servicenow.com/v2/enqueue' }) - ).toEqual({ apiUrl: 'https://events.servicenow.com/v2/enqueue' }); - }); - - test('config validation returns an error if the specified URL isnt whitelisted', () => { - actionType = getActionType({ - configurationUtilities: { - ...configUtilsMock, - ensureWhitelistedUri: _ => { - throw new Error(`target url is not whitelisted`); - }, - }, - }); - - expect(() => { - validateConfig(actionType, { apiUrl: 'https://events.servicenow.com/v2/enqueue' }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type config: error configuring servicenow action: target url is not whitelisted"` - ); - }); -}); - -describe('validateSecrets()', () => { - test('should validate and pass when secrets is valid', () => { - const { secrets } = mockServiceNow; - expect(validateSecrets(actionType, secrets)).toEqual(secrets); - }); - - test('should validate and throw error when secrets is invalid', () => { - expect(() => { - validateSecrets(actionType, { username: false }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type secrets: [password]: expected value of type [string] but got [undefined]"` - ); - - expect(() => { - validateSecrets(actionType, { username: false, password: 'hello' }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type secrets: [username]: expected value of type [string] but got [boolean]"` - ); - }); -}); - -describe('validateParams()', () => { - test('should validate and pass when params is valid', () => { - const { params } = mockServiceNow; - expect(validateParams(actionType, params)).toEqual(params); - }); - - test('should validate and throw error when params is invalid', () => { - expect(() => { - validateParams(actionType, { eventAction: 'ackynollage' }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action params: [short_description]: expected value of type [string] but got [undefined]"` - ); - }); -}); - -describe('execute()', () => { - beforeEach(() => { - postServiceNowMock.mockReset(); - }); - const { config, params, secrets } = mockServiceNow; - test('should succeed with valid params', async () => { - postServiceNowMock.mockImplementation(() => { - return { status: 201, data: 'data-here' }; - }); - - const actionId = 'some-action-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; - const actionResponse = await actionType.executor(executorOptions); - const { apiUrl, data, headers } = postServiceNowMock.mock.calls[0][0]; - expect({ apiUrl, data, headers, secrets }).toMatchInlineSnapshot(` - Object { - "apiUrl": "www.servicenowisinkibanaactions.com", - "data": Object { - "comments": "hello cool service now incident", - "short_description": "this is a cool service now incident", - }, - "headers": Object { - "Accept": "application/json", - "Content-Type": "application/json", - }, - "secrets": Object { - "password": "secret-password", - "username": "secret-username", - }, - } - `); - expect(actionResponse).toMatchInlineSnapshot(` - Object { - "actionId": "some-action-id", - "data": "data-here", - "status": "ok", - } - `); - }); - - test('should fail when postServiceNow throws', async () => { - postServiceNowMock.mockImplementation(() => { - throw new Error('doing some testing'); - }); - - const actionId = 'some-action-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; - const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toMatchInlineSnapshot(` - Object { - "actionId": "some-action-id", - "message": "error posting servicenow event", - "serviceMessage": "doing some testing", - "status": "error", - } - `); - }); - - test('should fail when postServiceNow returns 429', async () => { - postServiceNowMock.mockImplementation(() => { - return { status: 429, data: 'data-here' }; - }); - - const actionId = 'some-action-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; - const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toMatchInlineSnapshot(` - Object { - "actionId": "some-action-id", - "message": "error posting servicenow event: http status 429, retry later", - "retry": true, - "status": "error", - } - `); - }); - - test('should fail when postServiceNow returns 501', async () => { - postServiceNowMock.mockImplementation(() => { - return { status: 501, data: 'data-here' }; - }); - - const actionId = 'some-action-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; - const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toMatchInlineSnapshot(` - Object { - "actionId": "some-action-id", - "message": "error posting servicenow event: http status 501, retry later", - "retry": true, - "status": "error", - } - `); - }); - - test('should fail when postServiceNow returns 418', async () => { - postServiceNowMock.mockImplementation(() => { - return { status: 418, data: 'data-here' }; - }); - - const actionId = 'some-action-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; - const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toMatchInlineSnapshot(` - Object { - "actionId": "some-action-id", - "message": "error posting servicenow event: unexpected status 418", - "status": "error", - } - `); - }); -}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow.ts deleted file mode 100644 index 0ad435281eba4..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow.ts +++ /dev/null @@ -1,171 +0,0 @@ -/* - * 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 { curry } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { schema, TypeOf } from '@kbn/config-schema'; -import { - ActionType, - ActionTypeExecutorOptions, - ActionTypeExecutorResult, - ExecutorType, -} from '../types'; -import { ActionsConfigurationUtilities } from '../actions_config'; -import { postServiceNow } from './lib/post_servicenow'; - -// config definition -export type ConfigType = TypeOf; - -const ConfigSchemaProps = { - apiUrl: schema.string(), -}; - -const ConfigSchema = schema.object(ConfigSchemaProps); - -function validateConfig( - configurationUtilities: ActionsConfigurationUtilities, - configObject: ConfigType -) { - if (configObject.apiUrl == null) { - return i18n.translate('xpack.actions.builtin.servicenow.servicenowApiNullError', { - defaultMessage: 'ServiceNow [apiUrl] is required', - }); - } - try { - configurationUtilities.ensureWhitelistedUri(configObject.apiUrl); - } catch (whitelistError) { - return i18n.translate('xpack.actions.builtin.servicenow.servicenowApiWhitelistError', { - defaultMessage: 'error configuring servicenow action: {message}', - values: { - message: whitelistError.message, - }, - }); - } -} -// secrets definition -export type SecretsType = TypeOf; -const SecretsSchemaProps = { - password: schema.string(), - username: schema.string(), -}; - -const SecretsSchema = schema.object(SecretsSchemaProps); - -function validateSecrets( - configurationUtilities: ActionsConfigurationUtilities, - secrets: SecretsType -) { - if (secrets.username == null) { - return i18n.translate('xpack.actions.builtin.servicenow.servicenowApiUserError', { - defaultMessage: 'error configuring servicenow action: no secrets [username] provided', - }); - } - if (secrets.password == null) { - return i18n.translate('xpack.actions.builtin.servicenow.servicenowApiPasswordError', { - defaultMessage: 'error configuring servicenow action: no secrets [password] provided', - }); - } -} - -// params definition - -export type ParamsType = TypeOf; - -const ParamsSchema = schema.object({ - comments: schema.maybe(schema.string()), - short_description: schema.string(), -}); - -// action type definition -export function getActionType({ - configurationUtilities, - executor = serviceNowExecutor, -}: { - configurationUtilities: ActionsConfigurationUtilities; - executor?: ExecutorType; -}): ActionType { - return { - id: '.servicenow', - name: i18n.translate('xpack.actions.builtin.servicenowTitle', { - defaultMessage: 'ServiceNow', - }), - validate: { - config: schema.object(ConfigSchemaProps, { - validate: curry(validateConfig)(configurationUtilities), - }), - secrets: schema.object(SecretsSchemaProps, { - validate: curry(validateSecrets)(configurationUtilities), - }), - params: ParamsSchema, - }, - executor, - }; -} - -// action executor - -async function serviceNowExecutor( - execOptions: ActionTypeExecutorOptions -): Promise { - const actionId = execOptions.actionId; - const config = execOptions.config as ConfigType; - const secrets = execOptions.secrets as SecretsType; - const params = execOptions.params as ParamsType; - const headers = { - Accept: 'application/json', - 'Content-Type': 'application/json', - }; - let response; - try { - response = await postServiceNow({ apiUrl: config.apiUrl, data: params, headers, secrets }); - } catch (err) { - const message = i18n.translate('xpack.actions.builtin.servicenow.postingErrorMessage', { - defaultMessage: 'error posting servicenow event', - }); - return { - status: 'error', - actionId, - message, - serviceMessage: err.message, - }; - } - if (response.status === 200 || response.status === 201 || response.status === 204) { - return { - status: 'ok', - actionId, - data: response.data, - }; - } - - if (response.status === 429 || response.status >= 500) { - const message = i18n.translate('xpack.actions.builtin.servicenow.postingRetryErrorMessage', { - defaultMessage: 'error posting servicenow event: http status {status}, retry later', - values: { - status: response.status, - }, - }); - - return { - status: 'error', - actionId, - message, - retry: true, - }; - } - - const message = i18n.translate('xpack.actions.builtin.servicenow.postingUnexpectedErrorMessage', { - defaultMessage: 'error posting servicenow event: unexpected status {status}', - values: { - status: response.status, - }, - }); - - return { - status: 'error', - actionId, - message, - }; -} diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts new file mode 100644 index 0000000000000..381b44439033c --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts @@ -0,0 +1,157 @@ +/* + * 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 { handleCreateIncident, handleUpdateIncident } from './action_handlers'; +import { ServiceNow } from './lib'; +import { finalMapping } from './mock'; +import { Incident } from './lib/types'; + +jest.mock('./lib'); + +const ServiceNowMock = ServiceNow as jest.Mock; + +const incident: Incident = { + short_description: 'A title', + description: 'A description', +}; + +const comments = [ + { + commentId: '456', + version: 'WzU3LDFd', + comment: 'A comment', + incidentCommentId: undefined, + }, +]; + +describe('handleCreateIncident', () => { + beforeAll(() => { + ServiceNowMock.mockImplementation(() => { + return { + serviceNow: { + getUserID: jest.fn().mockResolvedValue('1234'), + createIncident: jest.fn().mockResolvedValue({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + }), + updateIncident: jest.fn().mockResolvedValue({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + }), + batchCreateComments: jest + .fn() + .mockResolvedValue([{ commentId: '456', pushedDate: '2020-03-10T12:24:20.000Z' }]), + batchUpdateComments: jest + .fn() + .mockResolvedValue([{ commentId: '456', pushedDate: '2020-03-10T12:24:20.000Z' }]), + }, + }; + }); + }); + + test('create an incident without comments', async () => { + const { serviceNow } = new ServiceNowMock(); + + const res = await handleCreateIncident({ + serviceNow, + params: incident, + comments: [], + mapping: finalMapping, + }); + + expect(serviceNow.createIncident).toHaveBeenCalled(); + expect(serviceNow.createIncident).toHaveBeenCalledWith(incident); + expect(serviceNow.createIncident).toHaveReturned(); + expect(serviceNow.batchCreateComments).not.toHaveBeenCalled(); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + }); + }); + + test('create an incident with comments', async () => { + const { serviceNow } = new ServiceNowMock(); + + const res = await handleCreateIncident({ + serviceNow, + params: incident, + comments, + mapping: finalMapping, + }); + + expect(serviceNow.createIncident).toHaveBeenCalled(); + expect(serviceNow.createIncident).toHaveBeenCalledWith(incident); + expect(serviceNow.createIncident).toHaveReturned(); + expect(serviceNow.batchCreateComments).toHaveBeenCalled(); + expect(serviceNow.batchCreateComments).toHaveBeenCalledWith('123', comments, 'comments'); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + comments: [ + { + commentId: '456', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + ], + }); + }); + + test('update an incident without comments', async () => { + const { serviceNow } = new ServiceNowMock(); + + const res = await handleUpdateIncident({ + incidentId: '123', + serviceNow, + params: incident, + comments: [], + mapping: finalMapping, + }); + + expect(serviceNow.updateIncident).toHaveBeenCalled(); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', incident); + expect(serviceNow.updateIncident).toHaveReturned(); + expect(serviceNow.batchUpdateComments).not.toHaveBeenCalled(); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + }); + }); + + test('update an incident and create new comments', async () => { + const { serviceNow } = new ServiceNowMock(); + + const res = await handleUpdateIncident({ + incidentId: '123', + serviceNow, + params: incident, + comments, + mapping: finalMapping, + }); + + expect(serviceNow.updateIncident).toHaveBeenCalled(); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', incident); + expect(serviceNow.updateIncident).toHaveReturned(); + expect(serviceNow.batchUpdateComments).not.toHaveBeenCalled(); + expect(serviceNow.batchCreateComments).toHaveBeenCalledWith('123', comments, 'comments'); + + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + comments: [ + { + commentId: '456', + pushedDate: '2020-03-10T12:24:20.000Z', + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts new file mode 100644 index 0000000000000..47120c5da096d --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts @@ -0,0 +1,78 @@ +/* + * 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 { zipWith } from 'lodash'; +import { Incident, CommentResponse } from './lib/types'; +import { + ActionHandlerArguments, + UpdateParamsType, + UpdateActionHandlerArguments, + IncidentCreationResponse, + CommentType, + CommentsZipped, +} from './types'; +import { ServiceNow } from './lib'; + +const createComments = async ( + serviceNow: ServiceNow, + incidentId: string, + key: string, + comments: CommentType[] +): Promise => { + const createdComments = await serviceNow.batchCreateComments(incidentId, comments, key); + + return zipWith(comments, createdComments, (a: CommentType, b: CommentResponse) => ({ + commentId: a.commentId, + pushedDate: b.pushedDate, + })); +}; + +export const handleCreateIncident = async ({ + serviceNow, + params, + comments, + mapping, +}: ActionHandlerArguments): Promise => { + const paramsAsIncident = params as Incident; + + const { incidentId, number, pushedDate } = await serviceNow.createIncident({ + ...paramsAsIncident, + }); + + const res: IncidentCreationResponse = { incidentId, number, pushedDate }; + + if (comments && Array.isArray(comments) && comments.length > 0) { + res.comments = [ + ...(await createComments(serviceNow, incidentId, mapping.get('comments').target, comments)), + ]; + } + + return { ...res }; +}; + +export const handleUpdateIncident = async ({ + incidentId, + serviceNow, + params, + comments, + mapping, +}: UpdateActionHandlerArguments): Promise => { + const paramsAsIncident = params as UpdateParamsType; + + const { number, pushedDate } = await serviceNow.updateIncident(incidentId, { + ...paramsAsIncident, + }); + + const res: IncidentCreationResponse = { incidentId, number, pushedDate }; + + if (comments && Array.isArray(comments) && comments.length > 0) { + res.comments = [ + ...(await createComments(serviceNow, incidentId, mapping.get('comments').target, comments)), + ]; + } + + return { ...res }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/constants.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/constants.ts new file mode 100644 index 0000000000000..a0ffd859e14ca --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/constants.ts @@ -0,0 +1,8 @@ +/* + * 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 const ACTION_TYPE_ID = '.servicenow'; +export const SUPPORTED_SOURCE_FIELDS = ['title', 'comments', 'description']; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.test.ts new file mode 100644 index 0000000000000..96962b41b3c68 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { normalizeMapping, buildMap, mapParams } from './helpers'; +import { mapping, finalMapping } from './mock'; +import { SUPPORTED_SOURCE_FIELDS } from './constants'; +import { MapsType } from './types'; + +const maliciousMapping: MapsType[] = [ + { source: '__proto__', target: 'short_description', actionType: 'nothing' }, + { source: 'description', target: '__proto__', actionType: 'nothing' }, + { source: 'comments', target: 'comments', actionType: 'nothing' }, + { source: 'unsupportedSource', target: 'comments', actionType: 'nothing' }, +]; + +describe('sanitizeMapping', () => { + test('remove malicious fields', () => { + const sanitizedMapping = normalizeMapping(SUPPORTED_SOURCE_FIELDS, maliciousMapping); + expect(sanitizedMapping.every(m => m.source !== '__proto__' && m.target !== '__proto__')).toBe( + true + ); + }); + + test('remove unsuppported source fields', () => { + const normalizedMapping = normalizeMapping(SUPPORTED_SOURCE_FIELDS, maliciousMapping); + expect(normalizedMapping).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: 'unsupportedSource', + target: 'comments', + actionType: 'nothing', + }), + ]) + ); + }); +}); + +describe('buildMap', () => { + test('builds sanitized Map', () => { + const finalMap = buildMap(maliciousMapping); + expect(finalMap.get('__proto__')).not.toBeDefined(); + }); + + test('builds Map correct', () => { + const final = buildMap(mapping); + expect(final).toEqual(finalMapping); + }); +}); + +describe('mapParams', () => { + test('maps params correctly', () => { + const params = { + caseId: '123', + incidentId: '456', + title: 'Incident title', + description: 'Incident description', + }; + + const fields = mapParams(params, finalMapping); + + expect(fields).toEqual({ + short_description: 'Incident title', + description: 'Incident description', + }); + }); + + test('do not add fields not in mapping', () => { + const params = { + caseId: '123', + incidentId: '456', + title: 'Incident title', + description: 'Incident description', + }; + const fields = mapParams(params, finalMapping); + + const { title, description, ...unexpectedFields } = params; + + expect(fields).not.toEqual(expect.objectContaining(unexpectedFields)); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.ts new file mode 100644 index 0000000000000..99e67c1c43f35 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SUPPORTED_SOURCE_FIELDS } from './constants'; +import { MapsType, FinalMapping } from './types'; + +export const normalizeMapping = (fields: string[], mapping: MapsType[]): MapsType[] => { + // Prevent prototype pollution and remove unsupported fields + return mapping.filter( + m => m.source !== '__proto__' && m.target !== '__proto__' && fields.includes(m.source) + ); +}; + +export const buildMap = (mapping: MapsType[]): FinalMapping => { + return normalizeMapping(SUPPORTED_SOURCE_FIELDS, mapping).reduce((fieldsMap, field) => { + const { source, target, actionType } = field; + fieldsMap.set(source, { target, actionType }); + fieldsMap.set(target, { target: source, actionType }); + return fieldsMap; + }, new Map()); +}; + +interface KeyAny { + [key: string]: unknown; +} + +export const mapParams = (params: any, mapping: FinalMapping) => { + return Object.keys(params).reduce((prev: KeyAny, curr: string): KeyAny => { + const field = mapping.get(curr); + if (field) { + prev[field.target] = params[curr]; + } + return prev; + }, {}); +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts new file mode 100644 index 0000000000000..a1df243b0ee7c --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts @@ -0,0 +1,256 @@ +/* + * 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 { getActionType } from '.'; +import { ActionType, Services, ActionTypeExecutorOptions } from '../../types'; +import { validateConfig, validateSecrets, validateParams } from '../../lib'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/mocks'; +import { createActionTypeRegistry } from '../index.test'; +import { configUtilsMock } from '../../actions_config.mock'; + +import { ACTION_TYPE_ID } from './constants'; +import * as i18n from './translations'; + +import { handleCreateIncident, handleUpdateIncident } from './action_handlers'; +import { incidentResponse } from './mock'; + +jest.mock('./action_handlers'); + +const handleCreateIncidentMock = handleCreateIncident as jest.Mock; +const handleUpdateIncidentMock = handleUpdateIncident as jest.Mock; + +const services: Services = { + callCluster: async (path: string, opts: any) => {}, + savedObjectsClient: savedObjectsClientMock.create(), +}; + +let actionType: ActionType; + +const mockOptions = { + name: 'servicenow-connector', + actionTypeId: '.servicenow', + secrets: { + username: 'secret-username', + password: 'secret-password', + }, + config: { + apiUrl: 'https://service-now.com', + casesConfiguration: { + mapping: [ + { + source: 'title', + target: 'short_description', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'work_notes', + actionType: 'append', + }, + ], + }, + }, + params: { + caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + incidentId: 'ceb5986e079f00100e48fbbf7c1ed06d', + title: 'Incident title', + description: 'Incident description', + comments: [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + version: 'WzU3LDFd', + comment: 'A comment', + incidentCommentId: '315e1ece071300100e48fbbf7c1ed0d0', + }, + ], + }, +}; + +beforeAll(() => { + const { actionTypeRegistry } = createActionTypeRegistry(); + actionType = actionTypeRegistry.get(ACTION_TYPE_ID); +}); + +describe('get()', () => { + test('should return correct action type', () => { + expect(actionType.id).toEqual(ACTION_TYPE_ID); + expect(actionType.name).toEqual(i18n.NAME); + }); +}); + +describe('validateConfig()', () => { + test('should validate and pass when config is valid', () => { + const { config } = mockOptions; + expect(validateConfig(actionType, config)).toEqual(config); + }); + + test('should validate and throw error when config is invalid', () => { + expect(() => { + validateConfig(actionType, { shouldNotBeHere: true }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]"` + ); + }); + + test('should validate and pass when the servicenow url is whitelisted', () => { + actionType = getActionType({ + configurationUtilities: { + ...configUtilsMock, + ensureWhitelistedUri: url => { + expect(url).toEqual(mockOptions.config.apiUrl); + }, + }, + }); + + expect(validateConfig(actionType, mockOptions.config)).toEqual(mockOptions.config); + }); + + test('config validation returns an error if the specified URL isnt whitelisted', () => { + actionType = getActionType({ + configurationUtilities: { + ...configUtilsMock, + ensureWhitelistedUri: _ => { + throw new Error(`target url is not whitelisted`); + }, + }, + }); + + expect(() => { + validateConfig(actionType, mockOptions.config); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: error configuring servicenow action: target url is not whitelisted"` + ); + }); +}); + +describe('validateSecrets()', () => { + test('should validate and pass when secrets is valid', () => { + const { secrets } = mockOptions; + expect(validateSecrets(actionType, secrets)).toEqual(secrets); + }); + + test('should validate and throw error when secrets is invalid', () => { + expect(() => { + validateSecrets(actionType, { username: false }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [password]: expected value of type [string] but got [undefined]"` + ); + + expect(() => { + validateSecrets(actionType, { username: false, password: 'hello' }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [username]: expected value of type [string] but got [boolean]"` + ); + }); +}); + +describe('validateParams()', () => { + test('should validate and pass when params is valid', () => { + const { params } = mockOptions; + expect(validateParams(actionType, params)).toEqual(params); + }); + + test('should validate and throw error when params is invalid', () => { + expect(() => { + validateParams(actionType, {}); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: [caseId]: expected value of type [string] but got [undefined]"` + ); + }); +}); + +describe('execute()', () => { + beforeEach(() => { + handleCreateIncidentMock.mockReset(); + handleUpdateIncidentMock.mockReset(); + }); + + test('should create an incident', async () => { + const actionId = 'some-id'; + const { incidentId, ...rest } = mockOptions.params; + + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config: mockOptions.config, + params: { ...rest }, + secrets: mockOptions.secrets, + services, + }; + + handleCreateIncidentMock.mockImplementation(() => incidentResponse); + + const actionResponse = await actionType.executor(executorOptions); + expect(actionResponse).toEqual({ actionId, status: 'ok', data: incidentResponse }); + }); + + test('should throw an error when failed to create incident', async () => { + expect.assertions(1); + const { incidentId, ...rest } = mockOptions.params; + + const actionId = 'some-id'; + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config: mockOptions.config, + params: { ...rest }, + secrets: mockOptions.secrets, + services, + }; + const errorMessage = 'Failed to create incident'; + + handleCreateIncidentMock.mockImplementation(() => { + throw new Error(errorMessage); + }); + + try { + await actionType.executor(executorOptions); + } catch (error) { + expect(error.message).toEqual(errorMessage); + } + }); + + test('should update an incident', async () => { + const actionId = 'some-id'; + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config: mockOptions.config, + params: { ...mockOptions.params, executorAction: 'updateIncident' }, + secrets: mockOptions.secrets, + services, + }; + + const actionResponse = await actionType.executor(executorOptions); + expect(actionResponse).toEqual({ actionId, status: 'ok' }); + }); + + test('should throw an error when failed to update an incident', async () => { + expect.assertions(1); + + const actionId = 'some-id'; + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config: mockOptions.config, + params: { ...mockOptions.params, executorAction: 'updateIncident' }, + secrets: mockOptions.secrets, + services, + }; + const errorMessage = 'Failed to update incident'; + + handleUpdateIncidentMock.mockImplementation(() => { + throw new Error(errorMessage); + }); + + try { + await actionType.executor(executorOptions); + } catch (error) { + expect(error.message).toEqual(errorMessage); + } + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts new file mode 100644 index 0000000000000..01e566af17d08 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -0,0 +1,115 @@ +/* + * 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 { curry, isEmpty } from 'lodash'; +import { schema } from '@kbn/config-schema'; +import { + ActionType, + ActionTypeExecutorOptions, + ActionTypeExecutorResult, + ExecutorType, +} from '../../types'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { ServiceNow } from './lib'; + +import * as i18n from './translations'; + +import { ACTION_TYPE_ID } from './constants'; +import { ConfigType, SecretsType, ParamsType, CommentType } from './types'; + +import { ConfigSchemaProps, SecretsSchemaProps, ParamsSchema } from './schema'; + +import { buildMap, mapParams } from './helpers'; +import { handleCreateIncident, handleUpdateIncident } from './action_handlers'; + +function validateConfig( + configurationUtilities: ActionsConfigurationUtilities, + configObject: ConfigType +) { + try { + if (isEmpty(configObject.casesConfiguration.mapping)) { + return i18n.MAPPING_EMPTY; + } + + configurationUtilities.ensureWhitelistedUri(configObject.apiUrl); + } catch (whitelistError) { + return i18n.WHITE_LISTED_ERROR(whitelistError.message); + } +} + +function validateSecrets( + configurationUtilities: ActionsConfigurationUtilities, + secrets: SecretsType +) {} + +// action type definition +export function getActionType({ + configurationUtilities, + executor = serviceNowExecutor, +}: { + configurationUtilities: ActionsConfigurationUtilities; + executor?: ExecutorType; +}): ActionType { + return { + id: ACTION_TYPE_ID, + name: i18n.NAME, + validate: { + config: schema.object(ConfigSchemaProps, { + validate: curry(validateConfig)(configurationUtilities), + }), + secrets: schema.object(SecretsSchemaProps, { + validate: curry(validateSecrets)(configurationUtilities), + }), + params: ParamsSchema, + }, + executor, + }; +} + +// action executor + +async function serviceNowExecutor( + execOptions: ActionTypeExecutorOptions +): Promise { + const actionId = execOptions.actionId; + const { + apiUrl, + casesConfiguration: { mapping }, + } = execOptions.config as ConfigType; + const { username, password } = execOptions.secrets as SecretsType; + const params = execOptions.params as ParamsType; + const { comments, incidentId, ...restParams } = params; + + const finalMap = buildMap(mapping); + const restParamsMapped = mapParams(restParams, finalMap); + const serviceNow = new ServiceNow({ url: apiUrl, username, password }); + + const handlerInput = { + serviceNow, + params: restParamsMapped, + comments: comments as CommentType[], + mapping: finalMap, + }; + + const res: Pick & + Pick = { + status: 'ok', + actionId, + }; + + let data = {}; + + if (!incidentId) { + data = await handleCreateIncident(handlerInput); + } else { + data = await handleUpdateIncident({ incidentId, ...handlerInput }); + } + + return { + ...res, + data, + }; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts new file mode 100644 index 0000000000000..c84e1928e2e5a --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/constants.ts @@ -0,0 +1,10 @@ +/* + * 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 const API_VERSION = 'v2'; +export const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`; +export const USER_URL = `api/now/${API_VERSION}/table/sys_user?user_name=`; +export const COMMENT_URL = `api/now/${API_VERSION}/table/incident`; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts new file mode 100644 index 0000000000000..22be625611e85 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.test.ts @@ -0,0 +1,232 @@ +/* + * 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 axios from 'axios'; +import { ServiceNow } from '.'; +import { instance, params } from '../mock'; + +jest.mock('axios'); + +axios.create = jest.fn(() => axios); +const axiosMock = (axios as unknown) as jest.Mock; + +let serviceNow: ServiceNow; + +const testMissingConfiguration = (field: string) => { + expect.assertions(1); + try { + new ServiceNow({ ...instance, [field]: '' }); + } catch (error) { + expect(error.message).toEqual('[Action][ServiceNow]: Wrong configuration.'); + } +}; + +const prependInstanceUrl = (url: string): string => `${instance.url}/${url}`; + +describe('ServiceNow lib', () => { + beforeEach(() => { + serviceNow = new ServiceNow(instance); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should thrown an error if url is missing', () => { + testMissingConfiguration('url'); + }); + + test('should thrown an error if username is missing', () => { + testMissingConfiguration('username'); + }); + + test('should thrown an error if password is missing', () => { + testMissingConfiguration('password'); + }); + + test('get user id', async () => { + axiosMock.mockResolvedValue({ + status: 200, + headers: { + 'content-type': 'application/json', + }, + data: { result: [{ sys_id: '123' }] }, + }); + + const res = await serviceNow.getUserID(); + const [url, { method }] = axiosMock.mock.calls[0]; + + expect(url).toEqual(prependInstanceUrl('api/now/v2/table/sys_user?user_name=username')); + expect(method).toEqual('get'); + expect(res).toEqual('123'); + }); + + test('create incident', async () => { + axiosMock.mockResolvedValue({ + status: 200, + headers: { + 'content-type': 'application/json', + }, + data: { result: { sys_id: '123', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } }, + }); + + const res = await serviceNow.createIncident({ + short_description: 'A title', + description: 'A description', + caller_id: '123', + }); + const [url, { method, data }] = axiosMock.mock.calls[0]; + + expect(url).toEqual(prependInstanceUrl('api/now/v2/table/incident')); + expect(method).toEqual('post'); + expect(data).toEqual({ + short_description: 'A title', + description: 'A description', + caller_id: '123', + }); + + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + }); + }); + + test('update incident', async () => { + axiosMock.mockResolvedValue({ + status: 200, + headers: { + 'content-type': 'application/json', + }, + data: { result: { sys_id: '123', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } }, + }); + + const res = await serviceNow.updateIncident('123', { + short_description: params.title, + }); + const [url, { method, data }] = axiosMock.mock.calls[0]; + + expect(url).toEqual(prependInstanceUrl(`api/now/v2/table/incident/123`)); + expect(method).toEqual('patch'); + expect(data).toEqual({ short_description: params.title }); + expect(res).toEqual({ + incidentId: '123', + number: 'INC01', + pushedDate: '2020-03-10T12:24:20.000Z', + }); + }); + + test('create comment', async () => { + axiosMock.mockResolvedValue({ + status: 200, + headers: { + 'content-type': 'application/json', + }, + data: { result: { sys_updated_on: '2020-03-10 12:24:20' } }, + }); + + const comment = { + commentId: '456', + version: 'WzU3LDFd', + comment: 'A comment', + incidentCommentId: undefined, + }; + + const res = await serviceNow.createComment('123', comment, 'comments'); + + const [url, { method, data }] = axiosMock.mock.calls[0]; + + expect(url).toEqual(prependInstanceUrl(`api/now/v2/table/incident/123`)); + expect(method).toEqual('patch'); + expect(data).toEqual({ + comments: 'A comment', + }); + + expect(res).toEqual({ + commentId: '456', + pushedDate: '2020-03-10T12:24:20.000Z', + }); + }); + + test('create batch comment', async () => { + axiosMock.mockReturnValueOnce({ + status: 200, + headers: { + 'content-type': 'application/json', + }, + data: { result: { sys_updated_on: '2020-03-10 12:24:20' } }, + }); + + axiosMock.mockReturnValueOnce({ + status: 200, + headers: { + 'content-type': 'application/json', + }, + data: { result: { sys_updated_on: '2020-03-10 12:25:20' } }, + }); + + const comments = [ + { + commentId: '123', + version: 'WzU3LDFd', + comment: 'A comment', + incidentCommentId: undefined, + }, + { + commentId: '456', + version: 'WzU3LDFd', + comment: 'A second comment', + incidentCommentId: undefined, + }, + ]; + const res = await serviceNow.batchCreateComments('000', comments, 'comments'); + + comments.forEach((comment, index) => { + const [url, { method, data }] = axiosMock.mock.calls[index]; + expect(url).toEqual(prependInstanceUrl('api/now/v2/table/incident/000')); + expect(method).toEqual('patch'); + expect(data).toEqual({ + comments: comment.comment, + }); + expect(res).toEqual([ + { commentId: '123', pushedDate: '2020-03-10T12:24:20.000Z' }, + { commentId: '456', pushedDate: '2020-03-10T12:25:20.000Z' }, + ]); + }); + }); + + test('throw if not status is not ok', async () => { + expect.assertions(1); + + axiosMock.mockResolvedValue({ + status: 401, + headers: { + 'content-type': 'application/json', + }, + }); + try { + await serviceNow.getUserID(); + } catch (error) { + expect(error.message).toEqual('[ServiceNow]: Instance is not alive.'); + } + }); + + test('throw if not content-type is not application/json', async () => { + expect.assertions(1); + + axiosMock.mockResolvedValue({ + status: 200, + headers: { + 'content-type': 'application/html', + }, + }); + try { + await serviceNow.getUserID(); + } catch (error) { + expect(error.message).toEqual('[ServiceNow]: Instance is not alive.'); + } + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts new file mode 100644 index 0000000000000..b3d17affb14c2 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/index.ts @@ -0,0 +1,129 @@ +/* + * 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 axios, { AxiosInstance, Method, AxiosResponse } from 'axios'; + +import { INCIDENT_URL, USER_URL, COMMENT_URL } from './constants'; +import { Instance, Incident, IncidentResponse, UpdateIncident, CommentResponse } from './types'; +import { CommentType } from '../types'; + +const validStatusCodes = [200, 201]; + +class ServiceNow { + private readonly incidentUrl: string; + private readonly commentUrl: string; + private readonly userUrl: string; + private readonly axios: AxiosInstance; + + constructor(private readonly instance: Instance) { + if ( + !this.instance || + !this.instance.url || + !this.instance.username || + !this.instance.password + ) { + throw Error('[Action][ServiceNow]: Wrong configuration.'); + } + + this.incidentUrl = `${this.instance.url}/${INCIDENT_URL}`; + this.commentUrl = `${this.instance.url}/${COMMENT_URL}`; + this.userUrl = `${this.instance.url}/${USER_URL}`; + this.axios = axios.create({ + auth: { username: this.instance.username, password: this.instance.password }, + }); + } + + private _throwIfNotAlive(status: number, contentType: string) { + if (!validStatusCodes.includes(status) || !contentType.includes('application/json')) { + throw new Error('[ServiceNow]: Instance is not alive.'); + } + } + + private async _request({ + url, + method = 'get', + data = {}, + }: { + url: string; + method?: Method; + data?: any; + }): Promise { + const res = await this.axios(url, { method, data }); + this._throwIfNotAlive(res.status, res.headers['content-type']); + return res; + } + + private _patch({ url, data }: { url: string; data: any }): Promise { + return this._request({ + url, + method: 'patch', + data, + }); + } + + private _addTimeZoneToDate(date: string, timezone = 'GMT'): string { + return `${date} GMT`; + } + + async getUserID(): Promise { + const res = await this._request({ url: `${this.userUrl}${this.instance.username}` }); + return res.data.result[0].sys_id; + } + + async createIncident(incident: Incident): Promise { + const res = await this._request({ + url: `${this.incidentUrl}`, + method: 'post', + data: { ...incident }, + }); + + return { + number: res.data.result.number, + incidentId: res.data.result.sys_id, + pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(), + }; + } + + async updateIncident(incidentId: string, incident: UpdateIncident): Promise { + const res = await this._patch({ + url: `${this.incidentUrl}/${incidentId}`, + data: { ...incident }, + }); + + return { + number: res.data.result.number, + incidentId: res.data.result.sys_id, + pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), + }; + } + + async batchCreateComments( + incidentId: string, + comments: CommentType[], + field: string + ): Promise { + const res = await Promise.all(comments.map(c => this.createComment(incidentId, c, field))); + return res; + } + + async createComment( + incidentId: string, + comment: CommentType, + field: string + ): Promise { + const res = await this._patch({ + url: `${this.commentUrl}/${incidentId}`, + data: { [field]: comment.comment }, + }); + + return { + commentId: comment.commentId, + pushedDate: new Date(this._addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), + }; + } +} + +export { ServiceNow }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.ts new file mode 100644 index 0000000000000..4a3c5c42fcb44 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/lib/types.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. + */ + +export interface Instance { + url: string; + username: string; + password: string; +} + +export interface Incident { + short_description?: string; + description?: string; + caller_id?: string; +} + +export interface IncidentResponse { + number: string; + incidentId: string; + pushedDate: string; +} + +export interface CommentResponse { + commentId: string; + pushedDate: string; +} + +export type UpdateIncident = Partial; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts new file mode 100644 index 0000000000000..9a150bbede5f8 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts @@ -0,0 +1,103 @@ +/* + * 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 { MapsType, FinalMapping, ParamsType } from './types'; +import { Incident } from './lib/types'; + +const mapping: MapsType[] = [ + { source: 'title', target: 'short_description', actionType: 'nothing' }, + { source: 'description', target: 'description', actionType: 'nothing' }, + { source: 'comments', target: 'comments', actionType: 'nothing' }, +]; + +const finalMapping: FinalMapping = new Map(); + +finalMapping.set('title', { + target: 'short_description', + actionType: 'nothing', +}); + +finalMapping.set('description', { + target: 'description', + actionType: 'nothing', +}); + +finalMapping.set('comments', { + target: 'comments', + actionType: 'nothing', +}); + +finalMapping.set('short_description', { + target: 'title', + actionType: 'nothing', +}); + +const params: ParamsType = { + caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + incidentId: 'ceb5986e079f00100e48fbbf7c1ed06d', + title: 'Incident title', + description: 'Incident description', + comments: [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + version: 'WzU3LDFd', + comment: 'A comment', + incidentCommentId: '263ede42075300100e48fbbf7c1ed047', + }, + { + commentId: 'e3db587f-ca27-4ae9-ad2e-31f2dcc9bd0d', + version: 'WlK3LDFd', + comment: 'Another comment', + incidentCommentId: '315e1ece071300100e48fbbf7c1ed0d0', + }, + ], +}; + +const incidentResponse = { + incidentId: 'c816f79cc0a8016401c5a33be04be441', + number: 'INC0010001', +}; + +const userId = '2e9a0a5e2f79001016ab51172799b670'; + +const axiosResponse = { + status: 200, + headers: { + 'content-type': 'application/json', + }, +}; +const userIdResponse = { + result: [{ sys_id: userId }], +}; + +const incidentAxiosResponse = { + result: { sys_id: incidentResponse.incidentId, number: incidentResponse.number }, +}; + +const instance = { + url: 'https://instance.service-now.com', + username: 'username', + password: 'password', +}; + +const incident: Incident = { + short_description: params.title, + description: params.description, + caller_id: userId, +}; + +export { + mapping, + finalMapping, + params, + incidentResponse, + incidentAxiosResponse, + userId, + userIdResponse, + axiosResponse, + instance, + incident, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts new file mode 100644 index 0000000000000..0bb4f50819665 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -0,0 +1,55 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +export const MapsSchema = schema.object({ + source: schema.string(), + target: schema.string(), + actionType: schema.oneOf([ + schema.literal('nothing'), + schema.literal('overwrite'), + schema.literal('append'), + ]), +}); + +export const CasesConfigurationSchema = schema.object({ + mapping: schema.arrayOf(MapsSchema), +}); + +export const ConfigSchemaProps = { + apiUrl: schema.string(), + casesConfiguration: CasesConfigurationSchema, +}; + +export const ConfigSchema = schema.object(ConfigSchemaProps); + +export const SecretsSchemaProps = { + password: schema.string(), + username: schema.string(), +}; + +export const SecretsSchema = schema.object(SecretsSchemaProps); + +export const CommentSchema = schema.object({ + commentId: schema.string(), + comment: schema.string(), + version: schema.maybe(schema.string()), + incidentCommentId: schema.maybe(schema.string()), +}); + +export const ExecutorAction = schema.oneOf([ + schema.literal('newIncident'), + schema.literal('updateIncident'), +]); + +export const ParamsSchema = schema.object({ + caseId: schema.string(), + comments: schema.maybe(schema.arrayOf(CommentSchema)), + description: schema.maybe(schema.string()), + title: schema.maybe(schema.string()), + incidentId: schema.maybe(schema.string()), +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts new file mode 100644 index 0000000000000..8601c5ce772db --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.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 { i18n } from '@kbn/i18n'; + +export const API_URL_REQUIRED = i18n.translate( + 'xpack.actions.builtin.servicenow.servicenowApiNullError', + { + defaultMessage: 'ServiceNow [apiUrl] is required', + } +); + +export const WHITE_LISTED_ERROR = (message: string) => + i18n.translate('xpack.actions.builtin.servicenow.servicenowApiWhitelistError', { + defaultMessage: 'error configuring servicenow action: {message}', + values: { + message, + }, + }); + +export const NAME = i18n.translate('xpack.actions.builtin.servicenowTitle', { + defaultMessage: 'ServiceNow', +}); + +export const MAPPING_EMPTY = i18n.translate('xpack.actions.builtin.servicenow.emptyMapping', { + defaultMessage: '[casesConfiguration.mapping]: expected non-empty but got empty', +}); + +export const ERROR_POSTING = i18n.translate( + 'xpack.actions.builtin.servicenow.postingErrorMessage', + { + defaultMessage: 'error posting servicenow event', + } +); + +export const RETRY_POSTING = (status: number) => + i18n.translate('xpack.actions.builtin.servicenow.postingRetryErrorMessage', { + defaultMessage: 'error posting servicenow event: http status {status}, retry later', + values: { + status, + }, + }); + +export const UNEXPECTED_STATUS = (status: number) => + i18n.translate('xpack.actions.builtin.servicenow.postingUnexpectedErrorMessage', { + defaultMessage: 'error posting servicenow event: unexpected status {status}', + values: { + status, + }, + }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts new file mode 100644 index 0000000000000..7442f14fed064 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -0,0 +1,56 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; + +import { + ConfigSchema, + SecretsSchema, + ParamsSchema, + CasesConfigurationSchema, + MapsSchema, + CommentSchema, +} from './schema'; + +import { ServiceNow } from './lib'; + +// config definition +export type ConfigType = TypeOf; + +// secrets definition +export type SecretsType = TypeOf; + +export type ParamsType = TypeOf; + +export type CasesConfigurationType = TypeOf; +export type MapsType = TypeOf; +export type CommentType = TypeOf; + +export type FinalMapping = Map; + +export interface ActionHandlerArguments { + serviceNow: ServiceNow; + params: any; + comments: CommentType[]; + mapping: FinalMapping; +} + +export type UpdateParamsType = Partial; +export type UpdateActionHandlerArguments = ActionHandlerArguments & { + incidentId: string; +}; + +export interface IncidentCreationResponse { + incidentId: string; + number: string; + comments?: CommentsZipped[]; + pushedDate: string; +} + +export interface CommentsZipped { + commentId: string; + pushedDate: string; +} diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts index a872edfc17135..aeec07aba906c 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts @@ -29,7 +29,7 @@ export function getAllExternalServiceSimulatorPaths(): string[] { const allPaths = Object.values(ExternalServiceSimulator).map(service => getExternalServiceSimulatorPath(service) ); - allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v1/table/incident`); + allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident`); return allPaths; } diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/servicenow_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/servicenow_simulation.ts index f215b63560339..3f1a095238939 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/servicenow_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/servicenow_simulation.ts @@ -9,8 +9,10 @@ import Hapi from 'hapi'; interface ServiceNowRequest extends Hapi.Request { payload: { - comments: string; - short_description: string; + caseId: string; + title?: string; + description?: string; + comments?: Array<{ commentId: string; version: string; comment: string }>; }; } export function initPlugin(server: Hapi.Server, path: string) { @@ -22,8 +24,16 @@ export function initPlugin(server: Hapi.Server, path: string) { validate: { options: { abortEarly: false }, payload: Joi.object().keys({ - comments: Joi.string(), - short_description: Joi.string(), + caseId: Joi.string(), + title: Joi.string(), + description: Joi.string(), + comments: Joi.array().items( + Joi.object({ + commentId: Joi.string(), + version: Joi.string(), + comment: Joi.string(), + }) + ), }), }, }, @@ -32,14 +42,46 @@ export function initPlugin(server: Hapi.Server, path: string) { server.route({ method: 'POST', - path: `${path}/api/now/v1/table/incident`, + path: `${path}/api/now/v2/table/incident`, options: { auth: false, validate: { options: { abortEarly: false }, payload: Joi.object().keys({ - comments: Joi.string(), - short_description: Joi.string(), + caseId: Joi.string(), + title: Joi.string(), + description: Joi.string(), + comments: Joi.array().items( + Joi.object({ + commentId: Joi.string(), + version: Joi.string(), + comment: Joi.string(), + }) + ), + }), + }, + }, + handler: servicenowHandler, + }); + + server.route({ + method: 'PATCH', + path: `${path}/api/now/v2/table/incident`, + options: { + auth: false, + validate: { + options: { abortEarly: false }, + payload: Joi.object().keys({ + caseId: Joi.string(), + title: Joi.string(), + description: Joi.string(), + comments: Joi.array().items( + Joi.object({ + commentId: Joi.string(), + version: Joi.string(), + comment: Joi.string(), + }) + ), }), }, }, @@ -51,61 +93,9 @@ export function initPlugin(server: Hapi.Server, path: string) { // more info. function servicenowHandler(request: ServiceNowRequest, h: any) { - const body = request.payload; - const text = body && body.short_description; - if (text == null) { - return jsonResponse(h, 400, 'bad request to servicenow simulator'); - } - - switch (text) { - case 'success': - return jsonResponse(h, 200, 'Success'); - - case 'created': - return jsonResponse(h, 201, 'Created'); - - case 'no_text': - return jsonResponse(h, 204, 'Success'); - - case 'invalid_payload': - return jsonResponse(h, 400, 'Bad Request'); - - case 'unauthorized': - return jsonResponse(h, 401, 'Unauthorized'); - - case 'forbidden': - return jsonResponse(h, 403, 'Forbidden'); - - case 'not_found': - return jsonResponse(h, 404, 'Not found'); - - case 'not_allowed': - return jsonResponse(h, 405, 'Method not allowed'); - - case 'not_acceptable': - return jsonResponse(h, 406, 'Not acceptable'); - - case 'unsupported': - return jsonResponse(h, 415, 'Unsupported media type'); - - case 'status_500': - return jsonResponse(h, 500, 'simulated servicenow 500 response'); - - case 'rate_limit': - const response = { - retry_after: 1, - ok: false, - error: 'rate_limited', - }; - - return h - .response(response) - .type('application/json') - .header('retry-after', '1') - .code(429); - } - - return jsonResponse(h, 400, 'unknown request to servicenow simulator'); + return jsonResponse(h, 200, { + result: { sys_id: '123', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' }, + }); } function jsonResponse(h: any, code: number, object?: any) { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts index 15662649266ae..63c118966cfae 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -13,26 +13,60 @@ import { ExternalServiceSimulator, } from '../../../../common/fixtures/plugins/actions'; -// node ../scripts/functional_test_runner.js --grep "Actions.servicenddd" --config=test/alerting_api_integration/security_and_spaces/config.ts +// node ../scripts/functional_test_runner.js --grep "servicenow" --config=test/alerting_api_integration/security_and_spaces/config.ts + +const mapping = [ + { + source: 'title', + target: 'description', + actionType: 'nothing', + }, + { + source: 'description', + target: 'short_description', + actionType: 'nothing', + }, + { + source: 'comments', + target: 'comments', + actionType: 'nothing', + }, +]; // eslint-disable-next-line import/no-default-export export default function servicenowTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); + const mockServiceNow = { config: { apiUrl: 'www.servicenowisinkibanaactions.com', + casesConfiguration: { mapping: [...mapping] }, }, secrets: { password: 'elastic', username: 'changeme', }, params: { - comments: 'hello cool service now incident', - short_description: 'this is a cool service now incident', + caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + title: 'A title', + description: 'A description', + comments: [ + { + commentId: '123', + version: 'WzU3LDFd', + comment: 'A comment', + }, + { + commentId: '456', + version: 'WzU5LVFd', + comment: 'Another comment', + }, + ], }, }; + describe('servicenow', () => { let simulatedActionId = ''; let servicenowSimulatorURL: string = ''; @@ -55,8 +89,9 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: servicenowSimulatorURL, + casesConfiguration: { ...mockServiceNow.config.casesConfiguration }, }, - secrets: mockServiceNow.secrets, + secrets: { ...mockServiceNow.secrets }, }) .expect(200); @@ -66,6 +101,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: servicenowSimulatorURL, + casesConfiguration: { ...mockServiceNow.config.casesConfiguration }, }, }); @@ -81,11 +117,12 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: servicenowSimulatorURL, + casesConfiguration: { ...mockServiceNow.config.casesConfiguration }, }, }); }); - it('should respond with a 400 Bad Request when creating a servicenow action with no webhookUrl', async () => { + it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => { await supertest .post('/api/action') .set('kbn-xsrf', 'foo') @@ -105,7 +142,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }); }); - it('should respond with a 400 Bad Request when creating a servicenow action with a non whitelisted webhookUrl', async () => { + it('should respond with a 400 Bad Request when creating a servicenow action with a non whitelisted apiUrl', async () => { await supertest .post('/api/action') .set('kbn-xsrf', 'foo') @@ -114,7 +151,9 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: 'http://servicenow.mynonexistent.com', + casesConfiguration: { ...mockServiceNow.config.casesConfiguration }, }, + secrets: { ...mockServiceNow.secrets }, }) .expect(400) .then((resp: any) => { @@ -136,6 +175,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) { actionTypeId: '.servicenow', config: { apiUrl: servicenowSimulatorURL, + casesConfiguration: { ...mockServiceNow.config.casesConfiguration }, }, }) .expect(400) @@ -149,123 +189,127 @@ export default function servicenowTest({ getService }: FtrProviderContext) { }); }); - it('should create our servicenow simulator action successfully', async () => { - const { body: createdSimulatedAction } = await supertest + it('should respond with a 400 Bad Request when creating a servicenow action without casesConfiguration', async () => { + await supertest .post('/api/action') .set('kbn-xsrf', 'foo') .send({ - name: 'A servicenow simulator', + name: 'A servicenow action', actionTypeId: '.servicenow', config: { apiUrl: servicenowSimulatorURL, }, - secrets: mockServiceNow.secrets, - }) - .expect(200); - - simulatedActionId = createdSimulatedAction.id; - }); - - it('should handle executing with a simulated success', async () => { - const { body: result } = await supertest - .post(`/api/action/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - comments: 'success', - short_description: 'success', - }, + secrets: { ...mockServiceNow.secrets }, }) - .expect(200); - - expect(result.status).to.eql('ok'); - }); - - it('should handle executing with a simulated success without comments', async () => { - const { body: result } = await supertest - .post(`/api/action/${simulatedActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ - params: { - short_description: 'success', - }, - }) - .expect(200); - - expect(result.status).to.eql('ok'); + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [casesConfiguration.mapping]: expected value of type [array] but got [undefined]', + }); + }); }); - it('should handle failing with a simulated success without short_description', async () => { + it('should respond with a 400 Bad Request when creating a servicenow action with empty mapping', async () => { await supertest - .post(`/api/action/${simulatedActionId}/_execute`) + .post('/api/action') .set('kbn-xsrf', 'foo') .send({ - params: { - comments: 'success', + name: 'A servicenow action', + actionTypeId: '.servicenow', + config: { + apiUrl: servicenowSimulatorURL, + casesConfiguration: { mapping: [] }, }, + secrets: { ...mockServiceNow.secrets }, }) + .expect(400) .then((resp: any) => { expect(resp.body).to.eql({ - actionId: simulatedActionId, - status: 'error', - retry: false, + statusCode: 400, + error: 'Bad Request', message: - 'error validating action params: [short_description]: expected value of type [string] but got [undefined]', + 'error validating action type config: [casesConfiguration.mapping]: expected non-empty but got empty', }); }); }); - it('should handle a 40x servicenow error', async () => { - const { body: result } = await supertest - .post(`/api/action/${simulatedActionId}/_execute`) + it('should respond with a 400 Bad Request when creating a servicenow action with wrong actionType', async () => { + await supertest + .post('/api/action') .set('kbn-xsrf', 'foo') .send({ - params: { - comments: 'invalid_payload', - short_description: 'invalid_payload', + name: 'A servicenow action', + actionTypeId: '.servicenow', + config: { + apiUrl: servicenowSimulatorURL, + casesConfiguration: { + mapping: [ + { + source: 'title', + target: 'description', + actionType: 'non-supported', + }, + ], + }, }, + secrets: { ...mockServiceNow.secrets }, }) - .expect(200); - expect(result.status).to.equal('error'); - expect(result.message).to.match(/error posting servicenow event: unexpected status 400/); + .expect(400); }); - it('should handle a 429 servicenow error', async () => { - const { body: result } = await supertest - .post(`/api/action/${simulatedActionId}/_execute`) + it('should create our servicenow simulator action successfully', async () => { + const { body: createdSimulatedAction } = await supertest + .post('/api/action') .set('kbn-xsrf', 'foo') .send({ - params: { - comments: 'rate_limit', - short_description: 'rate_limit', + name: 'A servicenow simulator', + actionTypeId: '.servicenow', + config: { + apiUrl: servicenowSimulatorURL, + casesConfiguration: { ...mockServiceNow.config.casesConfiguration }, }, + secrets: { ...mockServiceNow.secrets }, }) .expect(200); - expect(result.status).to.equal('error'); - expect(result.message).to.equal( - 'error posting servicenow event: http status 429, retry later' - ); - expect(result.retry).to.equal(true); + simulatedActionId = createdSimulatedAction.id; }); - it('should handle a 500 servicenow error', async () => { + it('should handle executing with a simulated success', async () => { const { body: result } = await supertest .post(`/api/action/${simulatedActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ - params: { - comments: 'status_500', - short_description: 'status_500', - }, + params: { caseId: 'success' }, }) .expect(200); - expect(result.status).to.equal('error'); - expect(result.message).to.equal( - 'error posting servicenow event: http status 500, retry later' - ); - expect(result.retry).to.equal(true); + expect(result).to.eql({ + status: 'ok', + actionId: simulatedActionId, + data: { incidentId: '123', number: 'INC01', pushedDate: '2020-03-10T12:24:20.000Z' }, + }); + }); + + it('should handle failing with a simulated success without caseId', async () => { + await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: [caseId]: expected value of type [string] but got [undefined]', + }); + }); }); }); }