[alerting]: adds a connector for xMatters (#122357)

* Begin work on building out the backend of an xMatters connector

* Begin work on building out the frontend of an xMatters connector

* Continue attempting to get connector to register properly

* Begin working on the UI for the Edit Connector Test page

* Start working on writing tests for backend component of the xMatters connector

* Remove unneeded test due to not having any fancy escaping

* Write tests for the frontend component of the xMatters connector

* Add documentation for new xMatters connector

* Begin working on functional tests

* Continue work on frontend for xMatters conenctor

* Continue work on backend for xMatters conenctor

* Continue work on the functional tests for the xMatters connector

* Update based on xMatters string reviews

* Remove hidden parameters from ui

* Continue working to get tests running successfully

* Fix my code after rebasing onto latest main

* Fix the xMatters server for the simluator

* Check if listening before listening to the xmatters server in simulator

* Continue work on improving the xMatters connector

* Update strings based on the xMatters team string review

* Remove the headers as an option for the xMatters connector

* Fix alignment of the xmatters logo on the connectors page

* Allow alertId and alertActionGroupName to be null in the xMatters connector for test requests

* Fix the functional tests for the xMatters connector

* Rename alertName to ruleName and remove headers from xMatters connector

* Continue removing headers and renaming alertName to ruleName

* Update the tests so all are passing

* Some clean up for the xMatters connector

* Update the doc images based on changes for the xMatters connector

* Change alert id to use rule id and alert id and be labeled as signal id in the xMatters connector

* Fix failing tests for xMatters connector

* Start addressing comments and failing builds

* Combine if statement

* Update test strings after updating error strings

* Begin making updates after discussions and reviews

* Update failing tests

* Few adjustments after my self review of the xMatters connector

* Fix one failing test

* Fix a few small bugs in the xMatters connector

* Address a few small bugs in the xMatters connector

* Address latest comments and fix a few tests on the xMatters connector

* Adjust naming of secretsUrl and configUrl

* Work on fixing tests for xMatters connector

* Begin updating the xMatters documentation

* Update based on build errors

* Update documentation typo

* Add validation tests for connectors created using the API

* Fix the failing functional tests

* Update docs after review from xMatters team

* Update accidentally duplicated translate id

* Fix small bugs and update based on xMatters team string reviews

* Fix failing tests due to string changes

* [DOCS] Fixes doc build errors

* Update based on comments and feedback

* Update docs based on feedback

* Fix failing functional tests

* Update based on the feedback

* Fix failures in the functional tests

* Remove accidentally added file

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: lcawl <lcawley@elastic.co>
This commit is contained in:
msimpson-xm 2022-03-16 07:22:27 -07:00 committed by GitHub
parent de29e5a803
commit 5641dcc12c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 2505 additions and 1 deletions

View file

@ -0,0 +1,119 @@
[[xmatters-action-type]]
=== xMatters connector and action
++++
<titleabbrev>xMatters</titleabbrev>
++++
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 <<action-settings, `xpack.actions.allowedHosts`>> 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 <<action-settings, Action configuration settings>> 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 <<creating-keystore, {kib} keystore>>. 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]

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View file

@ -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[]

View file

@ -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.

View file

@ -24,6 +24,7 @@ const ACTION_TYPE_IDS = [
'.swimlane',
'.teams',
'.webhook',
'.xmatters',
];
export function createActionTypeRegistry(): {

View file

@ -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 }));

View file

@ -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<AxiosResponse> {
const { url, data, basicAuth } = options;
const axiosInstance = axios.create();
return await request({
axios: axiosInstance,
method: 'post',
url,
logger,
...basicAuth,
data,
configurationUtilities,
validateStatus: () => true,
});
}

View file

