diff --git a/docs/management/connectors/action-types/xmatters.asciidoc b/docs/management/connectors/action-types/xmatters.asciidoc new file mode 100644 index 0000000000000..8eae305d9f92d --- /dev/null +++ b/docs/management/connectors/action-types/xmatters.asciidoc @@ -0,0 +1,119 @@ +[[xmatters-action-type]] +=== xMatters connector and action +++++ +xMatters +++++ + +The xMatters connector uses the https://help.xmatters.com/integrations/#cshid=Elastic[xMatters Workflow for Elastic] to send actionable alerts to on-call xMatters resources. + +[float] +[[xmatters-connector-configuration]] +==== Connector configuration + +xMatters connectors have the following configuration properties: + +Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. +Authentication Type:: The type of authentication used in the request made to xMatters. +URL:: The request URL for the Elastic Alerts trigger in xMatters. If you are using the <> setting, make sure the hostname is added to the allowed hosts. +Username:: Username for HTTP Basic Authentication. +Password:: Password for HTTP Basic Authentication. + +[float] +[[xmatters-connector-networking-configuration]] +==== Connector networking configuration + +Use the <> to customize connector networking configurations, such as proxies, certificates, or TLS settings. You can set configurations that apply to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations. + +[float] +[[Preconfigured-xmatters-configuration]] +==== Preconfigured connector type + +Connector using Basic Authentication +[source,text] +-- + my-xmatters: + name: preconfigured-xmatters-connector-type + actionTypeId: .xmatters + config: + configUrl: https://test.host + usesBasic: true + secrets: + user: testuser + password: passwordkeystorevalue +-- + +Connector using URL Authentication +[source,text] +-- + my-xmatters: + name: preconfigured-xmatters-connector-type + actionTypeId: .xmatters + config: + usesBasic: false + secrets: + secretsUrl: https://test.host?apiKey=1234-abcd +-- + +Config defines information for the connector type: + +`configUrl`:: A URL string that corresponds to *URL*. Only used if `usesBasic` is true. + +`usesBasic`:: A boolean that corresponds to *Authentication Type*. If `true`, this connector will require values for `user` and `password` inside the secrets configuration. Defaults to `true`. + +Secrets defines sensitive information for the connector type: + +`user`:: A string that corresponds to *User*. Required if `usesBasic` is set to `true`. + +`password`:: A string that corresponds to *Password*. Should be stored in the <>. Required if `usesBasic` is set to `true`. + +`secretsUrl`:: A URL string that corresponds to *URL*. Only used if `usesBasic` is false, indicating the API key is included in the URL. + +[float] +[[define-xmatters-ui]] +==== Define connector in Stack Management + +Define xMatters connector properties. Choose between basic and URL authentication for the requests: + +[role="screenshot"] +image::management/connectors/images/xmatters-connector-basic.png[xMatters connector with basic authentication] + +[role="screenshot"] +image::management/connectors/images/xmatters-connector-url.png[xMatters connector with url authentication] + +Test xMatters rule parameters: + +[role="screenshot"] +image::management/connectors/images/xmatters-params-test.png[xMatters params test] + +[float] +[[xmatters-action-configuration]] +==== Action configuration + +xMatters rules have the following properties: + +Severity:: Severity of the rule. +Tags:: Comma-separated list of tags for the rule as provided by the user in Elastic. + +[float] +[[xmatters-benefits]] +==== Configure xMatters + +By integrating with xMatters, you can: + +. Leverage schedules, rotations, escalations, and device preferences to quickly engage the right resources. +. Allow resolvers to take immediate action with customizable notification responses, including incident creation. +. Reduce manual tasks so teams can streamline their resources and focus. + +[float] +[[xmatters-connector-prerequisites]] +==== Prerequisites +To use the Elastic xMatters connector either install the Elastic workflow template, or add the Elastic Alerts trigger to one of your existing xMatters flows. Once the workflow or trigger is in your xMatters instance, configure Elastic to send alerts to xMatters. + +. In xMatters, double-click the Elastic trigger to open the settings menu. +. Choose the authentication method and set your authenticating user. +. Copy the initiation URL. +. In Elastic, open the xMatters connector. +. Set the authentication method, then paste the initiation URL. + +Note: If you use basic authentication, specify the Web / App Login ID in the user credentials for the connector. This value can be found in the Edit Profile modal in xMatters for each user. +For detailed configuration instructions, see https://help.xmatters.com/ondemand/#cshid=ElasticTrigger[xMatters online help] diff --git a/docs/management/connectors/images/xmatters-connector-basic.png b/docs/management/connectors/images/xmatters-connector-basic.png new file mode 100644 index 0000000000000..7e6437cab9e78 Binary files /dev/null and b/docs/management/connectors/images/xmatters-connector-basic.png differ diff --git a/docs/management/connectors/images/xmatters-connector-url.png b/docs/management/connectors/images/xmatters-connector-url.png new file mode 100644 index 0000000000000..a916fd7870fe8 Binary files /dev/null and b/docs/management/connectors/images/xmatters-connector-url.png differ diff --git a/docs/management/connectors/images/xmatters-params-test.png b/docs/management/connectors/images/xmatters-params-test.png new file mode 100644 index 0000000000000..5f12050ada953 Binary files /dev/null and b/docs/management/connectors/images/xmatters-params-test.png differ diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index 6968475cf3a4e..c895c4450aace 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -11,4 +11,5 @@ include::action-types/servicenow-itom.asciidoc[] include::action-types/swimlane.asciidoc[] include::action-types/slack.asciidoc[] include::action-types/webhook.asciidoc[] +include::action-types/xmatters.asciidoc[] include::pre-configured-connectors.asciidoc[] diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index e7969191ee646..21f339788883d 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -126,7 +126,7 @@ into a single string. This configuration can be used for environments where the files cannot be made available. `xpack.actions.enabledActionTypes` {ess-icon}:: -A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, and `.webhook`. An empty list `[]` will disable all action types. +A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.xmatters`, and `.webhook`. An empty list `[]` will disable all action types. + Disabled action types will not appear as an option when creating new connectors, but existing connectors and actions of that type will remain in {kib} and will not function. diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts index 5feb47ea6c962..edb1ec2b46369 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts @@ -24,6 +24,7 @@ const ACTION_TYPE_IDS = [ '.swimlane', '.teams', '.webhook', + '.xmatters', ]; export function createActionTypeRegistry(): { diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.ts b/x-pack/plugins/actions/server/builtin_action_types/index.ts index 9f48a45fc4664..5c9c8e784af7e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts @@ -16,6 +16,7 @@ import { getActionType as getSwimlaneActionType } from './swimlane'; import { getActionType as getServerLogActionType } from './server_log'; import { getActionType as getSlackActionType } from './slack'; import { getActionType as getWebhookActionType } from './webhook'; +import { getActionType as getXmattersActionType } from './xmatters'; import { getServiceNowITSMActionType, getServiceNowSIRActionType, @@ -36,6 +37,8 @@ export type { ActionParamsType as SlackActionParams } from './slack'; export { ActionTypeId as SlackActionTypeId } from './slack'; export type { ActionParamsType as WebhookActionParams } from './webhook'; export { ActionTypeId as WebhookActionTypeId } from './webhook'; +export type { ActionParamsType as XmattersActionParams } from './xmatters'; +export { ActionTypeId as XmattersActionTypeId } from './xmatters'; export type { ActionParamsType as ServiceNowActionParams } from './servicenow'; export { ServiceNowITSMActionTypeId, @@ -69,6 +72,7 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getServerLogActionType({ logger })); actionTypeRegistry.register(getSlackActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities })); + actionTypeRegistry.register(getXmattersActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getServiceNowITSMActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getServiceNowSIRActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getServiceNowITOMActionType({ logger, configurationUtilities })); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/post_xmatters.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/post_xmatters.ts new file mode 100644 index 0000000000000..7cd6b105a34ac --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/post_xmatters.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import axios, { AxiosResponse } from 'axios'; +import { Logger } from '../../../../../../src/core/server'; +import { request } from './axios_utils'; +import { ActionsConfigurationUtilities } from '../../actions_config'; + +interface PostXmattersOptions { + url: string; + data: { + alertActionGroupName?: string; + signalId?: string; + ruleName?: string; + date?: string; + severity: string; + spaceId?: string; + tags?: string; + }; + basicAuth?: { + auth: { + username: string; + password: string; + }; + }; +} + +// trigger a flow in xmatters +export async function postXmatters( + options: PostXmattersOptions, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities +): Promise { + const { url, data, basicAuth } = options; + const axiosInstance = axios.create(); + return await request({ + axios: axiosInstance, + method: 'post', + url, + logger, + ...basicAuth, + data, + configurationUtilities, + validateStatus: () => true, + }); +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/xmatters.test.ts b/x-pack/plugins/actions/server/builtin_action_types/xmatters.test.ts new file mode 100644 index 0000000000000..989ab4ff63c9e --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/xmatters.test.ts @@ -0,0 +1,525 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('./lib/post_xmatters', () => ({ + postXmatters: jest.fn(), +})); + +import { Services } from '../types'; +import { validateConfig, validateSecrets, validateParams, validateConnector } from '../lib'; +import { postXmatters } from './lib/post_xmatters'; +import { actionsConfigMock } from '../actions_config.mock'; +import { createActionTypeRegistry } from './index.test'; +import { Logger } from '../../../../../src/core/server'; +import { actionsMock } from '../mocks'; +import { + ActionParamsType, + ActionTypeConfigType, + ActionTypeSecretsType, + getActionType, + XmattersActionType, +} from './xmatters'; + +const postxMattersMock = postXmatters as jest.Mock; + +const ACTION_TYPE_ID = '.xmatters'; + +const services: Services = actionsMock.createServices(); + +let actionType: XmattersActionType; +let mockedLogger: jest.Mocked; + +beforeAll(() => { + const { logger, actionTypeRegistry } = createActionTypeRegistry(); + actionType = actionTypeRegistry.get< + ActionTypeConfigType, + ActionTypeSecretsType, + ActionParamsType + >(ACTION_TYPE_ID); + mockedLogger = logger; +}); + +beforeEach(() => { + actionType = getActionType({ + logger: mockedLogger, + configurationUtilities: actionsConfigMock.create(), + }); +}); + +describe('actionType', () => { + test('exposes the action as `xmatters` on its Id and Name', () => { + expect(actionType.id).toEqual('.xmatters'); + expect(actionType.name).toEqual('xMatters'); + }); +}); + +describe('secrets validation', () => { + test('succeeds when secrets is valid with user and password', () => { + const secrets: Record = { + user: 'bob', + password: 'supersecret', + }; + expect(validateSecrets(actionType, secrets)).toEqual({ + ...secrets, + secretsUrl: null, + }); + }); + + test('succeeds when secrets is valid with url auth', () => { + const secrets: Record = { + secretsUrl: 'http://mylisteningserver:9200/endpoint?apiKey=someKey', + }; + expect(validateSecrets(actionType, secrets)).toEqual({ + ...secrets, + user: null, + password: null, + }); + }); + + test('fails when url auth is provided with user', () => { + const secrets: Record = { + user: 'bob', + secretsUrl: 'http://mylisteningserver:9200/endpoint?apiKey=someKey', + }; + expect(() => { + validateSecrets(actionType, secrets); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: Cannot use user/password for URL authentication. Provide valid secretsUrl or use Basic Authentication."` + ); + }); + + test('fails when url auth is provided with password', () => { + const secrets: Record = { + password: 'supersecret', + secretsUrl: 'http://mylisteningserver:9200/endpoint?apiKey=someKey', + }; + expect(() => { + validateSecrets(actionType, secrets); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: Cannot use user/password for URL authentication. Provide valid secretsUrl or use Basic Authentication."` + ); + }); + + test('fails when url auth is provided with user and password', () => { + const secrets: Record = { + user: 'bob', + password: 'supersecret', + secretsUrl: 'http://mylisteningserver:9200/endpoint?apiKey=someKey', + }; + expect(() => { + validateSecrets(actionType, secrets); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: Cannot use user/password for URL authentication. Provide valid secretsUrl or use Basic Authentication."` + ); + }); + + test('fails when secret user is provided, but password is omitted', () => { + expect(() => { + validateSecrets(actionType, { user: 'bob' }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: Both user and password must be specified."` + ); + }); + + test('fails when password is provided, but user is omitted', () => { + expect(() => { + validateSecrets(actionType, { password: 'supersecret' }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: Both user and password must be specified."` + ); + }); + + test('fails when user, password, and secretsUrl are omitted', () => { + expect(() => { + validateSecrets(actionType, {}); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: Provide either secretsUrl link or user/password to authenticate"` + ); + }); + + test('fails when url is invalid', () => { + const secrets: Record = { + secretsUrl: 'example.com/do-something?apiKey=someKey', + }; + expect(() => { + validateSecrets(actionType, secrets); + }).toThrowErrorMatchingInlineSnapshot( + '"error validating action type secrets: Invalid secretsUrl: TypeError: Invalid URL: example.com/do-something?apiKey=someKey"' + ); + }); + + test('fails when url host is not in allowedHosts', () => { + actionType = getActionType({ + logger: mockedLogger, + configurationUtilities: { + ...actionsConfigMock.create(), + ensureUriAllowed: (_) => { + throw new Error(`target url is not present in allowedHosts`); + }, + }, + }); + const secrets: Record = { + secretsUrl: 'http://mylisteningserver.com:9200/endpoint', + }; + + expect(() => { + validateSecrets(actionType, secrets); + }).toThrowErrorMatchingInlineSnapshot( + '"error validating action type secrets: target url is not present in allowedHosts"' + ); + }); +}); + +describe('config validation', () => { + test('config validation passes when useBasic is true and url is provided', () => { + const config: Record = { + configUrl: 'http://mylisteningserver:9200/endpoint', + usesBasic: true, + }; + expect(validateConfig(actionType, config)).toEqual(config); + }); + + test('config validation failed when a url is invalid', () => { + const config: Record = { + configUrl: 'example.com/do-something', + usesBasic: true, + }; + expect(() => { + validateConfig(actionType, config); + }).toThrowErrorMatchingInlineSnapshot( + '"error validating action type config: Error configuring xMatters action: unable to parse url: TypeError: Invalid URL: example.com/do-something"' + ); + }); + + test('config validation returns an error if the specified URL isnt added to allowedHosts', () => { + actionType = getActionType({ + logger: mockedLogger, + configurationUtilities: { + ...actionsConfigMock.create(), + ensureUriAllowed: (_) => { + throw new Error(`target url is not present in allowedHosts`); + }, + }, + }); + + const config: Record = { + configUrl: 'http://mylisteningserver.com:9200/endpoint', + usesBasic: true, + }; + + expect(() => { + validateConfig(actionType, config); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: Error configuring xMatters action: target url is not present in allowedHosts"` + ); + }); + + test('config validations returns successful useBasic is false and no url is provided', () => { + const config: Record = { + configUrl: null, + usesBasic: false, + }; + + expect(validateConfig(actionType, config)).toEqual(config); + }); +}); + +describe('params validation', () => { + test('param validation passes when only required fields are provided', () => { + const params: Record = { + severity: 'high', + }; + expect(validateParams(actionType, params)).toEqual({ + severity: 'high', + }); + }); + + test('params validation passes when a valid parameters are provided', () => { + const params: Record = { + alertActionGroupName: 'Small t-shirt', + signalId: 'c9437cab-6a5b-45e8-bc8a-f4a8af440e97:abcd-1234', + ruleName: 'Test xMatters', + date: '2022-01-18T19:01:08.818Z', + severity: 'high', + spaceId: 'default', + tags: 'test1, test2', + }; + expect(validateParams(actionType, params)).toEqual({ + ...params, + }); + }); +}); + +describe('connector validation', () => { + test('connector validation fails when configUrl passed with out user and password', () => { + const config: Record = { + configUrl: 'http://mylisteningserver.com:9200/endpoint', + usesBasic: true, + }; + const secrets: Record = {}; + expect(() => { + validateConnector(actionType, { config, secrets }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type connector: Provide valid Username"` + ); + }); + + test('connector validation fails when configUrl passed with out password', () => { + const config: Record = { + configUrl: 'http://mylisteningserver.com:9200/endpoint', + usesBasic: true, + }; + const secrets: Record = { + user: 'bob', + }; + expect(() => { + validateConnector(actionType, { config, secrets }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type connector: Provide valid Password"` + ); + }); + + test('connector validation fails when user and password passed with out configUrl', () => { + const config: Record = { + usesBasic: true, + }; + const secrets: Record = { + user: 'bob', + password: 'supersecret', + }; + expect(() => { + validateConnector(actionType, { config, secrets }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type connector: Provide valid configUrl"` + ); + }); + + test('connector validation fails when secretsUrl passed with user and password', () => { + const config: Record = { + usesBasic: false, + }; + const secrets: Record = { + user: 'bob', + password: 'supersecret', + secretsUrl: 'http://mylisteningserver:9200/endpoint', + }; + expect(() => { + validateConnector(actionType, { config, secrets }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type connector: Username and password should not be provided when usesBasic is false"` + ); + }); + + test('connector validation fails when configUrl and secretsUrl passed in', () => { + const config: Record = { + configUrl: 'http://mylisteningserver.com:9200/endpoint', + usesBasic: true, + }; + const secrets: Record = { + user: 'bob', + password: 'supersecret', + secretsUrl: 'http://mylisteningserver:9200/endpoint', + }; + expect(() => { + validateConnector(actionType, { config, secrets }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type connector: secretsUrl should not be provided when usesBasic is true"` + ); + }); + + test('connector validation fails when usesBasic is true, but url auth used', () => { + const config: Record = { + usesBasic: true, + }; + const secrets: Record = { + secretsUrl: 'http://mylisteningserver:9200/endpoint', + }; + expect(() => { + validateConnector(actionType, { config, secrets }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type connector: secretsUrl should not be provided when usesBasic is true"` + ); + }); + + test('connector validation fails when usesBasic is false, but basic auth used', () => { + const config: Record = { + configUrl: 'http://mylisteningserver.com:9200/endpoint', + usesBasic: false, + }; + const secrets: Record = { + user: 'bob', + password: 'supersecret', + }; + expect(() => { + validateConnector(actionType, { config, secrets }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type connector: Username and password should not be provided when usesBasic is false"` + ); + }); + + test('connector validation fails when usesBasic is false, but configUrl passed in', () => { + const config: Record = { + configUrl: 'http://mylisteningserver.com:9200/endpoint', + usesBasic: false, + }; + const secrets: Record = {}; + expect(() => { + validateConnector(actionType, { config, secrets }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type connector: configUrl should not be provided when usesBasic is false"` + ); + }); + + test('connector validation succeeds with basic auth', () => { + const config: Record = { + configUrl: 'http://mylisteningserver.com:9200/endpoint', + usesBasic: true, + }; + const secrets: Record = { + user: 'bob', + password: 'supersecret', + }; + expect(validateConnector(actionType, { config, secrets })).toEqual(null); + }); + + test('connector validation succeeds with url auth', () => { + const config: Record = { + usesBasic: false, + }; + const secrets: Record = { + secretsUrl: 'http://mylisteningserver:9200/endpoint', + }; + expect(validateConnector(actionType, { config, secrets })).toEqual(null); + }); +}); + +describe('execute()', () => { + beforeEach(() => { + postxMattersMock.mockReset(); + postxMattersMock.mockResolvedValue({ + status: 200, + statusText: '', + data: '', + config: {}, + }); + }); + + test('execute with useBasic=true uses authentication object', async () => { + const config: ActionTypeConfigType = { + configUrl: 'https://abc.def/my-xmatters', + usesBasic: true, + }; + await actionType.executor({ + actionId: 'some-id', + services, + config, + secrets: { secretsUrl: null, user: 'abc', password: '123' }, + params: { + alertActionGroupName: 'Small t-shirt', + signalId: 'c9437cab-6a5b-45e8-bc8a-f4a8af440e97:abcd-1234', + ruleName: 'Test xMatters', + date: '2022-01-18T19:01:08.818Z', + severity: 'high', + spaceId: 'default', + tags: 'test1, test2', + }, + }); + + expect(postxMattersMock.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "basicAuth": Object { + "auth": Object { + "password": "123", + "username": "abc", + }, + }, + "data": Object { + "alertActionGroupName": "Small t-shirt", + "date": "2022-01-18T19:01:08.818Z", + "ruleName": "Test xMatters", + "severity": "high", + "signalId": "c9437cab-6a5b-45e8-bc8a-f4a8af440e97:abcd-1234", + "spaceId": "default", + "tags": "test1, test2", + }, + "url": "https://abc.def/my-xmatters", + } + `); + }); + + test('execute with exception maxContentLength size exceeded should log the proper error', async () => { + const config: ActionTypeConfigType = { + configUrl: 'https://abc.def/my-xmatters', + usesBasic: true, + }; + postxMattersMock.mockRejectedValueOnce({ + tag: 'err', + message: 'maxContentLength size of 1000000 exceeded', + }); + await actionType.executor({ + actionId: 'some-id', + services, + config, + secrets: { secretsUrl: null, user: 'abc', password: '123' }, + params: { + alertActionGroupName: 'Small t-shirt', + signalId: 'c9437cab-6a5b-45e8-bc8a-f4a8af440e97:abcd-1234', + ruleName: 'Test xMatters', + date: '2022-01-18T19:01:08.818Z', + severity: 'high', + spaceId: 'default', + tags: 'test1, test2', + }, + }); + expect(mockedLogger.warn).toBeCalledWith( + 'Error thrown triggering xMatters workflow: maxContentLength size of 1000000 exceeded' + ); + }); + + test('execute with useBasic=false uses empty authentication object', async () => { + const config: ActionTypeConfigType = { + configUrl: null, + usesBasic: false, + }; + const secrets: ActionTypeSecretsType = { + user: null, + password: null, + secretsUrl: 'https://abc.def/my-xmatters?apiKey=someKey', + }; + await actionType.executor({ + actionId: 'some-id', + services, + config, + secrets, + params: { + alertActionGroupName: 'Small t-shirt', + signalId: 'c9437cab-6a5b-45e8-bc8a-f4a8af440e97:abcd-1234', + ruleName: 'Test xMatters', + date: '2022-01-18T19:01:08.818Z', + severity: 'high', + spaceId: 'default', + tags: 'test1, test2', + }, + }); + + expect(postxMattersMock.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "basicAuth": undefined, + "data": Object { + "alertActionGroupName": "Small t-shirt", + "date": "2022-01-18T19:01:08.818Z", + "ruleName": "Test xMatters", + "severity": "high", + "signalId": "c9437cab-6a5b-45e8-bc8a-f4a8af440e97:abcd-1234", + "spaceId": "default", + "tags": "test1, test2", + }, + "url": "https://abc.def/my-xmatters?apiKey=someKey", + } + `); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/xmatters.ts b/x-pack/plugins/actions/server/builtin_action_types/xmatters.ts new file mode 100644 index 0000000000000..9158b981c5a8c --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/xmatters.ts @@ -0,0 +1,326 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { curry, isString } from 'lodash'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { Logger } from '../../../../../src/core/server'; +import { postXmatters } from './lib/post_xmatters'; + +export type XmattersActionType = ActionType< + ActionTypeConfigType, + ActionTypeSecretsType, + ActionParamsType, + unknown +>; +export type XmattersActionTypeExecutorOptions = ActionTypeExecutorOptions< + ActionTypeConfigType, + ActionTypeSecretsType, + ActionParamsType +>; + +const configSchemaProps = { + configUrl: schema.nullable(schema.string()), + usesBasic: schema.boolean({ defaultValue: true }), +}; +const ConfigSchema = schema.object(configSchemaProps); +export type ActionTypeConfigType = TypeOf; + +// secrets definition +export type ActionTypeSecretsType = TypeOf; +const secretSchemaProps = { + user: schema.nullable(schema.string()), + password: schema.nullable(schema.string()), + secretsUrl: schema.nullable(schema.string()), +}; +const SecretsSchema = schema.object(secretSchemaProps); + +// params definition +export type ActionParamsType = TypeOf; +const ParamsSchema = schema.object({ + alertActionGroupName: schema.maybe(schema.string()), + signalId: schema.maybe(schema.string()), + ruleName: schema.maybe(schema.string()), + date: schema.maybe(schema.string()), + severity: schema.string(), + spaceId: schema.maybe(schema.string()), + tags: schema.maybe(schema.string()), +}); + +export const ActionTypeId = '.xmatters'; +// action type definition +export function getActionType({ + logger, + configurationUtilities, +}: { + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; +}): XmattersActionType { + return { + id: ActionTypeId, + minimumLicenseRequired: 'gold', + name: i18n.translate('xpack.actions.builtin.xmattersTitle', { + defaultMessage: 'xMatters', + }), + validate: { + config: schema.object(configSchemaProps, { + validate: curry(validateActionTypeConfig)(configurationUtilities), + }), + secrets: schema.object(secretSchemaProps, { + validate: curry(validateActionTypeSecrets)(configurationUtilities), + }), + params: ParamsSchema, + connector: validateConnector, + }, + executor: curry(executor)({ logger, configurationUtilities }), + }; +} + +function validateActionTypeConfig( + configurationUtilities: ActionsConfigurationUtilities, + configObject: ActionTypeConfigType +): string | undefined { + const configuredUrl = configObject.configUrl; + const usesBasic = configObject.usesBasic; + if (!usesBasic) return; + try { + if (configuredUrl) { + new URL(configuredUrl); + } + } catch (err) { + return i18n.translate('xpack.actions.builtin.xmatters.xmattersConfigurationErrorNoHostname', { + defaultMessage: 'Error configuring xMatters action: unable to parse url: {err}', + values: { + err, + }, + }); + } + + try { + if (configuredUrl) { + configurationUtilities.ensureUriAllowed(configuredUrl); + } + } catch (allowListError) { + return i18n.translate('xpack.actions.builtin.xmatters.xmattersConfigurationError', { + defaultMessage: 'Error configuring xMatters action: {message}', + values: { + message: allowListError.message, + }, + }); + } +} + +function validateConnector( + config: ActionTypeConfigType, + secrets: ActionTypeSecretsType +): string | null { + const { user, password, secretsUrl } = secrets; + const { usesBasic, configUrl } = config; + + if (usesBasic) { + if (secretsUrl) { + return i18n.translate('xpack.actions.builtin.xmatters.shouldNotHaveSecretsUrl', { + defaultMessage: 'secretsUrl should not be provided when usesBasic is true', + }); + } + if (user == null) { + return i18n.translate('xpack.actions.builtin.xmatters.missingUser', { + defaultMessage: 'Provide valid Username', + }); + } + if (password == null) { + return i18n.translate('xpack.actions.builtin.xmatters.missingPassword', { + defaultMessage: 'Provide valid Password', + }); + } + if (configUrl == null) { + return i18n.translate('xpack.actions.builtin.xmatters.missingConfigUrl', { + defaultMessage: 'Provide valid configUrl', + }); + } + } else { + if (user || password) { + return i18n.translate('xpack.actions.builtin.xmatters.shouldNotHaveUsernamePassword', { + defaultMessage: 'Username and password should not be provided when usesBasic is false', + }); + } + if (configUrl) { + return i18n.translate('xpack.actions.builtin.xmatters.shouldNotHaveConfigUrl', { + defaultMessage: 'configUrl should not be provided when usesBasic is false', + }); + } + if (secretsUrl == null) { + return i18n.translate('xpack.actions.builtin.xmatters.missingSecretsUrl', { + defaultMessage: 'Provide valid secretsUrl with API Key', + }); + } + } + return null; +} + +function validateActionTypeSecrets( + configurationUtilities: ActionsConfigurationUtilities, + secretsObject: ActionTypeSecretsType +): string | undefined { + if (!secretsObject.secretsUrl && !secretsObject.user && !secretsObject.password) { + return i18n.translate('xpack.actions.builtin.xmatters.noSecretsProvided', { + defaultMessage: 'Provide either secretsUrl link or user/password to authenticate', + }); + } + + // Check for secrets URL first + if (secretsObject.secretsUrl) { + // Neither user/password should be defined if secretsUrl is specified + if (secretsObject.user || secretsObject.password) { + return i18n.translate('xpack.actions.builtin.xmatters.noUserPassWhenSecretsUrl', { + defaultMessage: + 'Cannot use user/password for URL authentication. Provide valid secretsUrl or use Basic Authentication.', + }); + } + + // Test that URL is valid + try { + if (secretsObject.secretsUrl) { + new URL(secretsObject.secretsUrl); + } + } catch (err) { + return i18n.translate('xpack.actions.builtin.xmatters.xmattersInvalidUrlError', { + defaultMessage: 'Invalid secretsUrl: {err}', + values: { + err, + }, + }); + } + + // Test that hostname is allowed + try { + if (secretsObject.secretsUrl) { + configurationUtilities.ensureUriAllowed(secretsObject.secretsUrl); + } + } catch (allowListError) { + return i18n.translate('xpack.actions.builtin.xmatters.xmattersHostnameNotAllowed', { + defaultMessage: '{message}', + values: { + message: allowListError.message, + }, + }); + } + } else { + // Username and password must both be set + if (!secretsObject.user || !secretsObject.password) { + return i18n.translate('xpack.actions.builtin.xmatters.invalidUsernamePassword', { + defaultMessage: 'Both user and password must be specified.', + }); + } + } +} + +// action executor +export async function executor( + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, + execOptions: XmattersActionTypeExecutorOptions +): Promise> { + const actionId = execOptions.actionId; + const { configUrl, usesBasic } = execOptions.config; + const data = getPayloadForRequest(execOptions.params); + + const secrets: ActionTypeSecretsType = execOptions.secrets; + const basicAuth = + usesBasic && isString(secrets.user) && isString(secrets.password) + ? { auth: { username: secrets.user, password: secrets.password } } + : undefined; + const url = usesBasic ? configUrl : secrets.secretsUrl; + + let result; + try { + if (!url) { + throw new Error('Error: no url provided'); + } + result = await postXmatters({ url, data, basicAuth }, logger, configurationUtilities); + } catch (err) { + const message = i18n.translate('xpack.actions.builtin.xmatters.postingErrorMessage', { + defaultMessage: 'Error triggering xMatters workflow', + }); + logger.warn(`Error thrown triggering xMatters workflow: ${err.message}`); + return { + status: 'error', + actionId, + message, + serviceMessage: err.message, + }; + } + + if (result.status >= 200 && result.status < 300) { + const { status, statusText } = result; + logger.debug(`Response from xMatters action "${actionId}": [HTTP ${status}] ${statusText}`); + + return successResult(actionId, data); + } + + if (result.status === 429 || result.status >= 500) { + const message = i18n.translate('xpack.actions.builtin.xmatters.postingRetryErrorMessage', { + defaultMessage: 'Error triggering xMatters flow: http status {status}, retry later', + values: { + status: result.status, + }, + }); + + return { + status: 'error', + actionId, + message, + retry: true, + }; + } + const message = i18n.translate('xpack.actions.builtin.xmatters.unexpectedStatusErrorMessage', { + defaultMessage: 'Error triggering xMatters flow: unexpected status {status}', + values: { + status: result.status, + }, + }); + + return { + status: 'error', + actionId, + message, + }; +} + +// Action Executor Result w/ internationalisation +function successResult(actionId: string, data: unknown): ActionTypeExecutorResult { + return { status: 'ok', data, actionId }; +} + +interface XmattersPayload { + alertActionGroupName?: string; + signalId?: string; + ruleName?: string; + date?: string; + severity: string; + spaceId?: string; + tags?: string; +} + +function getPayloadForRequest(params: ActionParamsType): XmattersPayload { + // xMatters will assume the request is a test when the signalId and alertActionGroupName are not defined + const data: XmattersPayload = { + alertActionGroupName: params.alertActionGroupName, + signalId: params.signalId, + ruleName: params.ruleName, + date: params.date, + severity: params.severity || 'High', + spaceId: params.spaceId, + tags: params.tags, + }; + + return data; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts index a58dd84e9a32b..6817631e2150a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts @@ -12,6 +12,7 @@ import { getIndexActionType } from './es_index'; import { getPagerDutyActionType } from './pagerduty'; import { getSwimlaneActionType } from './swimlane'; import { getWebhookActionType } from './webhook'; +import { getXmattersActionType } from './xmatters'; import { TypeRegistry } from '../../type_registry'; import { ActionTypeModel } from '../../../types'; import { @@ -35,6 +36,7 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getPagerDutyActionType()); actionTypeRegistry.register(getSwimlaneActionType()); actionTypeRegistry.register(getWebhookActionType()); + actionTypeRegistry.register(getXmattersActionType()); actionTypeRegistry.register(getServiceNowITSMActionType()); actionTypeRegistry.register(getServiceNowITOMActionType()); actionTypeRegistry.register(getServiceNowSIRActionType()); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts index abacc5544c712..1d0c58ffb8f21 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts @@ -132,6 +132,45 @@ export interface WebhookSecrets { export type WebhookActionConnector = UserConfiguredActionConnector; +export enum XmattersSeverityOptions { + CRITICAL = 'critical', + HIGH = 'high', + MEDIUM = 'medium', + LOW = 'low', + MINIMAL = 'minimal', +} + +export interface XmattersActionParams { + alertActionGroupName: string; + signalId: string; + ruleName: string; + date: string; + severity: XmattersSeverityOptions; + spaceId: string; + tags: string; +} + +export interface XmattersConfig { + configUrl?: string; + usesBasic: boolean; +} + +export interface XmattersSecrets { + user: string; + password: string; + secretsUrl?: string; +} + +export type XmattersActionConnector = UserConfiguredActionConnector< + XmattersConfig, + XmattersSecrets +>; + +export enum XmattersAuthenticationType { + Basic = 'Basic Authentication', + URL = 'URL Authentication', +} + export interface TeamsSecrets { webhookUrl: string; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/index.ts new file mode 100644 index 0000000000000..54bc4fd06acd4 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getActionType as getXmattersActionType } from './xmatters'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/logo.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/logo.tsx new file mode 100644 index 0000000000000..f65f66587ba74 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/logo.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +const Logo = () => ( + + x-logo + + + + + + + + + + + + + + + + +); + +// eslint-disable-next-line import/no-default-export +export { Logo as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/translations.ts new file mode 100644 index 0000000000000..51626b6a31ea6 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/translations.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.error.requiredUrlText', + { + defaultMessage: 'URL is required.', + } +); + +export const URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.error.invalidUrlTextField', + { + defaultMessage: 'URL is invalid.', + } +); + +export const USERNAME_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.xmattersAction.error.requiredAuthUserNameText', + { + defaultMessage: 'Username is required.', + } +); + +export const PASSWORD_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.xmattersAction.error.requiredAuthPasswordText', + { + defaultMessage: 'Password is required.', + } +); + +export const PASSWORD_REQUIRED_FOR_USER = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.xmattersAction.error.requiredPasswordText', + { + defaultMessage: 'Password is required when username is used.', + } +); + +export const USERNAME_REQUIRED_FOR_PASSWORD = i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.xmattersAction.error.requiredUserText', + { + defaultMessage: 'Username is required when password is used.', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters.test.tsx new file mode 100644 index 0000000000000..62d890d172f0d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters.test.tsx @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TypeRegistry } from '../../../type_registry'; +import { registerBuiltInActionTypes } from '.././index'; +import { ActionTypeModel } from '../../../../types'; +import { XmattersActionConnector } from '../types'; + +const ACTION_TYPE_ID = '.xmatters'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.actionTypeTitle).toEqual('xMatters data'); + }); +}); + +describe('xmatters connector validation', () => { + test('connector validation succeeds when usesBasic is true and connector config is valid', async () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.xmatters', + name: 'xmatters', + isPreconfigured: false, + config: { + configUrl: 'http://test.com', + usesBasic: true, + }, + } as XmattersActionConnector; + + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ + config: { + errors: { + configUrl: [], + }, + }, + secrets: { + errors: { + user: [], + password: [], + secretsUrl: [], + }, + }, + }); + }); + + test('connector validation succeeds when usesBasic is false and connector config is valid', async () => { + const actionConnector = { + secrets: { + user: '', + password: '', + secretsUrl: 'https://test.com?apiKey=someKey', + }, + id: 'test', + actionTypeId: '.xmatters', + name: 'xmatters', + isPreconfigured: false, + config: { + usesBasic: false, + }, + } as XmattersActionConnector; + + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ + config: { + errors: { + configUrl: [], + }, + }, + secrets: { + errors: { + user: [], + password: [], + secretsUrl: [], + }, + }, + }); + }); + + test('connector validation fails when connector config is not valid', async () => { + const actionConnector = { + secrets: { + user: 'user', + }, + id: 'test', + actionTypeId: '.xmatters', + name: 'xmatters', + config: { + usesBasic: true, + }, + } as XmattersActionConnector; + + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ + config: { + errors: { + configUrl: ['URL is required.'], + }, + }, + secrets: { + errors: { + user: [], + password: ['Password is required when username is used.'], + secretsUrl: [], + }, + }, + }); + }); + + test('connector validation fails when url in config is not valid', async () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.xmatters', + name: 'xmatters', + config: { + configUrl: 'invalid.url', + usesBasic: true, + }, + } as XmattersActionConnector; + + expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({ + config: { + errors: { + configUrl: ['URL is invalid.'], + }, + }, + secrets: { + errors: { + user: [], + password: [], + secretsUrl: [], + }, + }, + }); + }); +}); + +describe('xmatters action params validation', () => { + test('action params validation succeeds when action params is valid', async () => { + const actionParams = { + alertActionGroupName: 'Small t-shirt', + signalId: 'c9437cab-6a5b-45e8-bc8a-f4a8af440e97', + ruleName: 'Test xMatters', + date: '2022-01-18T19:01:08.818Z', + severity: 'high', + spaceId: 'default', + tags: 'test1, test2', + }; + + expect(await actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { alertActionGroupName: [], signalId: [] }, + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters.tsx new file mode 100644 index 0000000000000..d638056ba31d9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + ActionTypeModel, + GenericValidationResult, + ConnectorValidationResult, +} from '../../../../types'; +import { + XmattersActionParams, + XmattersConfig, + XmattersSecrets, + XmattersActionConnector, +} from '../types'; +import { isValidUrl } from '../../../lib/value_validators'; + +export function getActionType(): ActionTypeModel< + XmattersConfig, + XmattersSecrets, + XmattersActionParams +> { + return { + id: '.xmatters', + iconClass: lazy(() => import('./logo')), + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.selectMessageText', + { + defaultMessage: 'Trigger an xMatters workflow.', + } + ), + actionTypeTitle: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.actionTypeTitle', + { + defaultMessage: 'xMatters data', + } + ), + validateConnector: async ( + action: XmattersActionConnector + ): Promise, XmattersSecrets>> => { + const translations = await import('./translations'); + const configErrors = { + configUrl: new Array(), + }; + const secretsErrors = { + user: new Array(), + password: new Array(), + secretsUrl: new Array(), + }; + const validationResult = { + config: { errors: configErrors }, + secrets: { errors: secretsErrors }, + }; + // basic auth validation + if (!action.config.configUrl && action.config.usesBasic) { + configErrors.configUrl.push(translations.URL_REQUIRED); + } + if (action.config.usesBasic && !action.secrets.user && !action.secrets.password) { + secretsErrors.user.push(translations.USERNAME_REQUIRED); + secretsErrors.password.push(translations.PASSWORD_REQUIRED); + } + if (action.config.configUrl && !isValidUrl(action.config.configUrl)) { + configErrors.configUrl = [...configErrors.configUrl, translations.URL_INVALID]; + } + if (action.config.usesBasic && action.secrets.user && !action.secrets.password) { + secretsErrors.password.push(translations.PASSWORD_REQUIRED_FOR_USER); + } + if (action.config.usesBasic && !action.secrets.user && action.secrets.password) { + secretsErrors.user.push(translations.USERNAME_REQUIRED_FOR_PASSWORD); + } + // API Key auth validation + if (!action.config.usesBasic && !action.secrets.secretsUrl) { + secretsErrors.secretsUrl.push(translations.URL_REQUIRED); + } + if (action.secrets.secretsUrl && !isValidUrl(action.secrets.secretsUrl)) { + secretsErrors.secretsUrl.push(translations.URL_INVALID); + } + return validationResult; + }, + validateParams: async ( + actionParams: XmattersActionParams + ): Promise< + GenericValidationResult> + > => { + const errors = { + alertActionGroupName: new Array(), + signalId: new Array(), + }; + const validationResult = { errors }; + validationResult.errors = errors; + return validationResult; + }, + actionConnectorFields: lazy(() => import('./xmatters_connectors')), + actionParamsFields: lazy(() => import('./xmatters_params')), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters_connectors.test.tsx new file mode 100644 index 0000000000000..a96e2bf679240 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters_connectors.test.tsx @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { XmattersActionConnector } from '../types'; +import XmattersActionConnectorFields from './xmatters_connectors'; + +describe('XmattersActionConnectorFields renders', () => { + test('all connector fields is rendered', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.xmatters', + isPreconfigured: false, + name: 'xmatters', + config: { + configUrl: 'http:\\test', + usesBasic: true, + }, + } as XmattersActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={() => {}} + isEdit={false} + /> + ); + expect(wrapper.find('[data-test-subj="xmattersUrlText"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="xmattersUserInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="xmattersPasswordInput"]').length > 0).toBeTruthy(); + }); + + test('should show only basic auth info when basic selected', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.xmatters', + isPreconfigured: false, + name: 'xmatters', + config: { + configUrl: 'http:\\test', + usesBasic: true, + }, + } as XmattersActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={() => {}} + isEdit={false} + /> + ); + expect(wrapper.find('[data-test-subj="xmattersUrlText"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="xmattersUserInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="xmattersPasswordInput"]').length > 0).toBeTruthy(); + }); + + test('should show only url auth info when url selected', () => { + const actionConnector = { + secrets: { + secretsUrl: 'http:\\test', + }, + id: 'test', + actionTypeId: '.xmatters', + isPreconfigured: false, + name: 'xmatters', + config: { + usesBasic: false, + }, + } as XmattersActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={() => {}} + isEdit={false} + /> + ); + expect(wrapper.find('[data-test-subj="xmattersUrlText"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="xmattersUserInput"]').length === 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="xmattersPasswordInput"]').length === 0).toBeTruthy(); + }); + + test('should display a message on create to remember credentials', () => { + const actionConnector = { + secrets: {}, + actionTypeId: '.xmatters', + isPreconfigured: false, + config: { + usesBasic: true, + }, + } as XmattersActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={() => {}} + isEdit={false} + /> + ); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toEqual(0); + }); + + test('should display a message on edit to re-enter credentials, Basic Auth', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.xmatters', + isPreconfigured: false, + name: 'xmatters', + config: { + configUrl: 'http:\\test', + usesBasic: true, + }, + } as XmattersActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={() => {}} + isEdit={false} + /> + ); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toEqual(0); + }); + + test('should display a message for missing secrets after import', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.xmatters', + isPreconfigured: false, + isMissingSecrets: true, + name: 'xmatters', + config: { + configUrl: 'http:\\test', + usesBasic: true, + }, + } as XmattersActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={() => {}} + isEdit={false} + /> + ); + expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0); + }); + + test('should display a message on edit to re-enter credentials, URL Auth', () => { + const actionConnector = { + secrets: { + secretsUrl: 'http:\\test?apiKey=someKey', + }, + id: 'test', + actionTypeId: '.xmatters', + isPreconfigured: false, + name: 'xmatters', + config: { + usesBasic: false, + }, + } as XmattersActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={() => {}} + isEdit={false} + /> + ); + expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toEqual(0); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters_connectors.tsx new file mode 100644 index 0000000000000..20ade11303350 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters_connectors.tsx @@ -0,0 +1,275 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { + EuiFieldPassword, + EuiFieldText, + EuiFormRow, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + EuiButtonGroup, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionConnectorFieldsProps } from '../../../../types'; +import { XmattersActionConnector, XmattersAuthenticationType } from '../types'; +import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label'; + +const XmattersActionConnectorFields: React.FunctionComponent< + ActionConnectorFieldsProps +> = ({ action, editActionConfig, editActionSecrets, errors, readOnly }) => { + const { user, password, secretsUrl } = action.secrets; + const { configUrl, usesBasic } = action.config; + + useEffect(() => { + if (!action.id) { + editActionConfig('usesBasic', true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const isUrlInvalid: boolean = usesBasic + ? errors.configUrl !== undefined && errors.configUrl.length > 0 && configUrl !== undefined + : errors.secretsUrl !== undefined && errors.secretsUrl.length > 0 && secretsUrl !== undefined; + const isPasswordInvalid: boolean = + password !== undefined && errors.password !== undefined && errors.password.length > 0; + const isUserInvalid: boolean = + user !== undefined && errors.user !== undefined && errors.user.length > 0; + + const authenticationButtons = [ + { + id: XmattersAuthenticationType.Basic, + label: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.basicAuthLabel', + { + defaultMessage: 'Basic Authentication', + } + ), + }, + { + id: XmattersAuthenticationType.URL, + label: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.urlAuthLabel', + { + defaultMessage: 'URL Authentication', + } + ), + }, + ]; + + let initialState; + if (typeof usesBasic === 'undefined') { + initialState = XmattersAuthenticationType.Basic; + } else { + initialState = usesBasic ? XmattersAuthenticationType.Basic : XmattersAuthenticationType.URL; + if (usesBasic) { + editActionSecrets('secretsUrl', ''); + } else { + editActionConfig('configUrl', ''); + } + } + const [selectedAuth, setSelectedAuth] = useState(initialState); + + return ( + <> + +

