Add Torq Connector (#149405)

## Summary

Add a new action type for Torq which triggers Torq workflows.

This is a re-do of https://github.com/elastic/kibana/pull/139635 ...

### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))


### Risk Matrix

Delete this section if it is not applicable to this PR.

Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.

When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:

| Risk | Probability | Severity | Mitigation/Notes |

|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces—unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes—Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: orihoogi <ohoogi@gmail.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nate 2023-01-31 17:25:46 +02:00 committed by GitHub
parent e62fdcb47d
commit 92cb000a2f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1685 additions and 2 deletions

View file

@ -74,6 +74,10 @@ a| <<cases-webhook-action-type,{webhook-cm}>>
a| <<xmatters-action-type,xMatters>>
| Send actionable alerts to on-call xMatters resources.
a| <<torq-action-type,Torq>>
| Trigger a Torq workflow.
|===
[NOTE]

View file

@ -0,0 +1,65 @@
[role="xpack"]
[[torq-action-type]]
=== Torq connector and action
++++
<titleabbrev>Torq</titleabbrev>
++++
The Torq connector uses a Torq webhook to trigger workflows with Kibana actions.
[float]
[[torq-connector-configuration]]
==== Connector configuration
Torq connectors have the following configuration properties.
Name:: The name of the connector. The name is used to identify a connector in the Stack Management UI connector listing, and in the connector list when configuring an action.
Torq endpoint URL:: Endpoint URL (webhook) of the Elastic Security integration you created in Torq.
Torq authentication header secret:: Secret of the webhook authentication header.
[float]
[[Preconfigured-torq-configuration]]
==== Preconfigured connector type
[source,yaml]
--
my-torq:
name: preconfigured-torq-connector-type
actionTypeId: .torq
config:
webhookIntegrationUrl: https://hooks.torq.io/v1/somehook
secrets:
token: mytorqtoken
--
Config defines information for the connector type.
`webhookIntegrationUrl`:: An address that corresponds to **Torq endpoint URL**.
Secrets defines sensitive information for the connector type.
`token`:: A string that corresponds to **Torq authentication header secret**.
[float]
[[define-torq-ui]]
==== Define connector in Stack Management
Define Torq connector properties.
[role="screenshot"]
image::management/connectors/images/torq-configured-connector.png[configured Torq connector]
Test Torq action parameters.
[role="screenshot"]
image::management/connectors/images/torq-connector-test.png[Torq connector test]
[float]
[[torq-action-configuration]]
==== Action configuration
Torq actions have the following configuration properties.
Body:: JSON payload to send to Torq.

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View file

@ -15,4 +15,5 @@ include::action-types/tines.asciidoc[leveloffset=+1]
include::action-types/webhook.asciidoc[]
include::action-types/cases-webhook.asciidoc[leveloffset=+1]
include::action-types/xmatters.asciidoc[]
include::action-types/torq.asciidoc[]
include::pre-configured-connectors.asciidoc[]

View file

@ -131,7 +131,7 @@ A list of allowed email domains which can be used with the email connector. When
WARNING: This feature is available in {kib} 7.17.4 and 8.3.0 onwards but is not supported in {kib} 8.0, 8.1 or 8.2. As such, this setting should be removed before upgrading from 7.17 to 8.0, 8.1 or 8.2. It is possible to configure the settings in 7.17.4 and then upgrade to 8.3.0 directly.
`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`, `.opsgenie`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.tines`, `.xmatters`, 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`, `.opsgenie`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.tines`, `.torq`, `.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

@ -22,6 +22,7 @@ import { getSlackConnectorType } from './slack';
import { getSwimlaneConnectorType } from './swimlane';
import { getTeamsConnectorType } from './teams';
import { getTinesConnectorType } from './tines';
import { getTorqConnectorType } from './torq';
import { getWebhookConnectorType } from './webhook';
import { getXmattersConnectorType } from './xmatters';
@ -55,5 +56,6 @@ export function registerConnectorTypes({
connectorTypeRegistry.register(getResilientConnectorType());
connectorTypeRegistry.register(getOpsgenieConnectorType());
connectorTypeRegistry.register(getTeamsConnectorType());
connectorTypeRegistry.register(getTorqConnectorType());
connectorTypeRegistry.register(getTinesConnectorType());
}

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 getTorqConnectorType } from './torq';

View file

@ -0,0 +1,22 @@
/*
* 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 { LogoProps } from '../types';
const Logo = (props: LogoProps) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#00A6C1" {...props}>
<path d="M88.5,20.3c0-3.5-3.8-6.7-7.8-6.7H46V30h42.5L88.5,20.3z" fill="#00A6C1" />
<rect x="11.5" y="41.8" width="77" height="16.4" fill="#00A6C1" />
<path d="M11.6,70v9.4c0,3.6,3.8,6.9,7.8,6.9h35.4V70L11.6,70L11.6,70z" fill="#00A6C1" />
</svg>
);
};
// eslint-disable-next-line import/no-default-export
export { Logo as default };

View file

@ -0,0 +1,75 @@
/*
* 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 { ActionTypeModel } from '@kbn/triggers-actions-ui-plugin/public';
import { TypeRegistry } from '@kbn/triggers-actions-ui-plugin/public/application/type_registry';
import { registerConnectorTypes } from '..';
import { registrationServicesMock } from '../../mocks';
const ACTION_TYPE_ID = '.torq';
let actionTypeModel: ActionTypeModel;
beforeAll(() => {
const connectorTypeRegistry = new TypeRegistry<ActionTypeModel>();
registerConnectorTypes({ connectorTypeRegistry, services: registrationServicesMock });
const getResult = connectorTypeRegistry.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);
});
});
describe('torq action params validation', () => {
test('action params validation succeeds when action params is valid', async () => {
const actionParams = {
body: '{"message": "{test}"}',
};
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: { body: [] },
});
});
test('action params validation succeeds when action params is valid - mustache', async () => {
const actionParams = {
body: '{"message": {{number}}}',
};
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: { body: [] },
});
});
test('params validation fails when body is empty', async () => {
const actionParams = {
body: '',
};
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
body: ['Body is required.'],
},
});
});
test('params validation fails when body is not a valid JSON', async () => {
const actionParams = {
body: 'some text',
};
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
body: ['Body must be a valid JSON.'],
},
});
});
});

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
ActionTypeModel,
GenericValidationResult,
} from '@kbn/triggers-actions-ui-plugin/public/types';
import { lazy } from 'react';
import { TorqActionParams, TorqConfig, TorqSecrets } from '../types';
import * as i18n from './translations';
const torqDefaultBody = `{
"alert": {{alert}},
"context": {{context}},
"rule": {{rule}},
"state": {{state}},
"date": "{{date}}",
"kibana_base_url": "{{kibanaBaseUrl}}"
}`;
function replaceReferencesWithNumbers(body: string) {
return body.replace(/\{\{[.\w]+\}\}/gm, '42');
}
export function getActionType(): ActionTypeModel<TorqConfig, TorqSecrets, TorqActionParams> {
const validateParams = async (
actionParams: TorqActionParams
): Promise<GenericValidationResult<TorqActionParams>> => {
const translations = await import('./translations');
const errors = {
body: [] as string[],
};
const validationResult = { errors };
validationResult.errors = errors;
if (!actionParams.body?.length) {
errors.body.push(translations.BODY_REQUIRED);
} else {
try {
JSON.parse(replaceReferencesWithNumbers(actionParams.body || ''));
} catch (e) {
errors.body.push(translations.INVALID_JSON);
}
}
return validationResult;
};
return {
id: '.torq',
iconClass: lazy(() => import('./logo')),
selectMessage: i18n.TORQ_SELECT_MESSAGE,
actionTypeTitle: i18n.TORQ_ACTION_TYPE_TITLE,
validateParams,
actionConnectorFields: lazy(() => import('./torq_connectors')),
actionParamsFields: lazy(() => import('./torq_params')),
defaultActionParams: {
body: torqDefaultBody,
},
};
}

View file

@ -0,0 +1,180 @@
/*
* 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 { mountWithIntl } from '@kbn/test-jest-helpers';
import { act, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { ConnectorFormTestProvider, waitForComponentToUpdate } from '../lib/test_utils';
import TorqActionConnectorFields from './torq_connectors';
const EMPTY_FUNC = () => {};
describe('TorqActionConnectorFields renders', () => {
test('all connector fields are rendered', async () => {
const actionConnector = {
actionTypeId: '.torq',
name: 'torq',
config: {
webhookIntegrationUrl: 'https://hooks.torq.io/v1/webhooks/fjdkljfekdfjlsa',
},
secrets: {
token: 'testtoken',
},
isDeprecated: false,
};
const wrapper = mountWithIntl(
<ConnectorFormTestProvider connector={actionConnector}>
<TorqActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={EMPTY_FUNC}
/>
</ConnectorFormTestProvider>
);
await waitForComponentToUpdate();
expect(wrapper.find('[data-test-subj="torqUrlText"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="torqTokenInput"]').length > 0).toBeTruthy();
});
describe('Validation', () => {
const onSubmit = jest.fn();
const actionConnector = {
actionTypeId: '.torq',
name: 'torq',
config: {
webhookIntegrationUrl: 'https://hooks.torq.io/v1/webhooks/fjdksla',
},
secrets: {
token: 'testtoken',
},
isDeprecated: false,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('connector validation succeeds when connector config is valid', async () => {
const { getByTestId } = render(
<ConnectorFormTestProvider connector={actionConnector} onSubmit={onSubmit}>
<TorqActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={EMPTY_FUNC}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toBeCalledWith({
data: {
actionTypeId: '.torq',
name: 'torq',
config: {
webhookIntegrationUrl: 'https://hooks.torq.io/v1/webhooks/fjdksla',
},
secrets: {
token: 'testtoken',
},
isDeprecated: false,
},
isValid: true,
});
});
it('connector validation fails when there is no token', async () => {
const connector = {
...actionConnector,
secrets: {
token: '',
},
};
const { getByTestId } = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<TorqActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={EMPTY_FUNC}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toBeCalledWith({
data: {},
isValid: false,
});
});
it('connector validation fails when there is no webhook URL', async () => {
const connector = {
...actionConnector,
config: {
webhookIntegrationUrl: '',
},
};
const { getByTestId } = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<TorqActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={EMPTY_FUNC}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toBeCalledWith({
data: {},
isValid: false,
});
});
it('connector validation fails if the URL is not of a Torq webhook', async () => {
const connector = {
...actionConnector,
config: {
webhookIntegrationUrl: 'https://test.com',
},
};
const { getByTestId } = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<TorqActionConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={EMPTY_FUNC}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toBeCalledWith({
data: {},
isValid: false,
});
});
});
});

View file

@ -0,0 +1,97 @@
/*
* 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 { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components';
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
import { ERROR_CODE } from '@kbn/es-ui-shared-plugin/static/forms/helpers/field_validators/types';
import {
UseField,
ValidationError,
ValidationFunc,
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { isUrl } from '@kbn/es-ui-shared-plugin/static/validators/string';
import { ActionConnectorFieldsProps, PasswordField } from '@kbn/triggers-actions-ui-plugin/public';
import React from 'react';
import * as i18n from './translations';
const { urlField } = fieldValidators;
const Callout: React.FC<{ title: string; dataTestSubj: string }> = ({ title, dataTestSubj }) => {
return (
<>
<EuiSpacer size="s" />
<EuiCallOut size="s" iconType="iInCircle" data-test-subj={dataTestSubj} title={title} />
<EuiSpacer size="m" />
</>
);
};
const torqWebhookEndpoint =
(message: string) =>
(...args: Parameters<ValidationFunc>): ReturnType<ValidationFunc<any, ERROR_CODE>> => {
const [{ value }] = args as Array<{ value: string }>;
const error: ValidationError<ERROR_CODE> = {
code: 'ERR_FIELD_FORMAT',
formatType: 'URL',
message,
};
if (!isUrl(value)) return error;
const hostname = new URL(value).hostname;
return hostname === 'hooks.torq.io' ? undefined : error;
};
const TorqActionConnectorFields: React.FunctionComponent<ActionConnectorFieldsProps> = ({
readOnly,
}) => {
return (
<>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<Callout title={i18n.HOW_TO_TEXT} dataTestSubj="torq-how-to" />
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<UseField
path="config.webhookIntegrationUrl"
config={{
label: i18n.URL_LABEL,
validations: [
{
validator: urlField(i18n.URL_INVALID),
},
{
validator: torqWebhookEndpoint(i18n.URL_NOT_TORQ_WEBHOOK),
},
],
}}
helpText={i18n.URL_HELP_TEXT}
component={Field}
componentProps={{
euiFieldProps: { readOnly, 'data-test-subj': 'torqUrlText', fullWidth: true },
}}
/>
</EuiFlexItem>
<EuiFlexItem>
<PasswordField
path="secrets.token"
label={i18n.TORQ_TOKEN_LABEL}
readOnly={readOnly}
helpText={i18n.TORQ_TOKEN_HELP_TEXT}
data-test-subj="torqTokenInput"
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiSpacer size="m" />
</>
);
};
// eslint-disable-next-line import/no-default-export
export { TorqActionConnectorFields as default };

View file

@ -0,0 +1,52 @@
/*
* 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 { mountWithIntl } from '@kbn/test-jest-helpers';
import { MockCodeEditor } from '@kbn/triggers-actions-ui-plugin/public/application/code_editor.mock';
import React from 'react';
import TorqParamsFields from './torq_params';
const kibanaReactPath = '../../../../../../src/plugins/kibana_react/public';
jest.mock(kibanaReactPath, () => {
const original = jest.requireActual(kibanaReactPath);
return {
...original,
CodeEditor: (props: any) => {
return <MockCodeEditor {...props} />;
},
};
});
describe('TorqParamsFields renders', () => {
test('all params fields is rendered', () => {
const actionParams = {
body: 'test message',
};
const wrapper = mountWithIntl(
<TorqParamsFields
actionParams={actionParams}
errors={{ body: [] }}
editAction={() => {}}
index={0}
messageVariables={[
{
name: 'myVar',
description: 'My variable description',
useWithTripleBracesInTemplates: true,
},
]}
/>
);
expect(wrapper.find('[data-test-subj="bodyJsonEditor"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="bodyJsonEditor"]').first().prop('value')).toStrictEqual(
'test message'
);
expect(wrapper.find('[data-test-subj="bodyAddVariableButton"]').length > 0).toBeTruthy();
});
});

View file

@ -0,0 +1,60 @@
/*
* 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 {
ActionParamsProps,
JsonEditorWithMessageVariables,
} from '@kbn/triggers-actions-ui-plugin/public';
import React from 'react';
import { TorqActionParams } from '../types';
import * as i18n from './translations';
const TorqParamsFields: React.FunctionComponent<ActionParamsProps<TorqActionParams>> = ({
actionParams,
editAction,
index,
messageVariables,
errors,
}) => {
const { body } = actionParams;
return (
<JsonEditorWithMessageVariables
messageVariables={messageVariables}
paramsProperty={'body'}
inputTargetValue={body}
label={i18n.BODY_FIELD_LABEL}
aria-label={i18n.BODY_FIELD_ARIA_LABEL}
errors={errors.body as string[]}
onDocumentsChange={(json: string) => {
editAction('body', json, index);
}}
onBlur={() => {
if (!body) {
editAction('body', '', index);
}
}}
euiCodeEditorProps={{
options: {
renderValidationDecorations: body && errors?.body?.length ? 'on' : 'off',
lineNumbers: 'on',
fontSize: 14,
minimap: {
enabled: false,
},
scrollBeyondLastLine: false,
folding: true,
wordWrap: 'on',
wrappingIndent: 'indent',
automaticLayout: true,
},
}}
/>
);
};
// eslint-disable-next-line import/no-default-export
export { TorqParamsFields as default };

View file

@ -0,0 +1,84 @@
/*
* 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_LABEL = i18n.translate('xpack.stackConnectors.torqAction.urlTextFieldLabel', {
defaultMessage: 'Torq endpoint URL',
});
export const URL_INVALID = i18n.translate(
'xpack.stackConnectors.torqAction.error.invalidUrlTextField',
{
defaultMessage: 'URL is invalid.',
}
);
export const BODY_FIELD_LABEL = i18n.translate('xpack.stackConnectors.torqAction.bodyFieldLabel', {
defaultMessage: 'Body',
});
export const BODY_FIELD_ARIA_LABEL = i18n.translate(
'xpack.stackConnectors.torqAction.bodyCodeEditorAriaLabel',
{
defaultMessage: 'Code editor',
}
);
export const URL_NOT_TORQ_WEBHOOK = i18n.translate(
'xpack.stackConnectors.torqAction.error.urlIsNotTorqWebhook',
{
defaultMessage: 'URL is not a Torq integration endpoint.',
}
);
export const TORQ_TOKEN_LABEL = i18n.translate('xpack.stackConnectors.torqAction.token', {
defaultMessage: 'Torq integration token',
});
export const BODY_REQUIRED = i18n.translate('xpack.stackConnectors.error.requiredWebhookBodyText', {
defaultMessage: 'Body is required.',
});
export const INVALID_JSON = i18n.translate('xpack.stackConnectors.error.requireValidJSONBody', {
defaultMessage: 'Body must be a valid JSON.',
});
export const TORQ_SELECT_MESSAGE = i18n.translate(
'xpack.stackConnectors.torqAction.selectMessageText',
{
defaultMessage: 'Trigger a Torq workflow.',
}
);
export const TORQ_ACTION_TYPE_TITLE = i18n.translate(
'xpack.stackConnectors.torqAction.actionTypeTitle',
{
defaultMessage: 'Alert data',
}
);
export const TORQ_TOKEN_HELP_TEXT = i18n.translate(
'xpack.stackConnectors.torqAction.tokenHelpText',
{
defaultMessage:
'Enter the webhook authentication header secret generated when you created the Elastic Security integration.',
}
);
export const URL_HELP_TEXT = i18n.translate('xpack.stackConnectors.torqAction.urlHelpText', {
defaultMessage:
'Enter the endpoint URL generated when you created the Elastic Security integration on Torq.',
});
export const HOW_TO_TEXT = i18n.translate(
'xpack.stackConnectors.torqActionConnectorFields.calloutTitle',
{
defaultMessage:
'Create an Elastic Security integration on Torq, and then come back and paste the endpoint URL and token generated for your integration.',
}
);

View file

@ -72,6 +72,10 @@ export interface WebhookActionParams {
body?: string;
}
export interface TorqActionParams {
body?: string;
}
export interface EmailConfig {
from: string;
host: string;
@ -132,6 +136,16 @@ export interface WebhookSecrets {
export type WebhookActionConnector = UserConfiguredActionConnector<WebhookConfig, WebhookSecrets>;
export interface TorqConfig {
url: string;
}
export interface TorqSecrets {
token: string;
}
export type TorqActionConnector = UserConfiguredActionConnector<TorqConfig, TorqSecrets>;
export enum XmattersSeverityOptions {
CRITICAL = 'critical',
HIGH = 'high',

View file

@ -18,6 +18,7 @@ const ACTION_TYPE_IDS = [
'.teams',
'.webhook',
'.xmatters',
'.torq',
];
const mockedActions = actionsMock.createSetup();

View file

@ -14,6 +14,7 @@ import { getServiceNowITSMConnectorType } from './servicenow_itsm';
import { getServiceNowSIRConnectorType } from './servicenow_sir';
import { getServiceNowITOMConnectorType } from './servicenow_itom';
import { getTinesConnectorType } from './tines';
import { getActionType as getTorqConnectorType } from './torq';
import { getConnectorType as getEmailConnectorType } from './email';
import { getConnectorType as getIndexConnectorType } from './es_index';
import { getConnectorType as getPagerDutyConnectorType } from './pagerduty';
@ -89,6 +90,7 @@ export function registerConnectorTypes({
actions.registerType(getJiraConnectorType());
actions.registerType(getResilientConnectorType());
actions.registerType(getTeamsConnectorType());
actions.registerType(getTorqConnectorType());
actions.registerSubActionConnectorType(getOpsgenieConnectorType());
actions.registerSubActionConnectorType(getTinesConnectorType());

View file

@ -0,0 +1,235 @@
/*
* 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 { Logger } from '@kbn/core/server';
import axios from 'axios';
import { ActionTypeConfigType, getActionType, TorqActionType } from '.';
import * as utils from '@kbn/actions-plugin/server/lib/axios_utils';
import { validateConfig, validateParams, validateSecrets } from '@kbn/actions-plugin/server/lib';
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
import { Services } from '@kbn/actions-plugin/server/types';
import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock';
import { loggerMock } from '@kbn/logging-mocks';
import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config';
jest.mock('axios');
jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => {
const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils');
return {
...originalUtils,
request: jest.fn(),
patch: jest.fn(),
};
});
const requestMock = utils.request as jest.Mock;
axios.create = jest.fn(() => axios);
const services: Services = actionsMock.createServices();
let actionType: TorqActionType;
const mockedLogger: jest.Mocked<Logger> = loggerMock.create();
let configurationUtilities: jest.Mocked<ActionsConfigurationUtilities>;
beforeAll(() => {
actionType = getActionType();
configurationUtilities = actionsConfigMock.create();
});
describe('actionType', () => {
test('exposes the action as `torq` on its Id and Name', () => {
expect(actionType.id).toEqual('.torq');
expect(actionType.name).toEqual('Torq');
});
});
describe('secrets validation', () => {
test('succeeds when secrets is valid', () => {
const secrets: Record<string, string> = {
token: 'jfi2fji3ofeaiw34if',
};
expect(validateSecrets(actionType, secrets, { configurationUtilities })).toEqual(secrets);
});
test('fails when secret token is not provided', () => {
expect(() => {
validateSecrets(actionType, {}, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type secrets: [token]: expected value of type [string] but got [undefined]"`
);
});
});
describe('config validation', () => {
const defaultValues: Record<string, string | null> = {};
test('config validation passes with an appropriate endpoint', () => {
const config: Record<string, string | boolean> = {
webhookIntegrationUrl: 'https://hooks.torq.io/v1/test',
};
expect(validateConfig(actionType, config, { configurationUtilities })).toEqual({
...defaultValues,
...config,
});
});
const errorCases: Array<{ name: string; url: string; errorMsg: string }> = [
{
name: 'invalid URL leads to error',
url: 'iamnotavalidurl',
errorMsg: `"error validating action type config: error configuring send to Torq action: unable to parse url: TypeError: Invalid URL: iamnotavalidurl"`,
},
{
name: 'incomplete URL leads to error',
url: 'example.com/do-something',
errorMsg: `"error validating action type config: error configuring send to Torq action: unable to parse url: TypeError: Invalid URL: example.com/do-something"`,
},
{
name: 'fails when URL is not a Torq webhook endpoint',
url: 'http://mylisteningserver:9200/endpoint',
errorMsg: `"error validating action type config: error configuring send to Torq action: url must begin with https://hooks.torq.io"`,
},
];
errorCases.forEach(({ name, url, errorMsg }) => {
test(name, () => {
const config: Record<string, string> = {
webhookIntegrationUrl: url,
};
expect(() => {
validateConfig(actionType, config, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(errorMsg);
});
});
test("config validation returns an error if the specified URL isn't added to allowedHosts", () => {
actionType = getActionType();
const configUtils = {
...actionsConfigMock.create(),
ensureUriAllowed: (_: string) => {
throw new Error(`target url is not present in allowedHosts`);
},
};
// any for testing
const config: Record<string, string> = {
webhookIntegrationUrl: 'http://mylisteningserver.com:9200/endpoint',
};
expect(() => {
validateConfig(actionType, config, { configurationUtilities: configUtils });
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type config: error configuring send to Torq action: target url is not present in allowedHosts"`
);
});
});
describe('params validation', () => {
test('params validation passes when a valid body is provided', () => {
const params: Record<string, string> = {
body: '{"message": "Hello"}',
};
expect(validateParams(actionType, params, { configurationUtilities })).toEqual({
...params,
});
});
});
describe('execute Torq action', () => {
beforeAll(() => {
requestMock.mockReset();
actionType = getActionType();
});
beforeEach(() => {
requestMock.mockReset();
requestMock.mockResolvedValue({
status: 200,
statusText: '',
data: '',
headers: [],
config: {},
});
});
test('execute with token happy flow', async () => {
const config: ActionTypeConfigType = {
webhookIntegrationUrl: 'https://hooks.torq.io/v1/test',
};
await actionType.executor({
actionId: 'some-id',
services,
config,
secrets: { token: '1234' },
params: { body: '{"msg": "some data"}' },
configurationUtilities,
logger: mockedLogger,
});
delete requestMock.mock.calls[0][0].configurationUtilities;
expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"axios": [MockFunction],
"data": Object {
"msg": "some data",
},
"headers": Object {
"Content-Type": "application/json",
"X-Torq-Token": "1234",
},
"logger": Object {
"context": Array [],
"debug": [MockFunction] {
"calls": Array [
Array [
"response from Torq action \\"some-id\\": [HTTP 200] ",
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
},
"error": [MockFunction],
"fatal": [MockFunction],
"get": [MockFunction],
"info": [MockFunction],
"isLevelEnabled": [MockFunction],
"log": [MockFunction],
"trace": [MockFunction],
"warn": [MockFunction],
},
"method": "post",
"url": "https://hooks.torq.io/v1/test",
"validateStatus": [Function],
}
`);
});
test('renders parameter templates as expected', async () => {
const templatedObject = `{"material": "rubber", "kind": "band"}`;
expect(actionType.renderParameterTemplates).toBeTruthy();
const paramsWithTemplates = {
body: '{"x": {{obj}}, "y": "{{scalar}}", "z": "{{scalar_with_json_chars}}"}',
};
const variables = {
obj: templatedObject,
scalar: '1970',
scalar_with_json_chars: 'noinjection", "here": "',
};
const params = actionType.renderParameterTemplates!(paramsWithTemplates, variables);
expect(params.body).toBe(
`{"x": ${templatedObject}, "y": "${variables.scalar}", "z": "${variables.scalar_with_json_chars}"}`
);
});
});

View file

@ -0,0 +1,391 @@
/*
* 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 } from 'lodash';
import axios, { AxiosError, AxiosResponse } from 'axios';
import { schema, TypeOf } from '@kbn/config-schema';
import { pipe } from 'fp-ts/lib/pipeable';
import { map, getOrElse } from 'fp-ts/lib/Option';
import { Logger } from '@kbn/core/server';
import { ActionType, ActionTypeExecutorOptions } from '@kbn/actions-plugin/server';
import {
AlertingConnectorFeatureId,
UptimeConnectorFeatureId,
SecurityConnectorFeatureId,
ActionTypeExecutorResult,
} from '@kbn/actions-plugin/common';
import { renderMustacheObject } from '@kbn/actions-plugin/server/lib/mustache_renderer';
import { request } from '@kbn/actions-plugin/server/lib/axios_utils';
import { ValidatorServices } from '@kbn/actions-plugin/server/types';
import { getRetryAfterIntervalFromHeaders } from '../lib/http_response_retry_header';
import { promiseResult, isOk, Result } from '../lib/result_type';
export type TorqActionType = ActionType<
ActionTypeConfigType,
ActionTypeSecretsType,
ActionParamsType,
unknown
>;
export type TorqActionTypeExecutorOptions = ActionTypeExecutorOptions<
ActionTypeConfigType,
ActionTypeSecretsType,
ActionParamsType
>;
const configSchemaProps = {
webhookIntegrationUrl: schema.string(),
};
const ConfigSchema = schema.object(configSchemaProps);
export type ActionTypeConfigType = TypeOf<typeof ConfigSchema>;
// secrets definition
export type ActionTypeSecretsType = TypeOf<typeof SecretsSchema>;
const secretSchemaProps = {
token: schema.string(),
};
const SecretsSchema = schema.object(secretSchemaProps);
// params definition
export type ActionParamsType = TypeOf<typeof ParamsSchema>;
const ParamsSchema = schema.object({
body: schema.string(),
});
export const ActionTypeId = '.torq';
// action type definition
export function getActionType(): TorqActionType {
return {
id: ActionTypeId,
minimumLicenseRequired: 'gold',
name: i18n.translate('xpack.stackConnectors.torqTitle', {
defaultMessage: 'Torq',
}),
supportedFeatureIds: [
AlertingConnectorFeatureId,
UptimeConnectorFeatureId,
SecurityConnectorFeatureId,
],
validate: {
config: {
schema: schema.object(configSchemaProps),
customValidator: validateActionTypeConfig,
},
secrets: {
schema: SecretsSchema,
},
params: {
schema: ParamsSchema,
},
},
renderParameterTemplates,
executor: curry(executor)(),
};
}
function renderParameterTemplates(
params: ActionParamsType,
variables: Record<string, unknown>
): ActionParamsType {
if (!params.body) return params;
return renderMustacheObject(params, variables);
}
function validateActionTypeConfig(
configObject: ActionTypeConfigType,
validatorServices: ValidatorServices
) {
const configuredUrl = configObject.webhookIntegrationUrl;
let configureUrlObj: URL;
try {
configureUrlObj = new URL(configuredUrl);
} catch (err) {
throw new Error(
i18n.translate('xpack.stackConnectors.torq.torqConfigurationErrorNoHostname', {
defaultMessage: 'error configuring send to Torq action: unable to parse url: {err}',
values: {
err,
},
})
);
}
try {
validatorServices.configurationUtilities.ensureUriAllowed(configuredUrl);
} catch (allowListError) {
throw new Error(
i18n.translate('xpack.stackConnectors.torq.torqConfigurationError', {
defaultMessage: 'error configuring send to Torq action: {message}',
values: {
message: allowListError.message,
},
})
);
}
if (configureUrlObj.hostname !== 'hooks.torq.io' && configureUrlObj.hostname !== 'localhost') {
throw new Error(
i18n.translate('xpack.stackConnectors.torq.torqConfigurationErrorInvalidHostname', {
defaultMessage:
'error configuring send to Torq action: url must begin with https://hooks.torq.io',
})
);
}
}
// action executor
export async function executor(
execOptions: TorqActionTypeExecutorOptions
): Promise<ActionTypeExecutorResult<unknown>> {
const actionId = execOptions.actionId;
const { webhookIntegrationUrl } = execOptions.config;
const { body: data } = execOptions.params;
const configurationUtilities = execOptions.configurationUtilities;
const secrets: ActionTypeSecretsType = execOptions.secrets;
const token = secrets.token;
let body;
try {
body = JSON.parse(data || 'null');
} catch (err) {
return errorInvalidBody(actionId, execOptions.logger, err);
}
const axiosInstance = axios.create();
const result: Result<AxiosResponse, AxiosError<{ message: string }>> = await promiseResult(
request({
axios: axiosInstance,
url: webhookIntegrationUrl,
method: 'post',
headers: {
'X-Torq-Token': token || '',
'Content-Type': 'application/json',
},
data: body,
configurationUtilities,
logger: execOptions.logger,
validateStatus: (status: number) => status >= 200 && status < 300,
})
);
if (isOk(result)) {
const {
value: { status, statusText },
} = result;
execOptions.logger.debug(
`response from Torq action "${actionId}": [HTTP ${status}] ${statusText}`
);
return successResult(actionId, data);
}
const { error } = result;
return handleExecutionError(error, execOptions.logger, actionId);
}
async function handleExecutionError(
error: AxiosError<{ message: string }>,
logger: Logger,
actionId: string
): Promise<ActionTypeExecutorResult<unknown>> {
if (error.response) {
const {
status,
statusText,
headers: responseHeaders,
data: { message: responseMessage },
} = error.response;
const responseMessageAsSuffix = responseMessage ? `: ${responseMessage}` : '';
const message = `[${status}] ${statusText}${responseMessageAsSuffix}`;
logger.error(`error on ${actionId} Torq event: ${message}`);
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
// special handling for 5xx
if (status >= 500) {
return retryResult(actionId, message);
}
// special handling for rate limiting
if (status === 429) {
return pipe(
getRetryAfterIntervalFromHeaders(responseHeaders),
map((retry) => retryResultSeconds(actionId, message, retry)),
getOrElse(() => retryResult(actionId, message))
);
}
if (status === 405) {
return errorResultInvalidMethod(actionId, message);
}
if (status === 401) {
return errorResultUnauthorised(actionId, message);
}
if (status === 404) {
return errorNotFound(actionId, message);
}
return errorResultInvalid(actionId, message);
} else if (error.code) {
const message = `[${error.code}] ${error.message}`;
logger.error(`error on ${actionId} Torq event: ${message}`);
return errorResultRequestFailed(actionId, message);
} else if (error.isAxiosError) {
const message = `${error.message}`;
logger.error(`error on ${actionId} Torq event: ${message}`);
return errorResultRequestFailed(actionId, message);
}
logger.error(`error on ${actionId} Torq action: unexpected error`);
return errorResultUnexpectedError(actionId);
}
function successResult(actionId: string, data: unknown): ActionTypeExecutorResult<unknown> {
return { status: 'ok', data, actionId };
}
function errorInvalidBody(
actionId: string,
logger: Logger,
err: Error
): ActionTypeExecutorResult<void> {
const errMessage = i18n.translate('xpack.stackConnectors.torq.invalidBodyErrorMessage', {
defaultMessage: 'error triggering Torq workflow, invalid body',
});
logger.error(`error on ${actionId} Torq event: ${errMessage}: ${err.message}`);
return {
status: 'error',
message: errMessage,
actionId,
serviceMessage: err.message,
};
}
function errorResultInvalid(
actionId: string,
serviceMessage: string
): ActionTypeExecutorResult<void> {
const errMessage = i18n.translate('xpack.stackConnectors.torq.invalidResponseErrorMessage', {
defaultMessage: 'error triggering Torq workflow, invalid response',
});
return {
status: 'error',
message: errMessage,
actionId,
serviceMessage,
};
}
function errorNotFound(actionId: string, serviceMessage: string): ActionTypeExecutorResult<void> {
const errMessage = i18n.translate('xpack.stackConnectors.torq.notFoundErrorMessage', {
defaultMessage: 'error triggering Torq workflow, make sure the webhook URL is valid',
});
return {
status: 'error',
message: errMessage,
actionId,
serviceMessage,
};
}
function errorResultRequestFailed(
actionId: string,
serviceMessage: string
): ActionTypeExecutorResult<unknown> {
const errMessage = i18n.translate('xpack.stackConnectors.torq.requestFailedErrorMessage', {
defaultMessage: 'error triggering Torq workflow, request failed',
});
return {
status: 'error',
message: errMessage,
actionId,
serviceMessage,
};
}
function errorResultInvalidMethod(
actionId: string,
serviceMessage: string
): ActionTypeExecutorResult<unknown> {
const errMessage = i18n.translate('xpack.stackConnectors.torq.invalidMethodErrorMessage', {
defaultMessage: 'error triggering Torq workflow, method is not supported',
});
return {
status: 'error',
message: errMessage,
actionId,
serviceMessage,
};
}
function errorResultUnauthorised(
actionId: string,
serviceMessage: string
): ActionTypeExecutorResult<unknown> {
const errMessage = i18n.translate('xpack.stackConnectors.torq.unauthorisedErrorMessage', {
defaultMessage: 'error triggering Torq workflow, unauthorised',
});
return {
status: 'error',
message: errMessage,
actionId,
serviceMessage,
};
}
function errorResultUnexpectedError(actionId: string): ActionTypeExecutorResult<void> {
const errMessage = i18n.translate('xpack.stackConnectors.torq.unreachableErrorMessage', {
defaultMessage: 'error triggering Torq workflow, unexpected error',
});
return {
status: 'error',
message: errMessage,
actionId,
};
}
function retryResult(actionId: string, serviceMessage: string): ActionTypeExecutorResult<void> {
const errMessage = i18n.translate(
'xpack.stackConnectors.torq.invalidResponseRetryLaterErrorMessage',
{
defaultMessage: 'error triggering Torq workflow, retry later',
}
);
return {
status: 'error',
message: errMessage,
retry: true,
actionId,
serviceMessage,
};
}
function retryResultSeconds(
actionId: string,
serviceMessage: string,
retryAfter: number
): ActionTypeExecutorResult<void> {
const retryEpoch = Date.now() + retryAfter * 1000;
const retry = new Date(retryEpoch);
const retryString = retry.toISOString();
const errMessage = i18n.translate(
'xpack.stackConnectors.torq.invalidResponseRetryDateErrorMessage',
{
defaultMessage: 'error triggering Torq workflow, retry at {retryString}',
values: {
retryString,
},
}
);
return {
status: 'error',
message: errMessage,
retry,
actionId,
serviceMessage,
};
}

View file

@ -25,7 +25,7 @@ describe('Stack Connectors Plugin', () => {
it('should register built in connector types', () => {
const actionsSetup = actionsMock.createSetup();
plugin.setup(coreSetup, { actions: actionsSetup });
expect(actionsSetup.registerType).toHaveBeenCalledTimes(15);
expect(actionsSetup.registerType).toHaveBeenCalledTimes(16);
expect(actionsSetup.registerType).toHaveBeenNthCalledWith(
1,
expect.objectContaining({

View file

@ -47,6 +47,7 @@ const enabledActionTypes = [
'.tines',
'.webhook',
'.xmatters',
'.torq',
'test.sub-action-connector',
'test.sub-action-connector-without-sub-actions',
'test.authorization',

View file

@ -25,6 +25,7 @@ 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';
import { initPlugin as initTorq } from './torq_simulation';
import { initPlugin as initUnsecuredAction } from './unsecured_actions_simulation';
import { initPlugin as initTines } from './tines_simulation';
@ -41,6 +42,7 @@ export enum ExternalServiceSimulator {
WEBHOOK = 'webhook',
MS_EXCHANGE = 'exchange',
XMATTERS = 'xmatters',
TORQ = 'torq',
TINES = 'tines',
}
@ -137,6 +139,7 @@ export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, Fixtu
const router = core.http.createRouter();
initTorq(router, getExternalServiceSimulatorPath(ExternalServiceSimulator.TORQ));
initXmatters(router, getExternalServiceSimulatorPath(ExternalServiceSimulator.XMATTERS));
initPagerduty(router, getExternalServiceSimulatorPath(ExternalServiceSimulator.PAGERDUTY));
initJira(router, getExternalServiceSimulatorPath(ExternalServiceSimulator.JIRA));

View file

@ -0,0 +1,76 @@
/*
* 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 '@kbn/core/server';
export function initPlugin(router: IRouter, path: string) {
router.post(
{
path,
options: {
authRequired: false,
},
validate: {
body: schema.object(
{
msg: schema.string(),
},
{ unknowns: 'allow' }
),
},
},
async function (
context: RequestHandlerContext,
req: KibanaRequest<any, any, any, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
if (!validateTorqToken(req)) {
return jsonErrorResponse(res, 401, new Error('unauthorised'));
}
const { body } = req;
const content = body?.msg;
switch (content) {
case 'respond-with-400':
return jsonErrorResponse(res, 400, new Error(content));
case 'respond-with-404':
return jsonErrorResponse(res, 404, new Error(content));
case 'respond-with-429':
return jsonErrorResponse(res, 429, new Error(content));
case 'respond-with-405':
return jsonErrorResponse(res, 405, new Error(content));
case 'respond-with-502':
return jsonErrorResponse(res, 502, new Error(content));
}
return jsonResponse(res, 204, {
status: 'success',
});
}
);
}
function validateTorqToken(req: KibanaRequest<any, any, any, any>): boolean {
return req.headers['x-torq-token'] === 'someRandomToken';
}
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,245 @@
/*
* 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 '@kbn/alerting-api-integration-helpers/get_proxy_server';
import { FtrProviderContext } from '../../../../../../common/ftr_provider_context';
import {
getExternalServiceSimulatorPath,
ExternalServiceSimulator,
} from '../../../../../common/plugins/actions_simulators/server/plugin';
// eslint-disable-next-line import/no-default-export
export default function torqTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
const configService = getService('config');
describe('Torq action', () => {
let simulatedActionId = '';
let torqSimulatorURL: string = '';
let proxyServer: httpProxy | undefined;
let proxyHaveBeenCalled = false;
// need to wait for kibanaServer to settle ...
before(async () => {
torqSimulatorURL = kibanaServer.resolveUrl(
getExternalServiceSimulatorPath(ExternalServiceSimulator.TORQ)
);
proxyServer = await getHttpProxyServer(
kibanaServer.resolveUrl('/'),
configService.get('kbnTestServer.serverArgs'),
() => {
proxyHaveBeenCalled = true;
}
);
});
it('Torq connector invalid token', async () => {
const { body: createdAction } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A Torq action',
connector_type_id: '.torq',
config: {
webhookIntegrationUrl: torqSimulatorURL,
},
secrets: {
token: 'invalidToken',
},
})
.expect(200);
const { body: result } = await supertest
.post(`/api/actions/connector/${createdAction.id}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
body: `{"msg": "test"}`,
},
})
.expect(200);
expect(result.status).to.eql('error');
expect(result.message).to.match(/error triggering Torq workflow, unauthorised/);
});
it('Torq connector can be executed with token', async () => {
const { body: createdAction } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A Torq action',
connector_type_id: '.torq',
config: {
webhookIntegrationUrl: torqSimulatorURL,
},
secrets: {
token: 'someRandomToken',
},
})
.expect(200);
expect(createdAction).to.eql({
id: createdAction.id,
is_preconfigured: false,
is_deprecated: false,
name: 'A Torq action',
connector_type_id: '.torq',
is_missing_secrets: false,
config: {
webhookIntegrationUrl: torqSimulatorURL,
},
});
expect(typeof createdAction.id).to.be('string');
});
it('should return unsuccessfully when default Torq webhookIntegrationUrl is not present in allowedHosts', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A Torq action',
connector_type_id: '.torq',
config: {
webhookIntegrationUrl: 'https://test.torq.io/v1/something',
},
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type config: error configuring send to Torq action: target url "https://test.torq.io/v1/something" is not added to the Kibana config xpack.actions.allowedHosts',
});
});
});
it('should create Torq simulator action successfully', async () => {
const { body: createdSimulatedAction } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A Torq simulator',
connector_type_id: '.torq',
config: {
webhookIntegrationUrl: torqSimulatorURL,
},
secrets: {
token: 'someRandomToken',
},
})
.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: {
body: `{"msg": "test"}`,
},
})
.expect(200);
expect(proxyHaveBeenCalled).to.equal(true);
expect(result).to.eql({
status: 'ok',
connector_id: simulatedActionId,
data: `{"msg": "test"}`,
});
});
it('should handle a 400 Torq error', async () => {
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
body: `{"msg": "respond-with-400"}`,
},
})
.expect(200);
expect(result.status).to.equal('error');
expect(result.message).to.match(/error triggering Torq workflow, invalid response/);
});
it('should handle a 404 Torq error', async () => {
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
body: `{"msg": "respond-with-404"}`,
},
})
.expect(200);
expect(result.status).to.equal('error');
expect(result.message).to.match(
/error triggering Torq workflow, make sure the webhook URL is valid/
);
});
it('should handle a 429 Torq error', async () => {
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
body: `{"msg": "respond-with-429"}`,
},
})
.expect(200);
expect(result.status).to.equal('error');
expect(result.message).to.match(/error triggering Torq workflow, retry later/);
});
it('should handle a 500 Torq error', async () => {
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
body: `{"msg": "respond-with-502"}`,
},
})
.expect(200);
expect(result.status).to.equal('error');
expect(result.message).to.match(/error triggering Torq workflow, retry later/);
expect(result.retry).to.equal(true);
});
it('should handle a 405 Torq error', async () => {
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
body: `{"msg": "respond-with-405"}`,
},
})
.expect(200);
expect(result.status).to.equal('error');
expect(result.message).to.match(/error triggering Torq workflow, method is not supported/);
});
after(() => {
if (proxyServer) {
proxyServer.close();
}
});
});
}

View file

@ -36,6 +36,7 @@ export default function connectorsTests({ loadTestFile, getService }: FtrProvide
loadTestFile(require.resolve('./connector_types/webhook'));
loadTestFile(require.resolve('./connector_types/xmatters'));
loadTestFile(require.resolve('./connector_types/tines'));
loadTestFile(require.resolve('./connector_types/torq'));
loadTestFile(require.resolve('./create'));
loadTestFile(require.resolve('./delete'));
loadTestFile(require.resolve('./execute'));

View file

@ -44,6 +44,7 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr
'.resilient',
'.teams',
'.tines',
'.torq',
'.opsgenie',
].sort()
);

View file

@ -60,6 +60,7 @@ export default function ({ getService }: FtrProviderContext) {
'actions:.swimlane',
'actions:.teams',
'actions:.tines',
'actions:.torq',
'actions:.webhook',
'actions:.xmatters',
'actions_telemetry',