@ -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<Logger>;
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<string, string> = {
user: 'bob',
password: 'supersecret',
};
expect(validateSecrets(actionType, secrets)).toEqual({
...secrets,
secretsUrl: null,
});
});
test('succeeds when secrets is valid with url auth', () => {
const secrets: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string | boolean> = {
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<string, string | boolean> = {
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<string, string | boolean> = {
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<string, null | boolean> = {
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<string, string> = {
severity: 'high',
};
expect(validateParams(actionType, params)).toEqual({
severity: 'high',
});
});
test('params validation passes when a valid parameters are provided', () => {
const params: Record<string, string> = {
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<string, string | boolean> = {
configUrl: 'http://mylisteningserver.com:9200/endpoint',
usesBasic: true,
};
const secrets: Record<string, string> = {};
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<string, string | boolean> = {
configUrl: 'http://mylisteningserver.com:9200/endpoint',
usesBasic: true,
};
const secrets: Record<string, string> = {
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<string, string | boolean> = {
usesBasic: true,
};
const secrets: Record<string, string> = {
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<string, string | boolean> = {
usesBasic: false,
};
const secrets: Record<string, string> = {
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<string, string | boolean> = {
configUrl: 'http://mylisteningserver.com:9200/endpoint',
usesBasic: true,
};
const secrets: Record<string, string> = {
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<string, string | boolean> = {
usesBasic: true,
};
const secrets: Record<string, string> = {
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<string, string | boolean> = {
configUrl: 'http://mylisteningserver.com:9200/endpoint',
usesBasic: false,
};
const secrets: Record<string, string> = {
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<string, string | boolean> = {
configUrl: 'http://mylisteningserver.com:9200/endpoint',
usesBasic: false,
};
const secrets: Record<string, string> = {};
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<string, string | boolean> = {
configUrl: 'http://mylisteningserver.com:9200/endpoint',
usesBasic: true,
};
const secrets: Record<string, string> = {
user: 'bob',
password: 'supersecret',
};
expect(validateConnector(actionType, { config, secrets })).toEqual(null);
});
test('connector validation succeeds with url auth', () => {
const config: Record<string, string | boolean> = {
usesBasic: false,
};
const secrets: Record<string, string> = {
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",
}
`);
});
});

View file

@ -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<typeof ConfigSchema>;
// secrets definition
export type ActionTypeSecretsType = TypeOf<typeof SecretsSchema>;
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<typeof ParamsSchema>;
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<ActionTypeExecutorResult<unknown>> {
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<unknown> {
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;
}

View file

@ -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());

View file

@ -132,6 +132,45 @@ export interface WebhookSecrets {
export type WebhookActionConnector = UserConfiguredActionConnector<WebhookConfig, WebhookSecrets>;
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;
}

View file

@ -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';

View file

@ -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 = () => (
<svg
width="32px"
height="32px"
viewBox="0 0 32 32"
className="euiIcon euiIcon--xLarge euiCard__icon"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<title>x-logo</title>
<defs>
<linearGradient
x1="-152.186789%"
y1="49.9260313%"
x2="122.099883%"
y2="49.9260313%"
id="linearGradient-1"
>
<stop stopColor="#00EAFF" offset="0%" />
<stop stopColor="#0027FF" offset="100%" />
</linearGradient>
<linearGradient
x1="133.249608%"
y1="50.0759851%"
x2="-139.951896%"
y2="50.0759851%"
id="linearGradient-2"
>
<stop stopColor="#00EAFF" offset="0%" />
<stop stopColor="#2FFF5D" offset="100%" />
</linearGradient>
<linearGradient
x1="-581.151461%"
y1="49.9863834%"
x2="168.680248%"
y2="49.9863834%"
id="linearGradient-3"
>
<stop stopColor="#00EAFF" offset="0%" />
<stop stopColor="#0027FF" offset="100%" />
</linearGradient>
</defs>
<g id="x-logo" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<g id="logo" transform="translate(1.016000, 0.000000)" fillRule="nonzero">
<path
d="M5.57870798,2.60882523 C6.35454282,2.60983449 7.09802778,2.9198044 7.64480955,3.47021373 L13.7237505,9.54915463 C17.2763116,13.1019067 17.2763116,18.8618534 13.7237505,22.4146055 L7.64480955,28.4935464 C6.50407296,29.6346227 4.6542981,29.6348982 3.51322172,28.4941616 C2.37214535,27.353425 2.37186986,25.5036502 3.5126064,24.3625738 L8.66493951,19.2102407 C10.4508094,17.4236601 10.4508094,14.5277944 8.66493951,12.7412139 L3.5126064,7.5962641 C2.67676857,6.76075345 2.42667308,5.5039484 2.87898098,4.4121057 C3.33128888,3.32026299 4.39688615,2.60849819 5.57870798,2.60882523 M5.57870798,2.50071512e-05 C3.34178543,2.50071512e-05 1.32500883,1.34716007 0.468940322,3.41379241 C-0.387128187,5.48042476 0.0861310957,7.85925319 1.6680047,9.4408658 L6.81910725,14.5858156 C7.58575926,15.3543266 7.58575926,16.5983585 6.81910725,17.3668695 L1.6680047,22.5118193 C0.242430371,23.9029275 -0.324901319,25.9531924 0.18277456,27.879253 C0.690450439,29.8053136 2.19468637,31.3095496 4.12074697,31.8172254 C6.04680757,32.3249013 8.09707249,31.7575696 9.4881807,30.3319953 L15.5671216,24.2530544 C20.1309041,19.678443 20.1309041,12.2730115 15.5671216,7.69840015 L9.4881807,1.61945925 C8.45380288,0.578892326 7.04591557,-0.00429297415 5.57870798,2.50071512e-05 Z"
id="Shape"
fill="url(#linearGradient-1)"
/>
<g
id="Group"
opacity="0.85"
transform="translate(11.025203, 0.000000)"
fill="url(#linearGradient-2)"
>
<path
d="M13.4080666,2.60882277 C14.5896644,2.60882277 15.6548061,3.32094325 16.1067984,4.41267422 C16.5587906,5.50440519 16.308617,6.76091179 15.4729376,7.5962641 L10.3279878,12.7412139 C9.47006302,13.5990185 8.98807579,14.7625234 8.98807579,15.9757273 C8.98807579,17.1889312 9.47006302,18.352436 10.3279878,19.2102407 L15.4729376,24.356421 C16.228551,25.0907353 16.530195,26.1750468 16.2623439,27.1940781 C15.9944927,28.2131094 15.1986534,29.0089487 14.1796221,29.2767999 C13.1605908,29.544651 12.0762793,29.243007 11.341965,28.4873936 L5.26917688,22.4084527 C1.7166157,18.8557006 1.7166157,13.0957539 5.26917688,9.54300186 L11.3481178,3.46406095 C11.8956791,2.91556337 12.6391891,2.60779717 13.4142194,2.60882277 M13.4142194,2.44027688e-05 C11.9466398,-0.00434727387 10.5383501,0.578821143 9.50351608,1.61945925 L3.42334462,7.69840015 C-1.14111487,12.2727315 -1.14111487,19.678723 3.42334462,24.2530544 L9.50228552,30.3319953 C11.6693032,32.446621 15.1339268,32.4254245 17.2749088,30.2844425 C19.4158907,28.1434606 19.4370872,24.678837 17.3224615,22.5118193 L12.1725895,17.3668695 C11.4059375,16.5983585 11.4059375,15.3543266 12.1725895,14.5858156 L17.3175393,9.4408658 C18.8992545,7.85941155 19.372592,5.48088152 18.5168391,3.41436087 C17.6610862,1.34784022 15.6447652,2.44027688e-05 13.4080666,2.44027688e-05 L13.4142194,2.44027688e-05 Z"
id="Shape"
/>
</g>
<path
d="M15.5683522,7.69840015 L11.469374,3.60065254 L11.469374,7.29231706 L13.7237505,9.54669352 C14.6047033,10.4272373 15.2953739,11.4793686 15.7529354,12.6378473 L18.4158561,12.6378473 L18.4158561,12.3560502 C17.8494186,10.5996838 16.8733978,9.00321069 15.5683522,7.69840015 Z"
id="Path"
fill="url(#linearGradient-3)"
/>
</g>
</g>
</svg>
);
// eslint-disable-next-line import/no-default-export
export { Logo as default };

View file

@ -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.',
}
);

View file

@ -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<ActionTypeModel>();
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: [] },
});
});
});

View file

@ -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<ConnectorValidationResult<Pick<XmattersConfig, 'configUrl'>, XmattersSecrets>> => {
const translations = await import('./translations');
const configErrors = {
configUrl: new Array<string>(),
};
const secretsErrors = {
user: new Array<string>(),
password: new Array<string>(),
secretsUrl: new Array<string>(),
};
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<Pick<XmattersActionParams, 'alertActionGroupName' | 'signalId'>>
> => {
const errors = {
alertActionGroupName: new Array<string>(),
signalId: new Array<string>(),
};
const validationResult = { errors };
validationResult.errors = errors;
return validationResult;
},
actionConnectorFields: lazy(() => import('./xmatters_connectors')),
actionParamsFields: lazy(() => import('./xmatters_params')),
};
}

View file

@ -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(
<XmattersActionConnectorFields
action={actionConnector}
errors={{ url: [], user: [], password: [] }}
editActionConfig={() => {}}
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(
<XmattersActionConnectorFields
action={actionConnector}
errors={{ url: [], user: [], password: [] }}
editActionConfig={() => {}}
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(
<XmattersActionConnectorFields
action={actionConnector}
errors={{ url: [], user: [], password: [] }}
editActionConfig={() => {}}
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(
<XmattersActionConnectorFields
action={actionConnector}
errors={{ url: [], user: [], password: [] }}
editActionConfig={() => {}}
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(
<XmattersActionConnectorFields
action={actionConnector}
errors={{ url: [], user: [], password: [] }}
editActionConfig={() => {}}
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(
<XmattersActionConnectorFields
action={actionConnector}
errors={{ url: [], user: [], password: [] }}
editActionConfig={() => {}}
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(
<XmattersActionConnectorFields
action={actionConnector}
errors={{ url: [], user: [], password: [] }}
editActionConfig={() => {}}
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);
});
});

View file

@ -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<XmattersActionConnector>
> = ({ 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 (
<>
<EuiTitle size="xxs">
<h4>
<FormattedMessage
id="xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.authenticationLabel"
defaultMessage="Authentication"
/>
</h4>
</EuiTitle>
<EuiSpacer size="xs" />
<EuiFormRow fullWidth>
<p>
<FormattedMessage
id="xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.connectorSettingsLabel"
defaultMessage="Select the authentication method used when setting up the xMatters trigger."
/>
</p>
</EuiFormRow>
<EuiSpacer size="l" />
<EuiButtonGroup
isFullWidth
buttonSize="m"
legend="Basic Authentication"
options={authenticationButtons}
color="primary"
idSelected={selectedAuth}
onChange={(id: string) => {
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', '');
}
}}
/>
<EuiSpacer size="m" />
{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}
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiFormRow
id="url"
fullWidth
error={usesBasic ? errors.configUrl : errors.secretsUrl}
isInvalid={isUrlInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.connectorSettingsFieldLabel',
{
defaultMessage: 'Initiation URL',
}
)}
helpText={
<FormattedMessage
id="xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.initiationUrlHelpText"
defaultMessage="Include the full xMatters url."
/>
}
>
<EuiFieldText
name="url"
isInvalid={isUrlInvalid}
fullWidth
readOnly={readOnly}
value={usesBasic ? configUrl : secretsUrl}
data-test-subj="xmattersUrlText"
onChange={(e) => {
if (selectedAuth === XmattersAuthenticationType.Basic) {
editActionConfig('configUrl', e.target.value);
} else {
editActionSecrets('secretsUrl', e.target.value);
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
{selectedAuth === XmattersAuthenticationType.Basic ? (
<>
<EuiSpacer size="m" />
<EuiTitle size="xxs">
<h4>
<FormattedMessage
data-test-subj="userCredsLabel"
id="xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.userCredsLabel"
defaultMessage="User credentials"
/>
</h4>
</EuiTitle>
<EuiSpacer size="xs" />
{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.',
}
)
)}
<EuiSpacer size="m" />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiFormRow
id="xmattersUser"
fullWidth
error={errors.user}
isInvalid={isUserInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.userTextFieldLabel',
{
defaultMessage: 'Username',
}
)}
>
<EuiFieldText
fullWidth
isInvalid={isUserInvalid}
name="user"
readOnly={readOnly}
value={user || ''}
data-test-subj="xmattersUserInput"
onChange={(e) => {
editActionSecrets('user', e.target.value);
}}
onBlur={() => {
if (!user) {
editActionSecrets('user', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="xmattersPassword"
fullWidth
error={errors.password}
isInvalid={isPasswordInvalid}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.passwordTextFieldLabel',
{
defaultMessage: 'Password',
}
)}
>
<EuiFieldPassword
fullWidth
name="password"
readOnly={readOnly}
isInvalid={isPasswordInvalid}
value={password || ''}
data-test-subj="xmattersPasswordInput"
onChange={(e) => {
editActionSecrets('password', e.target.value);
}}
onBlur={() => {
if (!password) {
editActionSecrets('password', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</>
) : null}
</>
);
};
// eslint-disable-next-line import/no-default-export
export { XmattersActionConnectorFields as default };

View file

@ -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(
<XmattersParamsFields
actionParams={actionParams}
errors={{
alertActionGroupName: [],
signalId: [],
ruleName: [],
date: [],
spaceId: [],
}}
editAction={() => {}}
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'
);
});
});

View file

@ -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<ActionParamsProps<XmattersActionParams>> = ({
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 (
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="xmattersSeverity"
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.severity',
{
defaultMessage: 'Severity',
}
)}
>
<EuiSelect
fullWidth
data-test-subj="severitySelect"
options={severityOptions}
hasNoInitialSelection={isUndefined(actionParams.severity)}
value={actionParams.severity}
onChange={(e) => {
editAction('severity', e.target.value, index);
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="xmattersTags"
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.xmattersAction.tags',
{
defaultMessage: 'Tags',
}
)}
>
<TextFieldWithMessageVariables
index={index}
editAction={editAction}
messageVariables={messageVariables}
paramsProperty={'tags'}
inputTargetValue={actionParams.tags}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};
// eslint-disable-next-line import/no-default-export
export { XmattersParamsFields as default };

View file

@ -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;
}
};

View file

@ -40,6 +40,7 @@ const enabledActionTypes = [
'.resilient',
'.slack',
'.webhook',
'.xmatters',
'test.authorization',
'test.failing',
'test.index-record',

View file

@ -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<void, void, FixtureSetupDeps, Fixtu
const router: IRouter = core.http.createRouter();
initXmatters(router, getExternalServiceSimulatorPath(ExternalServiceSimulator.XMATTERS));
initPagerduty(router, getExternalServiceSimulatorPath(ExternalServiceSimulator.PAGERDUTY));
initJira(router, getExternalServiceSimulatorPath(ExternalServiceSimulator.JIRA));
initResilient(router, getExternalServiceSimulatorPath(ExternalServiceSimulator.RESILIENT));

View file

@ -0,0 +1,68 @@
/*
* 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 { schema } from '@kbn/config-schema';
import {
RequestHandlerContext,
KibanaRequest,
KibanaResponseFactory,
IKibanaResponse,
IRouter,
} from 'kibana/server';
export function initPlugin(router: IRouter, path: string) {
router.post(
{
path,
options: {
authRequired: false,
},
validate: {
body: schema.object({
signalId: schema.string(),
alertActionGroupName: schema.string(),
ruleName: schema.string(),
date: schema.string(),
severity: schema.string(),
spaceId: schema.string(),
tags: schema.maybe(schema.string()),
}),
},
},
async function (
context: RequestHandlerContext,
req: KibanaRequest<any, any, any, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
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<string, unknown> = {}
) {
return res.custom<Record<string, unknown>>({ body: object, statusCode: code });
}
function jsonErrorResponse(res: KibanaResponseFactory, code: number, object: Error) {
return res.custom<Error>({ body: object, statusCode: code });
}

View file

@ -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();
}
});
});
}

View file

@ -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'));