+ +

+
+ + +

+ +

+
+ + { + if (id === XmattersAuthenticationType.Basic) { + setSelectedAuth(XmattersAuthenticationType.Basic); + editActionConfig('usesBasic', true); + editActionSecrets('secretsUrl', ''); + } else { + setSelectedAuth(XmattersAuthenticationType.URL); + editActionConfig('usesBasic', false); + editActionConfig('configUrl', ''); + editActionSecrets('user', ''); + editActionSecrets('password', ''); + } + }} + /> + + {selectedAuth === XmattersAuthenticationType.URL ? ( + <> + {getEncryptedFieldNotifyLabel( + !action.id, + 1, + action.isMissingSecrets ?? false, + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.reenterUrlAuthValuesLabel', + { + defaultMessage: 'URL is encrypted. Please reenter values for this field.', + } + ) + )} + + ) : null} + + + + } + > + { + if (selectedAuth === XmattersAuthenticationType.Basic) { + editActionConfig('configUrl', e.target.value); + } else { + editActionSecrets('secretsUrl', e.target.value); + } + }} + /> + + + + {selectedAuth === XmattersAuthenticationType.Basic ? ( + <> + + +

+ +

+
+ + {getEncryptedFieldNotifyLabel( + !action.id, + 2, + action.isMissingSecrets ?? false, + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.reenterBasicAuthValuesLabel', + { + defaultMessage: + 'User and password are encrypted. Please reenter values for these fields.', + } + ) + )} + + + + + { + editActionSecrets('user', e.target.value); + }} + onBlur={() => { + if (!user) { + editActionSecrets('user', ''); + } + }} + /> + + + + + + + { + editActionSecrets('password', e.target.value); + }} + onBlur={() => { + if (!password) { + editActionSecrets('password', ''); + } + }} + /> + + + + + ) : null} + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { XmattersActionConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters_params.test.tsx new file mode 100644 index 0000000000000..64c4b5ead81ae --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters_params.test.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { XmattersSeverityOptions } from '../types'; +import XmattersParamsFields from './xmatters_params'; + +describe('XmattersParamsFields renders', () => { + test('all params fields is rendered', () => { + const actionParams = { + alertActionGroupName: 'Small t-shirt', + signalId: 'c9437cab-6a5b-45e8-bc8a-f4a8af440e97', + ruleName: 'Test xMatters', + date: new Date().toISOString(), + severity: XmattersSeverityOptions.HIGH, + spaceId: 'default', + tags: 'test1, test2', + }; + + const wrapper = mountWithIntl( + {}} + index={0} + messageVariables={[ + { + name: 'myVar', + description: 'My variable description', + useWithTripleBracesInTemplates: true, + }, + ]} + /> + ); + expect(wrapper.find('[data-test-subj="severitySelect"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="tagsInput"]').length > 0).toBeTruthy(); + + expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('value')).toStrictEqual( + 'high' + ); + expect(wrapper.find('[data-test-subj="tagsInput"]').first().prop('value')).toStrictEqual( + 'test1, test2' + ); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters_params.tsx new file mode 100644 index 0000000000000..f66583ef32dd8 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/xmatters/xmatters_params.tsx @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; +import { isUndefined } from 'lodash'; +import { ActionParamsProps } from '../../../../types'; +import { XmattersActionParams } from '../types'; +import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; + +const severityOptions = [ + { + value: 'critical', + text: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.severitySelectCriticalOptionLabel', + { + defaultMessage: 'Critical', + } + ), + }, + { + value: 'high', + text: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.severitySelectHighOptionLabel', + { + defaultMessage: 'High', + } + ), + }, + { + value: 'medium', + text: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.severitySelectMediumOptionLabel', + { + defaultMessage: 'Medium', + } + ), + }, + { + value: 'low', + text: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.severitySelectLowOptionLabel', + { + defaultMessage: 'Low', + } + ), + }, + { + value: 'minimal', + text: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.severitySelectMinimalOptionLabel', + { + defaultMessage: 'Minimal', + } + ), + }, +]; + +const XmattersParamsFields: React.FunctionComponent> = ({ + actionParams, + editAction, + index, + messageVariables, + errors, +}) => { + useEffect(() => { + if (!actionParams) { + editAction( + 'actionParams', + { + signalId: '{{rule.id}}:{{alert.id}}', + alertActionGroupName: '{{alert.actionGroupName}}', + ruleName: '{{rule.name}}', + date: '{{date}}', + spaceId: '{{rule.spaceId}}', + }, + index + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionParams]); + return ( + <> + + + + { + editAction('severity', e.target.value, index); + }} + /> + + + + + + + + + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { XmattersParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/get_defaults_for_action_params.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/get_defaults_for_action_params.ts index 000e17d2eb255..7936e5843de7f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/get_defaults_for_action_params.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/get_defaults_for_action_params.ts @@ -29,5 +29,14 @@ export const getDefaultsForActionParams = ( pagerDutyDefaults.eventAction = EventActionOptions.RESOLVE; } return pagerDutyDefaults; + case '.xmatters': + const xmattersDefaults = { + alertActionGroupName: `{{${AlertProvidedActionVariables.alertActionGroupName}}}`, + signalId: `{{${AlertProvidedActionVariables.ruleId}}}:{{${AlertProvidedActionVariables.alertId}}}`, + ruleName: `{{${AlertProvidedActionVariables.ruleName}}}`, + date: `{{${AlertProvidedActionVariables.date}}}`, + spaceId: `{{${AlertProvidedActionVariables.ruleSpaceId}}}`, + }; + return xmattersDefaults; } }; diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 094857cfaf32c..d8a5ef9986f29 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -40,6 +40,7 @@ const enabledActionTypes = [ '.resilient', '.slack', '.webhook', + '.xmatters', 'test.authorization', 'test.failing', 'test.index-record', diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index ecfd8ef3b8e52..6e3e1491b633f 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -20,6 +20,7 @@ import { initPlugin as initResilient } from './resilient_simulation'; import { initPlugin as initSlack } from './slack_simulation'; import { initPlugin as initWebhook } from './webhook_simulation'; import { initPlugin as initMSExchange } from './ms_exchage_server_simulation'; +import { initPlugin as initXmatters } from './xmatters_simulation'; export const NAME = 'actions-FTS-external-service-simulators'; @@ -32,6 +33,7 @@ export enum ExternalServiceSimulator { RESILIENT = 'resilient', WEBHOOK = 'webhook', MS_EXCHANGE = 'exchange', + XMATTERS = 'xmatters', } export function getExternalServiceSimulatorPath(service: ExternalServiceSimulator): string { @@ -122,6 +124,7 @@ export class FixturePlugin implements Plugin, + res: KibanaResponseFactory + ): Promise> { + const { body } = req; + const alertActionGroupName = body?.alertActionGroupName; + switch (alertActionGroupName) { + case 'respond-with-400': + return jsonErrorResponse(res, 400, new Error(alertActionGroupName)); + case 'respond-with-429': + return jsonErrorResponse(res, 429, new Error(alertActionGroupName)); + case 'respond-with-502': + return jsonErrorResponse(res, 502, new Error(alertActionGroupName)); + } + return jsonResponse(res, 202, { + status: 'success', + }); + } + ); +} + +function jsonResponse( + res: KibanaResponseFactory, + code: number, + object: Record = {} +) { + return res.custom>({ body: object, statusCode: code }); +} + +function jsonErrorResponse(res: KibanaResponseFactory, code: number, object: Error) { + return res.custom({ body: object, statusCode: code }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/xmatters.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/xmatters.ts new file mode 100644 index 0000000000000..83edc6f5a2984 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/xmatters.ts @@ -0,0 +1,252 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import httpProxy from 'http-proxy'; +import expect from '@kbn/expect'; + +import { getHttpProxyServer } from '../../../../common/lib/get_proxy_server'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + getExternalServiceSimulatorPath, + ExternalServiceSimulator, +} from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +// eslint-disable-next-line import/no-default-export +export default function xmattersTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + const configService = getService('config'); + + describe('xmatters action', () => { + let simulatedActionId = ''; + let xmattersSimulatorURL: string = ''; + let proxyServer: httpProxy | undefined; + let proxyHaveBeenCalled = false; + + // need to wait for kibanaServer to settle ... + before(async () => { + xmattersSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.XMATTERS) + ); + proxyServer = await getHttpProxyServer( + kibanaServer.resolveUrl('/'), + configService.get('kbnTestServer.serverArgs'), + () => { + proxyHaveBeenCalled = true; + } + ); + }); + + it('xmatters connector can be executed without username and password, with secretsUrl', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An xmatters action', + connector_type_id: '.xmatters', + config: { + configUrl: null, + usesBasic: false, + }, + secrets: { + secretsUrl: xmattersSimulatorURL, + }, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + is_preconfigured: false, + name: 'An xmatters action', + connector_type_id: '.xmatters', + is_missing_secrets: false, + config: { + configUrl: null, + usesBasic: false, + }, + }); + + expect(typeof createdAction.id).to.be('string'); + }); + + it('xmatters connector can be executed with valid username and password', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An xmatters action', + connector_type_id: '.xmatters', + config: { + configUrl: xmattersSimulatorURL, + usesBasic: true, + }, + secrets: { + password: 'mypassphrase', + user: 'username', + }, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + is_preconfigured: false, + name: 'An xmatters action', + connector_type_id: '.xmatters', + is_missing_secrets: false, + config: { + configUrl: xmattersSimulatorURL, + usesBasic: true, + }, + }); + + expect(typeof createdAction.id).to.be('string'); + }); + + it('should return unsuccessfully when default xmatters configUrl is not present in allowedHosts', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A xmatters action', + connector_type_id: '.xmatters', + config: { + configUrl: 'https://events.xmatters.com/v2/enqueue', + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: Error configuring xMatters action: target url "https://events.xmatters.com/v2/enqueue" is not added to the Kibana config xpack.actions.allowedHosts', + }); + }); + }); + + it('should create xmatters simulator action successfully', async () => { + const { body: createdSimulatedAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A xmatters simulator', + connector_type_id: '.xmatters', + config: { + usesBasic: false, + }, + secrets: { + secretsUrl: xmattersSimulatorURL, + }, + }) + .expect(200); + + simulatedActionId = createdSimulatedAction.id; + }); + + it('should handle executing with a simulated success', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + alertActionGroupName: 'success', + signalId: 'abcd-1234:abcd-1234', + severity: 'High', + ruleName: 'SomeRule', + date: '', + spaceId: '', + }, + }) + .expect(200); + + expect(proxyHaveBeenCalled).to.equal(true); + expect(result).to.eql({ + status: 'ok', + connector_id: simulatedActionId, + data: { + alertActionGroupName: 'success', + signalId: 'abcd-1234:abcd-1234', + severity: 'High', + ruleName: 'SomeRule', + date: '', + spaceId: '', + }, + }); + }); + + it('should handle a 40x xmatters error', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + alertActionGroupName: 'respond-with-400', + signalId: 'abcd-1234:abcd-1234', + severity: 'High', + ruleName: 'SomeRule', + date: '', + spaceId: '', + }, + }) + .expect(200); + expect(result.status).to.equal('error'); + expect(result.message).to.match(/Error triggering xMatters flow: unexpected status 400/); + }); + + it('should handle a 429 xmatters error', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + alertActionGroupName: 'respond-with-429', + signalId: 'abcd-1234:abcd-1234', + severity: 'High', + ruleName: 'SomeRule', + date: '', + spaceId: '', + }, + }) + .expect(200); + + expect(result.status).to.equal('error'); + expect(result.message).to.match( + /Error triggering xMatters flow: http status 429, retry later/ + ); + }); + + it('should handle a 500 xmatters error', async () => { + const { body: result } = await supertest + .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + alertActionGroupName: 'respond-with-502', + signalId: 'abcd-1234:abcd-1234', + severity: 'High', + ruleName: 'SomeRule', + date: '', + spaceId: '', + }, + }) + .expect(200); + + expect(result.status).to.equal('error'); + expect(result.message).to.match( + /Error triggering xMatters flow: http status 502, retry later/ + ); + expect(result.retry).to.equal(true); + }); + + after(() => { + if (proxyServer) { + proxyServer.close(); + } + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts index d247e066226e9..93d4bbb4065ed 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts @@ -32,6 +32,7 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo loadTestFile(require.resolve('./builtin_action_types/resilient')); loadTestFile(require.resolve('./builtin_action_types/slack')); loadTestFile(require.resolve('./builtin_action_types/webhook')); + loadTestFile(require.resolve('./builtin_action_types/xmatters')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./execute'));