[Security Solution] Tines connector (#143505)

## Summary

Issue: https://github.com/elastic/kibana/issues/140066
Doc:
https://docs.google.com/document/d/14BY-6CIin1CUH5bwJJgfrGl37hWO-CeNMdl_35agpvk/edit?usp=sharing

Create a new connector type that offers low friction/low effort approach
to augmenting Elastic capabilities with SOAR capabilities of Tines.

## Implementation

Tines connector implements subActionConnector. With 4 subActions
configured:

- **stories**: Retrieves the User available Story objects from Tines, to
render the Story selector options in the params form. It uses the
`email` and `token` authentication headers from the configuration.
It is requested only when the form opens and when the connector instance
changes.

- **webhooks**: Retrieves the Story available Webhooks objects from
Tines, to render the Webhook selector in the params form. It uses the
`email` and `token` authentication headers from the configuration and
the `story_id` parameter.
There is no filter for `type` in the actions (a.k.a. agents) endpoint,
so we have to request all actions and filter them by `type ===
'Agents::WebhookAgent'` on our side.
It is requested every time the selected story changes.

- **run**: The main action execution. It sends the alerts to the Tines
configured webhook, using webhook' `path` and `secret` values. There's
no template to render, the data coming from the execution is just pruned
(the `kibana` entry is removed from all `context.alerts`) and sent
directly using the same format to Tines.

- **test**: The test form execution. It ends up calling **run** but
using a parametrized body.

### Pagination
Both **stories** and **webhooks** subActions need pagination, since
Tines do not expose any search endpoint for them. The current hard limit
is 100 pages. The `paginatedRequest` function in the connector
implementation encapsulates this logic.

## Testing

1- Create a [Tines](https://www.tines.com/) free account.

2- Create a [new
Story](https://www.tines.com/docs/quickstart/simple-story) and attach a
[Webhook
Action](https://www.tines.com/docs/quickstart/creating-an-action) to
start receiving events.

3- Create an [API token](https://www.tines.com/api/authentication)

4- Configure the Tines Connector in Kibana using the Tines tenant URL
that has been generated in the Tines app, the email used to sign in, and
the API token generated.
[docs](https://github.com/semd/kibana/blob/140066_tines_connector/docs/management/connectors/action-types/tines.asciidoc#connector-configuration)

5- Attach the Tines Connector to a Detection Rule, selecting the Story
and Webhooks created.
[docs](https://github.com/semd/kibana/blob/140066_tines_connector/docs/management/connectors/action-types/tines.asciidoc#actions)

6- After each rule execution, events should appear in the Tines webhook
action.

## Screenshots

Configure a Tines connector


![tines_connector_selection](https://user-images.githubusercontent.com/17747913/196389019-820aff49-6ad6-442e-a69f-3c782cbd65e6.png)


![tines_connector_config](https://user-images.githubusercontent.com/17747913/198035138-e7f3bb25-ebd1-4cfd-9cc5-b0bfe434c25c.png)

Use the Tines connector 


![tines_rule_action](https://user-images.githubusercontent.com/17747913/196389010-c87045a4-2b74-4903-9a81-ccbcff09fbf1.png)


![tine_params_form](https://user-images.githubusercontent.com/17747913/198034501-7e9ad912-111e-48b6-8387-fcf6f0663511.png)

Tines events


![tines_events](https://user-images.githubusercontent.com/17747913/196734338-91e1a397-2d03-4ee6-8ad2-16cb39abe9bf.png)

### 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(https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com>
This commit is contained in:
Sergi Massaneda 2022-11-14 13:04:47 +01:00 committed by GitHub
parent f893523d63
commit 6bba30f94c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 3742 additions and 72 deletions

View file

@ -59,6 +59,10 @@ a| <<swimlane-action-type,{swimlane}>>
| Create an incident in {swimlane}.
a| <<tines-action-type,Tines>>
| Send events to a Tines Story.
a| <<webhook-action-type, {webhook}>>
| Send a request to a web service.

View file

@ -0,0 +1,105 @@
[role="xpack"]
[[tines-action-type]]
== Tines connector
++++
<titleabbrev>Tines</titleabbrev>
++++
The Tines connector uses Tines's https://www.tines.com/docs/actions/types/webhook[Webhook actions] to send events via POST request.
[float]
[[tines-connector-configuration]]
=== Connector configuration
Tines connectors have the following configuration properties.
URL:: The Tines tenant URL. If you are using the <<action-settings, `xpack.actions.allowedHosts`>> setting, make sure the hostname is added to the allowed hosts.
Email:: The email used to sign in to Tines.
API Token:: A Tines API token created by the user. https://www.tines.com/api/authentication#generate-api-token[Docs]
[role="screenshot"]
image::../images/tines-connector.png[Tines connector]
[float]
[[Preconfigured-tines-configuration]]
==== Preconfigured connector type
[source,text]
--
my-tines:
name: preconfigured-tines-connector-type
actionTypeId: .tines
config:
url: https://some-tenant-2345.tines.com
secrets:
email: some.address@test.com
token: ausergeneratedapitoken
--
Config defines information for the connector type.
`url`:: A Tines tenant URL string that corresponds to *URL*.
Secrets defines sensitive information for the connector type.
`email`:: A string that corresponds to *Email*.
`token`:: A string that corresponds to *API Token*.
[float]
[[tines-action-parameters]]
=== Action parameters
Tines action have the following parameters.
Story:: The Story to send the events to.
Webhook:: The Webhook action from the previous story that will receive the events, it is the data entry point.
Test Tines action parameters.
[role="screenshot"]
image::../images/tines-params-test.png[Tines params test]
[float]
[[tines-action-format]]
=== Actions
Once the Tines connector has been configured in an Alerting Rule.
[role="screenshot"]
image::../images/tines-alerting.png[Tines rule alert]
It will send a POST request to the Tines webhook action on every action execution with at least one result.
[float]
[[webhookUrlFallback-tines-configuration]]
==== Webhook URL fallback
It is possible for the requests to the Tines API, to get the stories and webhooks for the selectors, to hit the 500 results limit; in this scenario, the webhook URL fallback text field will be displayed.
Users can still use the selectors if the story or webhook exists in the 500 options loaded. Otherwise, users can paste the webhook URL in the test input field, it can be copied from the Tines webhook configuration.
When the webhook URL is defined, the connector will use it directly in the execution stage, and the story and webhook selectors will be disabled and ignored. To re-enable the story and webhook selectors, remove the webhook URL value.
[role="screenshot"]
image::../images/tines-webhook-url-fallback.png[Tines Webhook URL fallback]
[float]
[[tines-story-library]]
=== Tines Story Libary
In order to simplify the integration with Elastic, Tines offers a set of pre-defined Elastic stories in the Story library.
They can be found by searching for "Elastic" in the Tines Story library:
[role="screenshot"]
image::../images/tines_elastic_stories.png[Tines Elastic stories]
They can be imported directly into your Tines tenant.
=== Format
Tines connector will send the data in JSON format.
The message contains execution specific fields, such as `alertId`, `date`, `_index`, `kibanaBaseUrl`, along with the `rule` and `params` objects.
The number of alerts (signals) can be found at `state.signals_count`.
The alerts (signals) data is stored in the `context.alerts` array, following the https://www.elastic.co/guide/en/ecs/current/ecs-field-reference.html[ECS] format.

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View file

@ -14,4 +14,5 @@ include::action-types/webhook.asciidoc[]
include::action-types/cases-webhook.asciidoc[leveloffset=+1]
include::action-types/opsgenie.asciidoc[]
include::action-types/xmatters.asciidoc[]
include::action-types/tines.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`, `.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`, `.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

@ -0,0 +1,16 @@
/*
* 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 const TINES_TITLE = 'Tines';
export const TINES_CONNECTOR_ID = '.tines';
export const API_MAX_RESULTS = 500;
export const enum SUB_ACTION {
STORIES = 'stories',
WEBHOOKS = 'webhooks',
RUN = 'run',
TEST = 'test',
}

View file

@ -0,0 +1,46 @@
/*
* 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';
// Connector schema
export const TinesConfigSchema = schema.object({ url: schema.string() });
export const TinesSecretsSchema = schema.object({ email: schema.string(), token: schema.string() });
// Stories action schema
export const TinesStoriesActionParamsSchema = null;
export const TinesStoryObjectSchema = schema.object({
id: schema.number(),
name: schema.string(),
published: schema.boolean(),
});
export const TinesStoriesActionResponseSchema = schema.object({
stories: schema.arrayOf(TinesStoryObjectSchema),
incompleteResponse: schema.boolean(),
});
// Webhooks action schema
export const TinesWebhooksActionParamsSchema = schema.object({ storyId: schema.number() });
export const TinesWebhookObjectSchema = schema.object({
id: schema.number(),
name: schema.string(),
storyId: schema.number(),
path: schema.string(),
secret: schema.string(),
});
export const TinesWebhooksActionResponseSchema = schema.object({
webhooks: schema.arrayOf(TinesWebhookObjectSchema),
incompleteResponse: schema.boolean(),
});
// Run action schema
export const TinesRunActionParamsSchema = schema.object({
webhook: schema.maybe(TinesWebhookObjectSchema),
webhookUrl: schema.maybe(schema.string()),
body: schema.string(),
});
export const TinesRunActionResponseSchema = schema.object({}, { unknowns: 'ignore' });

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TypeOf } from '@kbn/config-schema';
import {
TinesConfigSchema,
TinesSecretsSchema,
TinesRunActionParamsSchema,
TinesRunActionResponseSchema,
TinesStoriesActionResponseSchema,
TinesWebhooksActionResponseSchema,
TinesWebhooksActionParamsSchema,
TinesWebhookObjectSchema,
TinesStoryObjectSchema,
} from './schema';
export type TinesConfig = TypeOf<typeof TinesConfigSchema>;
export type TinesSecrets = TypeOf<typeof TinesSecretsSchema>;
export type TinesRunActionParams = TypeOf<typeof TinesRunActionParamsSchema>;
export type TinesRunActionResponse = TypeOf<typeof TinesRunActionResponseSchema>;
export type TinesStoriesActionParams = void;
export type TinesStoryObject = TypeOf<typeof TinesStoryObjectSchema>;
export type TinesStoriesActionResponse = TypeOf<typeof TinesStoriesActionResponseSchema>;
export type TinesWebhooksActionParams = TypeOf<typeof TinesWebhooksActionParamsSchema>;
export type TinesWebhooksActionResponse = TypeOf<typeof TinesWebhooksActionResponseSchema>;
export type TinesWebhookObject = TypeOf<typeof TinesWebhookObjectSchema>;

View file

@ -29,6 +29,8 @@ import {
getSwimlaneConnectorType,
} from './cases';
import { getTinesConnectorType } from './security';
export interface RegistrationServices {
validateEmailAddresses: (
addresses: string[],
@ -59,4 +61,5 @@ export function registerConnectorTypes({
connectorTypeRegistry.register(getResilientConnectorType());
connectorTypeRegistry.register(getOpsgenieConnectorType());
connectorTypeRegistry.register(getTeamsConnectorType());
connectorTypeRegistry.register(getTinesConnectorType());
}

View file

@ -4,3 +4,5 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { getTinesConnectorType } from './tines';

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 { getConnectorType as getTinesConnectorType } from './tines';

View file

@ -0,0 +1,40 @@
/*
* 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) => (
<svg
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
x="0"
y="0"
width="32px"
height="32px"
viewBox="0 0 32 32"
enableBackground="new 0 0 32 32"
xmlSpace="preserve"
{...props}
>
<g>
<rect y="128.4" className="st0" width="25.7" height="46.6" style={{ fill: '#06AC38' }} />
<path
className="st0"
style={{ fill: '#8578E6' }}
fillRule="evenodd"
clipRule="evenodd"
d="M11.8018 0C8.01458 0 4.66599 2.45749 3.53258 6.06868L0.415527 16L3.53258 25.9313C4.66599 29.5425 8.01458 32 11.8018 32H20.1981C23.9853 32 27.3339 29.5425 28.4673 25.9313L31.5844 16L28.4673 6.06868C27.3339 2.45749 23.9853 0 20.1981 0H11.8018ZM20.1982 2.49634C22.8938 2.49634 25.2772 4.24548 26.0839 6.81577L26.8481 9.25062C25.3107 7.98154 23.3639 7.26723 21.3292 7.26707L10.648 7.26679C8.62691 7.26694 6.69264 7.97168 5.16015 9.22481L5.91625 6.81577C6.72297 4.24548 9.10635 2.49634 11.8019 2.49634H20.1982ZM5.73674 12.1986L3.79587 14.7519L28.1811 14.7519L26.2404 12.1989C25.0741 10.6646 23.2571 9.76356 21.329 9.76341H10.5898C8.68349 9.78153 6.89125 10.6798 5.73674 12.1986ZM28.1771 17.2482L26.2403 19.7989C25.0739 21.3349 23.2555 22.237 21.326 22.2368L10.6509 22.2366C8.72137 22.2367 6.90298 21.3346 5.73661 19.7986L3.79996 17.2482L28.1771 17.2482ZM5.9161 25.1842C6.72282 27.7545 9.1062 29.5037 11.8018 29.5037H20.1981C22.8936 29.5037 25.277 27.7545 26.0837 25.1842L26.8485 22.7476C25.3104 24.0182 23.3622 24.7333 21.3258 24.7332L10.651 24.7329C8.6283 24.7331 6.69244 24.0274 5.15921 22.7727L5.9161 25.1842Z"
/>
</g>
</svg>
);
// eslint-disable-next-line import/no-default-export
export { Logo as default };

View file

@ -0,0 +1,192 @@
/*
* 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 '@kbn/triggers-actions-ui-plugin/public/application/type_registry';
import { registerConnectorTypes } from '../..';
import type { ActionTypeModel as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public/types';
import { registrationServicesMock } from '../../../mocks';
import { TinesExecuteActionParams } from './types';
import {
SUB_ACTION,
TINES_CONNECTOR_ID,
TINES_TITLE,
} from '../../../../common/connector_types/security/tines/constants';
let actionTypeModel: ConnectorTypeModel;
const webhook = {
id: 1234,
name: 'some webhook action',
storyId: 5435,
path: 'somePath',
secret: 'someSecret',
};
const actionParams: TinesExecuteActionParams = {
subAction: SUB_ACTION.RUN,
subActionParams: { webhook },
};
const defaultValidationErrors = {
subAction: [],
story: [],
webhook: [],
webhookUrl: [],
body: [],
};
beforeAll(() => {
const connectorTypeRegistry = new TypeRegistry<ConnectorTypeModel>();
registerConnectorTypes({ connectorTypeRegistry, services: registrationServicesMock });
const getResult = connectorTypeRegistry.get(TINES_CONNECTOR_ID);
if (getResult !== null) {
actionTypeModel = getResult;
}
});
describe('actionTypeRegistry.get() works', () => {
it('should get Tines action type static data', () => {
expect(actionTypeModel.id).toEqual(TINES_CONNECTOR_ID);
expect(actionTypeModel.actionTypeTitle).toEqual(TINES_TITLE);
});
});
describe('tines action params validation', () => {
it('should fail when storyId is missing', async () => {
const validation = await actionTypeModel.validateParams({
...actionParams,
subActionParams: { webhook: { ...webhook, storyId: undefined } },
});
expect(validation.errors).toEqual({
...defaultValidationErrors,
story: ['Story is required.'],
});
});
it('should fail when webhook is missing', async () => {
const validation = await actionTypeModel.validateParams({
...actionParams,
subActionParams: { webhook: { ...webhook, id: undefined } },
});
expect(validation.errors).toEqual({
...defaultValidationErrors,
webhook: ['Webhook is required.'],
});
});
it('should fail when webhook path is missing', async () => {
const validation = await actionTypeModel.validateParams({
...actionParams,
subActionParams: { webhook: { ...webhook, path: '' } },
});
expect(validation.errors).toEqual({
...defaultValidationErrors,
webhook: ['Webhook action path is missing.'],
});
});
it('should fail when webhook secret is missing', async () => {
const validation = await actionTypeModel.validateParams({
...actionParams,
subActionParams: { webhook: { ...webhook, secret: '' } },
});
expect(validation.errors).toEqual({
...defaultValidationErrors,
webhook: ['Webhook action secret is missing.'],
});
});
it('should succeed when webhook params are correct', async () => {
const validation = await actionTypeModel.validateParams(actionParams);
expect(validation.errors).toEqual(defaultValidationErrors);
});
it('should fail when webhookUrl is not a URL', async () => {
const validation = await actionTypeModel.validateParams({
...actionParams,
subActionParams: { ...actionParams.subActionParams, webhookUrl: 'foo' },
});
expect(validation.errors).toEqual({
...defaultValidationErrors,
webhookUrl: ['Webhook URL is invalid.'],
});
});
it('should fail when webhookUrl is not using https', async () => {
const validation = await actionTypeModel.validateParams({
...actionParams,
subActionParams: { ...actionParams.subActionParams, webhookUrl: 'http://example.tines.com' },
});
expect(validation.errors).toEqual({
...defaultValidationErrors,
webhookUrl: ['Webhook URL does not have a valid "https" protocol.'],
});
});
it('should succeed when webhookUrl is a proper Tines URL', async () => {
const validation = await actionTypeModel.validateParams({
...actionParams,
subActionParams: {
...actionParams.subActionParams,
webhookUrl: 'https://example.tines.com/abc/1234',
},
});
expect(validation.errors).toEqual({
...defaultValidationErrors,
});
});
it('should fail when subAction is missing', async () => {
const validation = await actionTypeModel.validateParams({
...actionParams,
subAction: '',
});
expect(validation.errors).toEqual({
...defaultValidationErrors,
subAction: ['Action is required.'],
});
});
it('should fail when subAction is wrong', async () => {
const validation = await actionTypeModel.validateParams({
...actionParams,
subAction: 'stories',
});
expect(validation.errors).toEqual({
...defaultValidationErrors,
subAction: ['Invalid action name.'],
});
});
it('should fail when subAction is test and body is missing', async () => {
const validation = await actionTypeModel.validateParams({
...actionParams,
subAction: SUB_ACTION.TEST,
});
expect(validation.errors).toEqual({
...defaultValidationErrors,
body: ['Body is required.'],
});
});
it('should fail when subAction is test and body is not JSON format', async () => {
const validation = await actionTypeModel.validateParams({
subAction: SUB_ACTION.TEST,
subActionParams: { webhook, body: 'not json' },
});
expect(validation.errors).toEqual({
...defaultValidationErrors,
body: ['Body does not have a valid JSON format.'],
});
});
it('should succeed when subAction is test and params are correct', async () => {
const validation = await actionTypeModel.validateParams({
subAction: SUB_ACTION.TEST,
subActionParams: { webhook, body: '[]' },
});
expect(validation.errors).toEqual(defaultValidationErrors);
});
});

View file

@ -0,0 +1,106 @@
/*
* 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 type {
ActionTypeModel as ConnectorTypeModel,
GenericValidationResult,
} from '@kbn/triggers-actions-ui-plugin/public';
import {
TINES_CONNECTOR_ID,
TINES_TITLE,
SUB_ACTION,
} from '../../../../common/connector_types/security/tines/constants';
import type {
TinesConfig,
TinesSecrets,
} from '../../../../common/connector_types/security/tines/types';
import type { TinesExecuteActionParams } from './types';
interface ValidationErrors {
subAction: string[];
story: string[];
webhook: string[];
webhookUrl: string[];
body: string[];
}
export function getConnectorType(): ConnectorTypeModel<
TinesConfig,
TinesSecrets,
TinesExecuteActionParams
> {
return {
id: TINES_CONNECTOR_ID,
actionTypeTitle: TINES_TITLE,
iconClass: lazy(() => import('./logo')),
selectMessage: i18n.translate('xpack.stackConnectors.security.tines.config.selectMessageText', {
defaultMessage: 'Send events to a Story.',
}),
validateParams: async (
actionParams: TinesExecuteActionParams
): Promise<GenericValidationResult<ValidationErrors>> => {
const translations = await import('./translations');
const errors: ValidationErrors = {
subAction: [],
story: [],
webhook: [],
webhookUrl: [],
body: [],
};
const { subAction, subActionParams } = actionParams;
if (subActionParams?.webhookUrl) {
try {
const parsedUrl = new URL(subActionParams.webhookUrl);
if (parsedUrl.protocol !== 'https:') {
errors.webhookUrl.push(translations.INVALID_PROTOCOL_WEBHOOK_URL);
}
} catch (err) {
errors.webhookUrl.push(translations.INVALID_WEBHOOK_URL);
}
} else {
if (!subActionParams?.webhook?.storyId) {
errors.story.push(translations.STORY_REQUIRED);
} else {
if (!subActionParams?.webhook?.id) {
errors.webhook.push(translations.WEBHOOK_REQUIRED);
} else if (!subActionParams?.webhook?.path) {
errors.webhook.push(translations.WEBHOOK_PATH_REQUIRED);
} else if (!subActionParams?.webhook?.secret) {
errors.webhook.push(translations.WEBHOOK_SECRET_REQUIRED);
}
}
}
if (subAction === SUB_ACTION.TEST) {
if (!subActionParams?.body?.length) {
errors.body.push(translations.BODY_REQUIRED);
} else {
try {
JSON.parse(subActionParams.body);
} catch {
errors.body.push(translations.BODY_INVALID);
}
}
}
if (errors.story.length || errors.webhook.length || errors.body.length) return { errors };
// The internal "subAction" param should always be valid, ensure it is only if "subActionParams" are valid
if (!subAction) {
errors.subAction.push(translations.ACTION_REQUIRED);
} else if (subAction !== SUB_ACTION.RUN && subAction !== SUB_ACTION.TEST) {
errors.subAction.push(translations.INVALID_ACTION);
}
return { errors };
},
actionConnectorFields: lazy(() => import('./tines_connector')),
actionParamsFields: lazy(() => import('./tines_params')),
};
}

View file

@ -0,0 +1,159 @@
/*
* 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 { ConnectorFormTestProvider, waitForComponentToUpdate } from '../../lib/test_utils';
import { act, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
TINES_CONNECTOR_ID,
TINES_TITLE,
} from '../../../../common/connector_types/security/tines/constants';
import TinesConnectorFields from './tines_connector';
const url = 'https://example.com';
const email = 'some.email@test.com';
const token = '123';
const actionConnector = {
actionTypeId: TINES_CONNECTOR_ID,
name: TINES_TITLE,
config: { url },
secrets: { email, token },
isDeprecated: false,
};
describe('TinesConnectorFields renders', () => {
it('should render all fields', async () => {
const wrapper = mountWithIntl(
<ConnectorFormTestProvider connector={actionConnector}>
<TinesConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await waitForComponentToUpdate();
expect(wrapper.find('input[data-test-subj="config.url-input"]').exists()).toBe(true);
expect(wrapper.find('input[data-test-subj="config.url-input"]').prop('value')).toBe(url);
expect(wrapper.find('input[data-test-subj="secrets.email-input"]').exists()).toBe(true);
expect(wrapper.find('input[data-test-subj="secrets.email-input"]').prop('value')).toBe(email);
expect(wrapper.find('input[data-test-subj="secrets.token-input"]').exists()).toBe(true);
expect(wrapper.find('input[data-test-subj="secrets.token-input"]').prop('value')).toBe(token);
});
describe('Validation', () => {
const onSubmit = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should succeed validation when connector config is valid', async () => {
const { getByTestId } = render(
<ConnectorFormTestProvider connector={actionConnector} onSubmit={onSubmit}>
<TinesConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toBeCalledWith({
data: actionConnector,
isValid: true,
});
});
it('should fail validation when connector secrets are empty', async () => {
const connector = {
...actionConnector,
secrets: {},
};
const { getByTestId } = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<TinesConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toBeCalledWith({
data: {},
isValid: false,
});
});
it('should fail validation when connector url is empty', async () => {
const connector = {
...actionConnector,
config: { url: '' },
};
const { getByTestId } = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<TinesConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toBeCalledWith({
data: {},
isValid: false,
});
});
it('should fail validation when connector url is invalid', async () => {
const connector = {
...actionConnector,
config: { url: 'not a url' },
};
const { getByTestId } = render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<TinesConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toBeCalledWith({
data: {},
isValid: false,
});
});
});
});

View file

@ -0,0 +1,51 @@
/*
* 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 {
ActionConnectorFieldsProps,
ConfigFieldSchema,
SecretsFieldSchema,
SimpleConnectorForm,
} from '@kbn/triggers-actions-ui-plugin/public';
import * as i18n from './translations';
const configFormSchema: ConfigFieldSchema[] = [
{
id: 'url',
label: i18n.URL_LABEL,
isUrlField: true,
},
];
const secretsFormSchema: SecretsFieldSchema[] = [
{
id: 'email',
label: i18n.EMAIL_LABEL,
},
{
id: 'token',
label: i18n.TOKEN_LABEL,
isPasswordField: true,
},
];
const TinesActionConnectorFields: React.FunctionComponent<ActionConnectorFieldsProps> = ({
readOnly,
isEdit,
}) => (
<SimpleConnectorForm
isEdit={isEdit}
readOnly={readOnly}
configFormSchema={configFormSchema}
secretsFormSchema={secretsFormSchema}
/>
);
// eslint-disable-next-line import/no-default-export
export { TinesActionConnectorFields as default };

View file

@ -0,0 +1,532 @@
/*
* 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 { MockCodeEditor } from '@kbn/triggers-actions-ui-plugin/public/application/code_editor.mock';
import type { UseSubActionParams } from '@kbn/triggers-actions-ui-plugin/public/application/hooks/use_sub_action';
import TinesParamsFields from './tines_params';
import { ActionConnectorMode } from '@kbn/triggers-actions-ui-plugin/public/types';
const kibanaReactPath = '@kbn/kibana-react-plugin/public';
const triggersActionsPath = '@kbn/triggers-actions-ui-plugin/public';
interface Result {
isLoading: boolean;
response: Record<string, unknown>;
error: null | Error;
}
const mockUseSubActionStories = jest.fn<Result, [UseSubActionParams<unknown>]>(() => ({
isLoading: false,
response: { stories: [story], incompleteResponse: false },
error: null,
}));
const mockUseSubActionWebhooks = jest.fn<Result, [UseSubActionParams<unknown>]>(() => ({
isLoading: false,
response: { webhooks: [webhook], incompleteResponse: false },
error: null,
}));
const mockUseSubAction = jest.fn<Result, [UseSubActionParams<unknown>]>((params) =>
params.subAction === 'stories'
? mockUseSubActionStories(params)
: mockUseSubActionWebhooks(params)
);
const mockToasts = { danger: jest.fn(), warning: jest.fn() };
jest.mock(triggersActionsPath, () => {
const original = jest.requireActual(triggersActionsPath);
return {
...original,
useSubAction: (params: UseSubActionParams<unknown>) => mockUseSubAction(params),
useKibana: () => ({
...original.useKibana(),
notifications: { toasts: mockToasts },
}),
};
});
jest.mock(kibanaReactPath, () => {
const original = jest.requireActual(kibanaReactPath);
return {
...original,
CodeEditor: (props: any) => {
return <MockCodeEditor {...props} />;
},
};
});
const mockEditAction = jest.fn();
const index = 0;
const webhook = {
id: 1234,
storyId: 5678,
name: 'test webhook',
path: 'somePath',
secret: 'someSecret',
};
const story = { id: webhook.storyId, name: 'test story', published: false };
const actionParams = { subActionParams: { webhook } };
const emptyErrors = { subAction: [], subActionParams: [] };
describe('TinesParamsFields renders', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('New connector', () => {
it('should render empty run form', () => {
const wrapper = mountWithIntl(
<TinesParamsFields
actionParams={{}}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
expect(wrapper.find('[data-test-subj="tines-bodyJsonEditor"]').exists()).toBe(false);
expect(wrapper.find('[data-test-subj="tines-storySelector"]').exists()).toBe(true);
expect(wrapper.find('[data-test-subj="tines-storySelector"]').first().text()).toBe(
'Select a Tines story'
);
expect(wrapper.find('[data-test-subj="tines-webhookSelector"]').exists()).toBe(true);
expect(wrapper.find('[data-test-subj="tines-webhookSelector"]').first().text()).toBe(
'Select a story first'
);
expect(wrapper.find('[data-test-subj="tines-fallbackCallout"]').exists()).toBe(false);
expect(wrapper.find('[data-test-subj="tines-webhookUrlInput"]').exists()).toBe(false);
expect(mockEditAction).toHaveBeenCalledWith('subAction', 'run', index);
});
it('should render empty test form', () => {
const wrapper = mountWithIntl(
<TinesParamsFields
actionParams={{}}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.Test}
/>
);
expect(wrapper.find('[data-test-subj="tines-bodyJsonEditor"]').exists()).toBe(true);
expect(wrapper.find('[data-test-subj="bodyAddVariableButton"]').exists()).toBe(false);
expect(wrapper.find('[data-test-subj="tines-storySelector"]').exists()).toBe(true);
expect(wrapper.find('[data-test-subj="tines-storySelector"]').first().text()).toBe(
'Select a Tines story'
);
expect(wrapper.find('[data-test-subj="tines-webhookSelector"]').exists()).toBe(true);
expect(wrapper.find('[data-test-subj="tines-webhookSelector"]').first().text()).toBe(
'Select a story first'
);
expect(mockEditAction).toHaveBeenCalledWith('subAction', 'test', index);
});
it('should call useSubAction with empty form', () => {
mountWithIntl(
<TinesParamsFields
actionParams={{}}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
expect(mockUseSubAction).toHaveBeenCalledTimes(2);
expect(mockUseSubActionStories).toHaveBeenCalledWith(
expect.objectContaining({ subAction: 'stories' })
);
expect(mockUseSubActionWebhooks).toHaveBeenCalledWith(
expect.objectContaining({ subAction: 'webhooks', disabled: true })
);
});
it('should render with story selectable and webhook selector disabled', () => {
const wrapper = mountWithIntl(
<TinesParamsFields
actionParams={{}}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
wrapper
.find('[data-test-subj="tines-storySelector"] [data-test-subj="comboBoxToggleListButton"]')
.first()
.simulate('click');
expect(wrapper.find('[data-test-subj="tines-storySelector-optionsList"]').exists()).toBe(
true
);
expect(wrapper.find('[data-test-subj="tines-storySelector-optionsList"]').text()).toBe(
story.name
);
expect(
wrapper.find('[data-test-subj="tines-webhookSelector"]').first().prop('disabled')
).toBe(true);
});
it('should render with a story option with Published badge', () => {
mockUseSubActionStories.mockReturnValueOnce({
isLoading: false,
response: { stories: [{ ...story, published: true }], incompleteResponse: false },
error: null,
});
const wrapper = mountWithIntl(
<TinesParamsFields
actionParams={{}}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
wrapper
.find('[data-test-subj="tines-storySelector"] [data-test-subj="comboBoxToggleListButton"]')
.first()
.simulate('click');
expect(wrapper.find('[data-test-subj="tines-storySelector-optionsList"]').text()).toContain(
'Published'
);
});
it('should enable with webhook selector when story selected', () => {
const wrapper = mountWithIntl(
<TinesParamsFields
actionParams={{}}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
wrapper
.find('[data-test-subj="tines-storySelector"] [data-test-subj="comboBoxToggleListButton"]')
.first()
.simulate('click');
wrapper
.find('[data-test-subj="tines-storySelector-optionsList"] button')
.first()
.simulate('click');
expect(
wrapper.find('[data-test-subj="tines-webhookSelector"]').first().prop('disabled')
).toBe(false);
expect(wrapper.find('[data-test-subj="tines-webhookSelector"]').first().text()).toBe(
'Select a webhook action'
);
wrapper
.find(
'[data-test-subj="tines-webhookSelector"] [data-test-subj="comboBoxToggleListButton"]'
)
.first()
.simulate('click');
expect(wrapper.find('[data-test-subj="tines-webhookSelector-optionsList"]').text()).toBe(
webhook.name
);
});
it('should set form values when selected', () => {
const wrapper = mountWithIntl(
<TinesParamsFields
actionParams={{}}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
wrapper
.find('[data-test-subj="tines-storySelector"] [data-test-subj="comboBoxToggleListButton"]')
.first()
.simulate('click');
wrapper
.find('[data-test-subj="tines-storySelector-optionsList"] button')
.first()
.simulate('click');
expect(mockEditAction).toHaveBeenCalledWith(
'subActionParams',
{ webhook: { storyId: story.id } },
index
);
wrapper
.find(
'[data-test-subj="tines-webhookSelector"] [data-test-subj="comboBoxToggleListButton"]'
)
.first()
.simulate('click');
wrapper
.find('[data-test-subj="tines-webhookSelector-optionsList"] button')
.first()
.simulate('click');
expect(mockEditAction).toHaveBeenCalledWith('subActionParams', { webhook }, index);
});
it('should render webhook url fallback when response incomplete', () => {
mockUseSubActionStories.mockReturnValueOnce({
isLoading: false,
response: { stories: [story], incompleteResponse: true },
error: null,
});
const wrapper = mountWithIntl(
<TinesParamsFields
actionParams={{}}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
expect(wrapper.find('[data-test-subj="tines-fallbackCallout"]').exists()).toBe(true);
expect(wrapper.find('[data-test-subj="tines-webhookUrlInput"]').exists()).toBe(true);
});
});
describe('Edit connector', () => {
it('should render form values', () => {
const wrapper = mountWithIntl(
<TinesParamsFields
actionParams={actionParams}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
expect(wrapper.find('[data-test-subj="tines-bodyJsonEditor"]').exists()).toBe(false);
expect(wrapper.find('[data-test-subj="tines-storySelector"]').exists()).toBe(true);
expect(wrapper.find('[data-test-subj="tines-storySelector"]').first().text()).toBe(
story.name
);
expect(wrapper.find('[data-test-subj="tines-webhookSelector"]').exists()).toBe(true);
expect(wrapper.find('[data-test-subj="tines-webhookSelector"]').first().text()).toBe(
webhook.name
);
expect(wrapper.find('[data-test-subj="tines-fallbackCallout"]').exists()).toBe(false);
expect(wrapper.find('[data-test-subj="tines-webhookUrlInput"]').exists()).toBe(false);
});
it('should call useSubAction with form values', () => {
mountWithIntl(
<TinesParamsFields
actionParams={actionParams}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
expect(mockUseSubActionStories).toHaveBeenCalledWith(
expect.objectContaining({ subAction: 'stories' })
);
expect(mockUseSubActionWebhooks).toHaveBeenCalledWith(
expect.objectContaining({
subAction: 'webhooks',
subActionParams: { storyId: story.id },
})
);
});
it('should show warning if story not found', () => {
mountWithIntl(
<TinesParamsFields
actionParams={{ subActionParams: { webhook: { ...webhook, storyId: story.id + 1 } } }}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
expect(mockToasts.warning).toHaveBeenCalledWith({
title: 'Cannot find the saved story. Please select a valid story from the selector',
});
});
it('should show warning if webhook not found', () => {
mountWithIntl(
<TinesParamsFields
actionParams={{ subActionParams: { webhook: { ...webhook, id: webhook.id + 1 } } }}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
expect(mockToasts.warning).toHaveBeenCalledWith({
title: 'Cannot find the saved webhook. Please select a valid webhook from the selector',
});
});
describe('WebhookUrl fallback', () => {
beforeEach(() => {
mockUseSubActionStories.mockReturnValue({
isLoading: false,
response: { stories: [story], incompleteResponse: true },
error: null,
});
mockUseSubActionWebhooks.mockReturnValue({
isLoading: false,
response: { webhooks: [webhook], incompleteResponse: true },
error: null,
});
});
it('should not render webhook url fallback when stories response incomplete but selected story found', () => {
const wrapper = mountWithIntl(
<TinesParamsFields
actionParams={actionParams}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
expect(wrapper.find('[data-test-subj="tines-fallbackCallout"]').exists()).toBe(false);
expect(wrapper.find('[data-test-subj="tines-webhookUrlInput"]').exists()).toBe(false);
});
it('should render webhook url fallback when stories response incomplete and selected story not found', () => {
mockUseSubActionStories.mockReturnValue({
isLoading: false,
response: { stories: [], incompleteResponse: true },
error: null,
});
const wrapper = mountWithIntl(
<TinesParamsFields
actionParams={actionParams}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
expect(wrapper.find('[data-test-subj="tines-fallbackCallout"]').exists()).toBe(true);
expect(wrapper.find('[data-test-subj="tines-webhookUrlInput"]').exists()).toBe(true);
});
it('should not render webhook url fallback when webhook response incomplete but webhook selected found', () => {
const wrapper = mountWithIntl(
<TinesParamsFields
actionParams={actionParams}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
expect(wrapper.find('[data-test-subj="tines-fallbackCallout"]').exists()).toBe(false);
expect(wrapper.find('[data-test-subj="tines-webhookUrlInput"]').exists()).toBe(false);
});
it('should render webhook url fallback when webhook response incomplete and webhook selected not found', () => {
mockUseSubActionWebhooks.mockReturnValue({
isLoading: false,
response: { webhooks: [], incompleteResponse: true },
error: null,
});
const wrapper = mountWithIntl(
<TinesParamsFields
actionParams={actionParams}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
expect(wrapper.find('[data-test-subj="tines-fallbackCallout"]').exists()).toBe(true);
expect(wrapper.find('[data-test-subj="tines-webhookUrlInput"]').exists()).toBe(true);
});
it('should render webhook url fallback without callout when responses are complete but webhookUrl is stored', () => {
const webhookUrl = 'https://example.tines.com/1234';
const wrapper = mountWithIntl(
<TinesParamsFields
actionParams={{ subActionParams: { ...actionParams.subActionParams, webhookUrl } }}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
expect(wrapper.find('[data-test-subj="tines-fallbackCallout"]').exists()).toBe(false);
expect(wrapper.find('input[data-test-subj="tines-webhookUrlInput"]').exists()).toBe(true);
expect(wrapper.find('input[data-test-subj="tines-webhookUrlInput"]').prop('value')).toBe(
webhookUrl
);
});
});
describe('subActions error', () => {
it('should show error when stories subAction has error', () => {
const errorMessage = 'something broke';
mockUseSubActionStories.mockReturnValueOnce({
isLoading: false,
response: { stories: [story] },
error: new Error(errorMessage),
});
mountWithIntl(
<TinesParamsFields
actionParams={{}}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
expect(mockToasts.danger).toHaveBeenCalledWith({
title: 'Error retrieving stories from Tines',
body: errorMessage,
});
});
it('should show error when webhooks subAction has error', () => {
const errorMessage = 'something broke';
mockUseSubActionWebhooks.mockReturnValueOnce({
isLoading: false,
response: { webhooks: [webhook] },
error: new Error(errorMessage),
});
mountWithIntl(
<TinesParamsFields
actionParams={{}}
errors={emptyErrors}
editAction={mockEditAction}
index={index}
executionMode={ActionConnectorMode.ActionForm}
/>
);
expect(mockToasts.danger).toHaveBeenCalledWith({
title: 'Error retrieving webhook actions from Tines',
body: errorMessage,
});
});
});
});
});

View file

@ -0,0 +1,329 @@
/*
* 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, { useCallback, useEffect, useMemo, useState } from 'react';
import {
EuiBadge,
EuiCallOut,
EuiComboBox,
EuiComboBoxOptionOption,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiHighlight,
EuiSpacer,
} from '@elastic/eui';
import { ActionConnectorMode, ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public';
import {
JsonEditorWithMessageVariables,
useSubAction,
useKibana,
} from '@kbn/triggers-actions-ui-plugin/public';
import { SUB_ACTION } from '../../../../common/connector_types/security/tines/constants';
import type {
TinesStoryObject,
TinesWebhookObject,
TinesWebhooksActionParams,
TinesStoriesActionResponse,
TinesWebhooksActionResponse,
TinesStoriesActionParams,
} from '../../../../common/connector_types/security/tines/types';
import type { TinesExecuteActionParams, TinesExecuteSubActionParams } from './types';
import * as i18n from './translations';
type StoryOption = EuiComboBoxOptionOption<TinesStoryObject>;
type WebhookOption = EuiComboBoxOptionOption<TinesWebhookObject>;
const createOption = <T extends TinesStoryObject | TinesWebhookObject>(
item: T
): EuiComboBoxOptionOption<T> => ({
key: item.id.toString(),
value: item,
label: item.name,
});
const renderStory = (
{ label, value }: StoryOption,
searchValue: string,
contentClassName: string
) => (
<EuiFlexGroup className={contentClassName} direction="row" alignItems="center">
<EuiFlexItem grow={false}>
<EuiHighlight search={searchValue}>{label}</EuiHighlight>
</EuiFlexItem>
{value?.published && (
<EuiFlexItem grow={false}>
<EuiBadge color="hollow">{i18n.STORY_PUBLISHED_BADGE_TEXT}</EuiBadge>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
const TinesParamsFields: React.FunctionComponent<ActionParamsProps<TinesExecuteActionParams>> = ({
actionConnector,
actionParams,
editAction,
index,
executionMode,
errors,
}) => {
const { toasts } = useKibana().notifications;
const { subAction, subActionParams } = actionParams;
const { body, webhook, webhookUrl } = subActionParams ?? {};
const [connectorId, setConnectorId] = useState<string | undefined>(actionConnector?.id);
const [selectedStoryOption, setSelectedStoryOption] = useState<StoryOption | null | undefined>();
const [selectedWebhookOption, setSelectedWebhookOption] = useState<
WebhookOption | null | undefined
>();
const isTest = useMemo(() => executionMode === ActionConnectorMode.Test, [executionMode]);
useEffect(() => {
if (!subAction) {
editAction('subAction', isTest ? SUB_ACTION.TEST : SUB_ACTION.RUN, index);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isTest, subAction]);
if (connectorId !== actionConnector?.id) {
// Story (and webhook) reset needed before requesting with a different connectorId
setSelectedStoryOption(null);
setConnectorId(actionConnector?.id);
}
const editSubActionParams = useCallback(
(params: TinesExecuteSubActionParams) => {
editAction('subActionParams', { ...subActionParams, ...params }, index);
},
[editAction, index, subActionParams]
);
const {
response: { stories, incompleteResponse: incompleteStories } = {},
isLoading: isLoadingStories,
error: storiesError,
} = useSubAction<TinesStoriesActionParams, TinesStoriesActionResponse>({
connectorId,
subAction: 'stories',
});
const {
response: { webhooks, incompleteResponse: incompleteWebhooks } = {},
isLoading: isLoadingWebhooks,
error: webhooksError,
} = useSubAction<TinesWebhooksActionParams, TinesWebhooksActionResponse>({
connectorId,
subAction: 'webhooks',
...(selectedStoryOption?.value?.id
? { subActionParams: { storyId: selectedStoryOption?.value?.id } }
: { disabled: true }),
});
const storiesOptions = useMemo(() => stories?.map(createOption) ?? [], [stories]);
const webhooksOptions = useMemo(() => webhooks?.map(createOption) ?? [], [webhooks]);
useEffect(() => {
if (storiesError) {
toasts.danger({ title: i18n.STORIES_ERROR, body: storiesError.message });
}
if (webhooksError) {
toasts.danger({ title: i18n.WEBHOOKS_ERROR, body: webhooksError.message });
}
}, [toasts, storiesError, webhooksError]);
const showFallbackFrom = useMemo<'Story' | 'Webhook' | 'any' | null>(() => {
if (incompleteStories && !selectedStoryOption) {
return 'Story';
}
if (incompleteWebhooks && !selectedWebhookOption) {
return 'Webhook';
}
if (webhookUrl) {
return 'any'; // no incompleteResponse but webhookUrl is stored in the connector
}
return null;
}, [
webhookUrl,
incompleteStories,
incompleteWebhooks,
selectedStoryOption,
selectedWebhookOption,
]);
useEffect(() => {
if (selectedStoryOption === undefined && webhook?.storyId && stories) {
// Set the initial selected story option from saved storyId when stories are loaded
const selectedStory = stories.find(({ id }) => id === webhook.storyId);
if (selectedStory) {
setSelectedStoryOption(createOption(selectedStory));
} else {
toasts.warning({ title: i18n.STORY_NOT_FOUND_WARNING });
editSubActionParams({ webhook: undefined });
}
}
if (selectedStoryOption !== undefined && selectedStoryOption?.value?.id !== webhook?.storyId) {
// Selected story changed, update storyId param and remove the rest webhook values
editSubActionParams({ webhook: { storyId: selectedStoryOption?.value?.id } });
// reset selected webhook. Preserve undefined (not edited) to keep selector isInvalid value consistent
setSelectedWebhookOption((current) => (current === undefined ? undefined : null));
}
}, [selectedStoryOption, webhook?.storyId, stories, toasts, editSubActionParams]);
useEffect(() => {
if (selectedWebhookOption === undefined && webhook?.id && webhooks) {
// Set the initial selected webhook option from saved webhookId when webhooks are loaded
const selectedWebhook = webhooks.find(({ id }) => id === webhook.id);
if (selectedWebhook) {
setSelectedWebhookOption(createOption(selectedWebhook));
} else {
toasts.warning({ title: i18n.WEBHOOK_NOT_FOUND_WARNING });
editSubActionParams({ webhook: { storyId: webhook?.storyId } });
}
}
if (selectedWebhookOption !== undefined && selectedWebhookOption?.value?.id !== webhook?.id) {
// Selected webhook changed, update webhook param, preserve storyId if the selected webhook has been reset
editSubActionParams({
webhook: selectedWebhookOption
? selectedWebhookOption.value
: { storyId: webhook?.storyId },
});
}
}, [selectedWebhookOption, webhook, webhooks, toasts, editSubActionParams]);
const selectedStoryOptions = useMemo(
() => (selectedStoryOption ? [selectedStoryOption] : []),
[selectedStoryOption]
);
const selectedWebhookOptions = useMemo(
() => (selectedWebhookOption ? [selectedWebhookOption] : []),
[selectedWebhookOption]
);
const onChangeStory = useCallback(([selected]: StoryOption[]) => {
setSelectedStoryOption(selected ?? null);
}, []);
const onChangeWebhook = useCallback(([selected]: WebhookOption[]) => {
setSelectedWebhookOption(selected ?? null);
}, []);
return (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiFormRow
fullWidth
error={errors.story}
isInvalid={!!errors.story?.length && selectedStoryOption !== undefined}
label={i18n.STORY_LABEL}
helpText={i18n.STORY_HELP}
>
<EuiComboBox
aria-label={i18n.STORY_PLACEHOLDER}
placeholder={
webhookUrl ? i18n.DISABLED_BY_WEBHOOK_URL_PLACEHOLDER : i18n.STORY_ARIA_LABEL
}
singleSelection={{ asPlainText: true }}
options={storiesOptions}
selectedOptions={selectedStoryOptions}
onChange={onChangeStory}
isDisabled={isLoadingStories || !!webhookUrl}
isLoading={isLoadingStories}
renderOption={renderStory}
fullWidth
data-test-subj="tines-storySelector"
/>
</EuiFormRow>
<EuiFormRow
fullWidth
error={errors.webhook}
isInvalid={!!errors.webhook?.length && selectedWebhookOption !== undefined}
label={i18n.WEBHOOK_LABEL}
helpText={i18n.WEBHOOK_HELP}
>
<EuiComboBox
aria-label={i18n.WEBHOOK_ARIA_LABEL}
placeholder={
webhookUrl
? i18n.DISABLED_BY_WEBHOOK_URL_PLACEHOLDER
: selectedStoryOption
? i18n.WEBHOOK_PLACEHOLDER
: i18n.WEBHOOK_DISABLED_PLACEHOLDER
}
singleSelection={{ asPlainText: true }}
options={webhooksOptions}
selectedOptions={selectedWebhookOptions}
onChange={onChangeWebhook}
isDisabled={!selectedStoryOption || isLoadingWebhooks || !!webhookUrl}
isLoading={isLoadingWebhooks}
fullWidth
data-test-subj="tines-webhookSelector"
/>
</EuiFormRow>
</EuiFlexItem>
{showFallbackFrom != null && (
<EuiFlexItem>
{showFallbackFrom !== 'any' && (
<>
<EuiCallOut
title={i18n.WEBHOOK_URL_FALLBACK_TITLE}
color="primary"
data-test-subj="tines-fallbackCallout"
>
{i18n.WEBHOOK_URL_FALLBACK_TEXT(showFallbackFrom)}
</EuiCallOut>
<EuiSpacer size="s" />
</>
)}
<EuiFormRow
fullWidth
error={errors.webhookUrl}
isInvalid={!!errors.webhookUrl?.length}
label={i18n.WEBHOOK_URL_LABEL}
helpText={i18n.WEBHOOK_URL_HELP}
>
<EuiFieldText
placeholder={i18n.WEBHOOK_URL_PLACEHOLDER}
value={webhookUrl}
onChange={(ev) => {
editSubActionParams({ webhookUrl: ev.target.value });
}}
fullWidth
data-test-subj="tines-webhookUrlInput"
/>
</EuiFormRow>
</EuiFlexItem>
)}
{isTest && (
<EuiFlexItem>
<JsonEditorWithMessageVariables
paramsProperty={'body'}
inputTargetValue={body}
label={i18n.BODY_LABEL}
aria-label={i18n.BODY_ARIA_LABEL}
errors={errors.body as string[]}
onDocumentsChange={(json: string) => {
editSubActionParams({ body: json });
}}
onBlur={() => {
if (!body) {
editSubActionParams({ body: '' });
}
}}
data-test-subj="tines-bodyJsonEditor"
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};
// eslint-disable-next-line import/no-default-export
export { TinesParamsFields as default };

View file

@ -0,0 +1,262 @@
/*
* 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 { API_MAX_RESULTS } from '../../../../common/connector_types/security/tines/constants';
// config form
export const URL_LABEL = i18n.translate(
'xpack.stackConnectors.security.tines.config.urlTextFieldLabel',
{
defaultMessage: 'Tines tenant URL',
}
);
export const AUTHENTICATION_TITLE = i18n.translate(
'xpack.stackConnectors.security.tines.config.authenticationTitle',
{
defaultMessage: 'Authentication',
}
);
export const EMAIL_LABEL = i18n.translate(
'xpack.stackConnectors.security.tines.config.emailTextFieldLabel',
{
defaultMessage: 'Email',
}
);
export const TOKEN_LABEL = i18n.translate(
'xpack.stackConnectors.security.tines.config.tokenTextFieldLabel',
{
defaultMessage: 'API token',
}
);
export const URL_INVALID = i18n.translate(
'xpack.stackConnectors.security.tines.config.error.invalidUrlTextField',
{
defaultMessage: 'Tenant URL is invalid.',
}
);
export const EMAIL_REQUIRED = i18n.translate(
'xpack.stackConnectors.security.tines.config.error.requiredEmailText',
{
defaultMessage: 'Email is required.',
}
);
export const TOKEN_REQUIRED = i18n.translate(
'xpack.stackConnectors.security.tines.config.error.requiredAuthTokenText',
{
defaultMessage: 'Auth token is required.',
}
);
// params form
export const STORY_LABEL = i18n.translate(
'xpack.stackConnectors.security.tines.params.storyFieldLabel',
{
defaultMessage: 'Tines Story',
}
);
export const STORY_HELP = i18n.translate('xpack.stackConnectors.security.tines.params.storyHelp', {
defaultMessage: 'The Tines story to send the events to',
});
export const STORY_PLACEHOLDER = i18n.translate(
'xpack.stackConnectors.security.tines.params.storyPlaceholder',
{
defaultMessage: 'Select a story',
}
);
export const STORY_ARIA_LABEL = i18n.translate(
'xpack.stackConnectors.security.tines.params.storyFieldAriaLabel',
{
defaultMessage: 'Select a Tines story',
}
);
export const STORY_PUBLISHED_BADGE_TEXT = i18n.translate(
'xpack.stackConnectors.security.tines.params.storyPublishedBadgeText',
{
defaultMessage: 'Published',
}
);
export const WEBHOOK_LABEL = i18n.translate(
'xpack.stackConnectors.security.tines.params.webhookFieldLabel',
{
defaultMessage: 'Tines Webhook action',
}
);
export const WEBHOOK_HELP = i18n.translate(
'xpack.stackConnectors.security.tines.params.webhookHelp',
{
defaultMessage: 'The data entry action in the story',
}
);
export const WEBHOOK_PLACEHOLDER = i18n.translate(
'xpack.stackConnectors.security.tines.params.webhookPlaceholder',
{
defaultMessage: 'Select a webhook action',
}
);
export const WEBHOOK_DISABLED_PLACEHOLDER = i18n.translate(
'xpack.stackConnectors.security.tines.params.webhookDisabledPlaceholder',
{
defaultMessage: 'Select a story first',
}
);
export const WEBHOOK_ARIA_LABEL = i18n.translate(
'xpack.stackConnectors.security.tines.params.webhookFieldAriaLabel',
{
defaultMessage: 'Select a Tines webhook action',
}
);
export const WEBHOOK_URL_LABEL = i18n.translate(
'xpack.stackConnectors.security.tines.params.webhookUrlFieldLabel',
{
defaultMessage: 'Webhook URL',
}
);
export const WEBHOOK_URL_FALLBACK_TITLE = i18n.translate(
'xpack.stackConnectors.security.tines.params.webhookUrlFallbackTitle',
{
defaultMessage: 'Tines API results limit reached',
}
);
export const WEBHOOK_URL_FALLBACK_TEXT = (entity: 'Story' | 'Webhook') =>
i18n.translate('xpack.stackConnectors.security.tines.params.webhookUrlFallbackText', {
values: { entity, limit: API_MAX_RESULTS },
defaultMessage: `Not possible to retrieve more than {limit} results from the Tines {entity} API. If your {entity} does not appear in the list, please fill the Webhook URL below`,
});
export const WEBHOOK_URL_HELP = i18n.translate(
'xpack.stackConnectors.security.tines.params.webhookUrlHelp',
{
defaultMessage: 'The Story and Webhook selectors will be ignored if the Webhook URL is defined',
}
);
export const WEBHOOK_URL_PLACEHOLDER = i18n.translate(
'xpack.stackConnectors.security.tines.params.webhookUrlPlaceholder',
{
defaultMessage: 'Paste the Webhook URL here',
}
);
export const DISABLED_BY_WEBHOOK_URL_PLACEHOLDER = i18n.translate(
'xpack.stackConnectors.security.tines.params.disabledByWebhookUrlPlaceholder',
{
defaultMessage: 'Remove the Webhook URL to use this selector',
}
);
export const BODY_LABEL = i18n.translate(
'xpack.stackConnectors.security.tines.params.bodyFieldLabel',
{
defaultMessage: 'Body',
}
);
export const BODY_ARIA_LABEL = i18n.translate(
'xpack.stackConnectors.security.tines.params.bodyFieldAriaLabel',
{
defaultMessage: 'Request body payload',
}
);
export const STORIES_ERROR = i18n.translate(
'xpack.stackConnectors.security.tines.params.componentError.storiesRequestFailed',
{
defaultMessage: 'Error retrieving stories from Tines',
}
);
export const WEBHOOKS_ERROR = i18n.translate(
'xpack.stackConnectors.security.tines.params.componentError.webhooksRequestFailed',
{
defaultMessage: 'Error retrieving webhook actions from Tines',
}
);
export const STORY_NOT_FOUND_WARNING = i18n.translate(
'xpack.stackConnectors.security.tines.params.componentWarning.storyNotFound',
{
defaultMessage: 'Cannot find the saved story. Please select a valid story from the selector',
}
);
export const WEBHOOK_NOT_FOUND_WARNING = i18n.translate(
'xpack.stackConnectors.security.tines.params.componentWarning.webhookNotFound',
{
defaultMessage:
'Cannot find the saved webhook. Please select a valid webhook from the selector',
}
);
export const ACTION_REQUIRED = i18n.translate(
'xpack.stackConnectors.security.tines.params.error.requiredActionText',
{
defaultMessage: 'Action is required.',
}
);
export const INVALID_ACTION = i18n.translate(
'xpack.stackConnectors.security.tines.params.error.invalidActionText',
{
defaultMessage: 'Invalid action name.',
}
);
export const BODY_REQUIRED = i18n.translate(
'xpack.stackConnectors.security.tines.params.error.requiredBodyText',
{
defaultMessage: 'Body is required.',
}
);
export const BODY_INVALID = i18n.translate(
'xpack.stackConnectors.security.tines.params.error.invalidBodyText',
{
defaultMessage: 'Body does not have a valid JSON format.',
}
);
export const STORY_REQUIRED = i18n.translate(
'xpack.stackConnectors.security.tines.params.error.requiredStoryText',
{
defaultMessage: 'Story is required.',
}
);
export const WEBHOOK_REQUIRED = i18n.translate(
'xpack.stackConnectors.security.tines.params.error.requiredWebhookText',
{
defaultMessage: 'Webhook is required.',
}
);
export const WEBHOOK_PATH_REQUIRED = i18n.translate(
'xpack.stackConnectors.security.tines.params.error.requiredWebhookPathText',
{
defaultMessage: 'Webhook action path is missing.',
}
);
export const WEBHOOK_SECRET_REQUIRED = i18n.translate(
'xpack.stackConnectors.security.tines.params.error.requiredWebhookSecretText',
{
defaultMessage: 'Webhook action secret is missing.',
}
);
export const INVALID_WEBHOOK_URL = i18n.translate(
'xpack.stackConnectors.security.tines.params.error.invalidWebhookUrlText',
{
defaultMessage: 'Webhook URL is invalid.',
}
);
export const INVALID_HOSTNAME_WEBHOOK_URL = i18n.translate(
'xpack.stackConnectors.security.tines.params.error.invalidHostnameWebhookUrlText',
{
defaultMessage: 'Webhook URL does not have a valid ".tines.com" domain.',
}
);
export const INVALID_PROTOCOL_WEBHOOK_URL = i18n.translate(
'xpack.stackConnectors.security.tines.params.error.invalidProtocolWebhookUrlText',
{
defaultMessage: 'Webhook URL does not have a valid "https" protocol.',
}
);

View file

@ -0,0 +1,18 @@
/*
* 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 type { TinesRunActionParams } from '../../../../common/connector_types/security/tines/types';
import type { SUB_ACTION } from '../../../../common/connector_types/security/tines/constants';
export type TinesExecuteSubActionParams = Omit<Partial<TinesRunActionParams>, 'webhook'> & {
webhook?: Partial<TinesRunActionParams['webhook']>;
};
export interface TinesExecuteActionParams {
subAction: SUB_ACTION.RUN | SUB_ACTION.TEST;
subActionParams: TinesExecuteSubActionParams;
}

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { EuiIconProps } from '@elastic/eui';
export type LogoProps = Omit<EuiIconProps, 'type'>;

View file

@ -26,6 +26,8 @@ import {
getServiceNowSIRConnectorType,
getSwimlaneConnectorType,
} from './cases';
import { getTinesConnectorType } from './security';
export type {
EmailActionParams,
IndexActionParams,
@ -83,5 +85,7 @@ export function registerConnectorTypes({
actions.registerType(getJiraConnectorType());
actions.registerType(getResilientConnectorType());
actions.registerType(getTeamsConnectorType());
actions.registerSubActionConnectorType(getOpsgenieConnectorType());
actions.registerSubActionConnectorType(getTinesConnectorType());
}

View file

@ -4,3 +4,5 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { getTinesConnectorType } from './tines';

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 { schema, TypeOf } from '@kbn/config-schema';
import { TinesStoryObjectSchema } from '../../../../common/connector_types/security/tines/schema';
// Tines response base schema
export const TinesBaseApiResponseSchema = schema.object(
{
meta: schema.object(
{
pages: schema.number(),
},
{ unknowns: 'ignore' }
),
},
{ unknowns: 'ignore' }
);
// Stories action schema
export const TinesStoriesApiResponseSchema = TinesBaseApiResponseSchema.extends(
{
stories: schema.arrayOf(TinesStoryObjectSchema.extends({}, { unknowns: 'ignore' })),
},
{ unknowns: 'ignore' }
);
// Webhooks action schema
export const TinesWebhooksApiResponseSchema = TinesBaseApiResponseSchema.extends(
{
agents: schema.arrayOf(
schema.object(
{
id: schema.number(),
name: schema.string(),
type: schema.string(),
story_id: schema.number(),
options: schema.object(
{
path: schema.maybe(schema.string()),
secret: schema.maybe(schema.string()),
},
{ unknowns: 'ignore' }
),
},
{ unknowns: 'ignore' }
)
),
},
{ unknowns: 'ignore' }
);
export const TinesRunApiResponseSchema = schema.object({}, { unknowns: 'ignore' });
export type TinesBaseApiResponse = TypeOf<typeof TinesBaseApiResponseSchema>;
export type TinesStoriesApiResponse = TypeOf<typeof TinesStoriesApiResponseSchema>;
export type TinesWebhooksApiResponse = TypeOf<typeof TinesWebhooksApiResponseSchema>;
export type TinesRunApiResponse = TypeOf<typeof TinesRunApiResponseSchema>;

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
SubActionConnectorType,
ValidatorType,
} from '@kbn/actions-plugin/server/sub_action_framework/types';
import { SecurityConnectorFeatureId } from '@kbn/actions-plugin/common';
import { urlAllowListValidator } from '@kbn/actions-plugin/server';
import {
TINES_CONNECTOR_ID,
TINES_TITLE,
} from '../../../../common/connector_types/security/tines/constants';
import {
TinesConfigSchema,
TinesSecretsSchema,
} from '../../../../common/connector_types/security/tines/schema';
import { TinesConfig, TinesSecrets } from '../../../../common/connector_types/security/tines/types';
import { TinesConnector } from './tines';
import { renderParameterTemplates } from './render';
export const getTinesConnectorType = (): SubActionConnectorType<TinesConfig, TinesSecrets> => ({
id: TINES_CONNECTOR_ID,
name: TINES_TITLE,
Service: TinesConnector,
schema: {
config: TinesConfigSchema,
secrets: TinesSecretsSchema,
},
validators: [{ type: ValidatorType.CONFIG, validator: urlAllowListValidator('url') }],
supportedFeatureIds: [SecurityConnectorFeatureId],
minimumLicenseRequired: 'gold' as const,
renderParameterTemplates,
});

View file

@ -0,0 +1,105 @@
/*
* 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 { set } from 'lodash/fp';
import { renderParameterTemplates } from './render';
const params = {
subAction: 'run',
subActionParams: {
webhook: {},
},
};
const alert1Expected = {
_id: 'l8jb2e55f2740ab15ba4e2717a96c93396c4ce323ac7a486c7dc179d67rg3ft',
_index: '.internal.alerts-security.alerts-default-000001',
host: { ip: ['10.252.97.126'], name: 'Host-dbzugdlqdn' },
user: { domain: 'm0zepcuuu2', name: 'gyd31qs02v' },
'@timestamp': '2022-09-30T13:56:38.314Z',
};
const alert2Expected = {
...alert1Expected,
_id: 'b02bc8e55f2740ab15ba4e2717a96c93396ccce323ac7a486c7dc179d67b3a2f',
};
const alert1 = {
...alert1Expected,
kibana: {
version: '8.5.0',
space_ids: ['default'],
alert: {
workflow_status: 'open',
},
},
};
const alert2 = {
...alert1,
_id: alert2Expected._id,
};
const variables = {
alertId: 'b02e31f0-336e-11ed-9f07-a9a06b00ec20',
alertName: 'testRule',
spaceId: 'default',
context: {
alerts: [alert1, alert2],
rule: {
description: 'test rule',
rule_id: '27eca842-d8c2-48f3-a1de-3173310b3d90',
},
},
};
describe('Tines body render', () => {
describe('renderParameterTemplates', () => {
it('should not render body on test action', () => {
const testParams = { subAction: 'test', subActionParams: { body: 'test_json' } };
const result = renderParameterTemplates(testParams, variables);
expect(result).toEqual(testParams);
});
it('should rendered body from variables with cleaned alerts on run action', () => {
const result = renderParameterTemplates(params, variables);
expect(result.subActionParams.body).toEqual(
JSON.stringify({
...variables,
context: {
...variables.context,
alerts: [alert1Expected, alert2Expected],
},
})
);
});
it('should rendered body from variables on run action without context.alerts', () => {
const variablesWithoutAlerts = set('context.alerts', undefined, variables);
const result = renderParameterTemplates(params, variablesWithoutAlerts);
expect(result.subActionParams.body).toEqual(JSON.stringify(variablesWithoutAlerts));
});
it('should rendered body from variables on run action without context', () => {
const variablesWithoutContext = set('context', undefined, variables);
const result = renderParameterTemplates(params, variablesWithoutContext);
expect(result.subActionParams.body).toEqual(JSON.stringify(variablesWithoutContext));
});
it('should render error body', () => {
const errorMessage = 'test error';
jest.spyOn(JSON, 'stringify').mockImplementationOnce(() => {
throw new Error(errorMessage);
});
const result = renderParameterTemplates(params, variables);
expect(result.subActionParams.body).toEqual(
JSON.stringify({ error: { message: errorMessage } })
);
});
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { set } from 'lodash/fp';
import { ExecutorParams } from '@kbn/actions-plugin/server/sub_action_framework/types';
import { RenderParameterTemplates } from '@kbn/actions-plugin/server/types';
import { SUB_ACTION } from '../../../../common/connector_types/security/tines/constants';
interface Context {
alerts: Array<Record<string, unknown>>;
}
export const renderParameterTemplates: RenderParameterTemplates<ExecutorParams> = (
params,
variables
) => {
if (params?.subAction !== SUB_ACTION.RUN) return params;
let body: string;
try {
let bodyObject;
const alerts = (variables?.context as Context)?.alerts;
if (alerts) {
// Remove the "kibana" entry from all alerts to reduce weight, the same data can be found in other parts of the alert object.
bodyObject = set(
'context.alerts',
alerts.map(({ kibana, ...alert }) => alert),
variables
);
} else {
bodyObject = variables;
}
body = JSON.stringify(bodyObject);
} catch (err) {
body = JSON.stringify({ error: { message: err.message } });
}
return set('subActionParams.body', body, params);
};

View file

@ -0,0 +1,221 @@
/*
* 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, { AxiosInstance } from 'axios';
import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock';
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import { TinesConnector } from './tines';
import { request } from '@kbn/actions-plugin/server/lib/axios_utils';
import {
API_MAX_RESULTS,
TINES_CONNECTOR_ID,
} from '../../../../common/connector_types/security/tines/constants';
jest.mock('axios');
(axios as jest.Mocked<typeof axios>).create.mockImplementation(
() => jest.fn() as unknown as AxiosInstance
);
jest.mock('@kbn/actions-plugin/server/lib/axios_utils');
const mockRequest = request as jest.Mock;
const url = 'https://example.com';
const email = 'some.email@test.com';
const token = '123';
const story = {
id: 97469,
name: 'Test story',
published: true,
team_id: 1234, // just to make sure it is cleaned
};
const storyResult = {
id: story.id,
name: story.name,
published: story.published,
};
const otherAgent = {
id: 941613,
name: 'HTTP Req. Action',
type: 'Agents::HTTPRequestAgent',
story_id: 97469,
options: {},
};
const webhookAgent = {
...otherAgent,
name: 'Elastic Security Webhook',
type: 'Agents::WebhookAgent',
options: {
path: '18f15eaaae93111d3187af42d236c8b2',
secret: 'eb80106acb3ee1521985f5cec3dd224c',
},
};
const webhookResult = {
id: webhookAgent.id,
name: webhookAgent.name,
storyId: webhookAgent.story_id,
path: webhookAgent.options.path,
secret: webhookAgent.options.secret,
};
const webhookUrl = `${url}/webhook/${webhookAgent.options.path}/${webhookAgent.options.secret}`;
const ignoredRequestFields = {
axios: expect.anything(),
configurationUtilities: expect.anything(),
logger: expect.anything(),
};
const storiesGetRequestExpected = {
...ignoredRequestFields,
method: 'get',
data: {},
url: `${url}/api/v1/stories`,
headers: {
'x-user-email': email,
'x-user-token': token,
'Content-Type': 'application/json',
},
params: { per_page: API_MAX_RESULTS },
};
const agentsGetRequestExpected = {
...ignoredRequestFields,
method: 'get',
data: {},
url: `${url}/api/v1/agents`,
headers: {
'x-user-email': email,
'x-user-token': token,
'Content-Type': 'application/json',
},
params: { story_id: story.id, per_page: API_MAX_RESULTS },
};
describe('TinesConnector', () => {
const connector = new TinesConnector({
configurationUtilities: actionsConfigMock.create(),
config: { url },
connector: { id: '1', type: TINES_CONNECTOR_ID },
secrets: { email, token },
logger: loggingSystemMock.createLogger(),
services: actionsMock.createServices(),
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('getStories', () => {
beforeAll(() => {
mockRequest.mockReturnValue({ data: { stories: [story], meta: { pages: 1 } } });
});
it('should request Tines stories', async () => {
await connector.getStories();
expect(mockRequest).toBeCalledTimes(1);
expect(mockRequest).toHaveBeenCalledWith(storiesGetRequestExpected);
});
it('should return the Tines stories reduced array', async () => {
const { stories } = await connector.getStories();
expect(stories).toEqual([storyResult]);
});
it('should request the Tines stories complete response', async () => {
mockRequest.mockReturnValueOnce({
data: { stories: [story], meta: { pages: 1 } },
});
const response = await connector.getStories();
expect(response.incompleteResponse).toEqual(false);
});
it('should request the Tines stories incomplete response', async () => {
mockRequest.mockReturnValueOnce({
data: { stories: [story], meta: { pages: 2 } },
});
const response = await connector.getStories();
expect(response.incompleteResponse).toEqual(true);
});
});
describe('getWebhooks', () => {
beforeAll(() => {
mockRequest.mockReturnValue({ data: { agents: [webhookAgent], meta: { pages: 1 } } });
});
it('should request Tines webhook actions', async () => {
await connector.getWebhooks({ storyId: story.id });
expect(mockRequest).toBeCalledTimes(1);
expect(mockRequest).toHaveBeenCalledWith(agentsGetRequestExpected);
});
it('should return the Tines webhooks reduced array', async () => {
const { webhooks } = await connector.getWebhooks({ storyId: story.id });
expect(webhooks).toEqual([webhookResult]);
});
it('should request the Tines webhook complete response', async () => {
mockRequest.mockReturnValueOnce({
data: { agents: [webhookAgent], meta: { pages: 1 } },
});
const response = await connector.getWebhooks({ storyId: story.id });
expect(response.incompleteResponse).toEqual(false);
});
it('should request the Tines webhook incomplete response', async () => {
mockRequest.mockReturnValueOnce({
data: { agents: [webhookAgent], meta: { pages: 2 } },
});
const response = await connector.getWebhooks({ storyId: story.id });
expect(response.incompleteResponse).toEqual(true);
});
});
describe('runWebhook', () => {
beforeAll(() => {
mockRequest.mockReturnValue({ data: { took: 5, requestId: '123', status: 'ok' } });
});
it('should send data to Tines webhook using selected webhook parameter', async () => {
await connector.runWebhook({
webhook: webhookResult,
body: '[]',
});
expect(mockRequest).toBeCalledTimes(1);
expect(mockRequest).toHaveBeenCalledWith({
...ignoredRequestFields,
method: 'post',
data: '[]',
url: webhookUrl,
headers: {
'Content-Type': 'application/json',
},
});
});
it('should send data to Tines webhook using webhook url parameter', async () => {
await connector.runWebhook({
webhookUrl,
body: '[]',
});
expect(mockRequest).toBeCalledTimes(1);
expect(mockRequest).toHaveBeenCalledWith({
...ignoredRequestFields,
method: 'post',
data: '[]',
url: webhookUrl,
headers: {
'Content-Type': 'application/json',
},
});
});
});
});

View file

@ -0,0 +1,177 @@
/*
* 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 { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server';
import type { AxiosError } from 'axios';
import { SubActionRequestParams } from '@kbn/actions-plugin/server/sub_action_framework/types';
import {
TinesStoriesActionParamsSchema,
TinesWebhooksActionParamsSchema,
TinesRunActionParamsSchema,
} from '../../../../common/connector_types/security/tines/schema';
import type {
TinesConfig,
TinesSecrets,
TinesRunActionParams,
TinesRunActionResponse,
TinesStoriesActionResponse,
TinesWebhooksActionParams,
TinesWebhooksActionResponse,
TinesWebhookObject,
TinesStoryObject,
} from '../../../../common/connector_types/security/tines/types';
import {
TinesStoriesApiResponseSchema,
TinesWebhooksApiResponseSchema,
TinesRunApiResponseSchema,
} from './api_schema';
import type {
TinesBaseApiResponse,
TinesStoriesApiResponse,
TinesWebhooksApiResponse,
} from './api_schema';
import {
API_MAX_RESULTS,
SUB_ACTION,
} from '../../../../common/connector_types/security/tines/constants';
export const API_PATH = '/api/v1';
export const WEBHOOK_PATH = '/webhook';
export const WEBHOOK_AGENT_TYPE = 'Agents::WebhookAgent';
const storiesReducer = ({ stories }: TinesStoriesApiResponse) => ({
stories: stories.map<TinesStoryObject>(({ id, name, published }) => ({ id, name, published })),
});
const webhooksReducer = ({ agents }: TinesWebhooksApiResponse) => ({
webhooks: agents.reduce<TinesWebhookObject[]>(
(webhooks, { id, type, name, story_id: storyId, options: { path = '', secret = '' } }) => {
if (type === WEBHOOK_AGENT_TYPE) {
webhooks.push({ id, name, path, secret, storyId });
}
return webhooks;
},
[]
),
});
export class TinesConnector extends SubActionConnector<TinesConfig, TinesSecrets> {
private urls: {
stories: string;
agents: string;
getRunWebhookURL: (webhook: TinesWebhookObject) => string;
};
constructor(params: ServiceParams<TinesConfig, TinesSecrets>) {
super(params);
this.urls = {
stories: `${this.config.url}${API_PATH}/stories`,
agents: `${this.config.url}${API_PATH}/agents`,
getRunWebhookURL: (webhook) =>
`${this.config.url}${WEBHOOK_PATH}/${webhook.path}/${webhook.secret}`,
};
this.registerSubActions();
}
private registerSubActions() {
this.registerSubAction({
name: SUB_ACTION.STORIES,
method: 'getStories',
schema: TinesStoriesActionParamsSchema,
});
this.registerSubAction({
name: SUB_ACTION.WEBHOOKS,
method: 'getWebhooks',
schema: TinesWebhooksActionParamsSchema,
});
this.registerSubAction({
name: SUB_ACTION.RUN,
method: 'runWebhook',
schema: TinesRunActionParamsSchema,
});
this.registerSubAction({
name: SUB_ACTION.TEST,
method: 'runWebhook',
schema: TinesRunActionParamsSchema,
});
}
private getAuthHeaders() {
return { 'x-user-email': this.secrets.email, 'x-user-token': this.secrets.token };
}
private async tinesApiRequest<R extends TinesBaseApiResponse, T>(
req: SubActionRequestParams<R>,
reducer: (response: R) => T
): Promise<T & { incompleteResponse: boolean }> {
const response = await this.request<R>({
...req,
params: { ...req.params, per_page: API_MAX_RESULTS },
});
return {
...reducer(response.data),
incompleteResponse: response.data.meta.pages > 1,
};
}
protected getResponseErrorMessage(error: AxiosError): string {
if (!error.response?.status) {
return 'Unknown API Error';
}
if (error.response.status === 401) {
return 'Unauthorized API Error';
}
return `API Error: ${error.response?.statusText}`;
}
public async getStories(): Promise<TinesStoriesActionResponse> {
return this.tinesApiRequest(
{
url: this.urls.stories,
headers: this.getAuthHeaders(),
responseSchema: TinesStoriesApiResponseSchema,
},
storiesReducer
);
}
public async getWebhooks({
storyId,
}: TinesWebhooksActionParams): Promise<TinesWebhooksActionResponse> {
return this.tinesApiRequest(
{
url: this.urls.agents,
params: { story_id: storyId },
headers: this.getAuthHeaders(),
responseSchema: TinesWebhooksApiResponseSchema,
},
webhooksReducer
);
}
public async runWebhook({
webhook,
webhookUrl,
body,
}: TinesRunActionParams): Promise<TinesRunActionResponse> {
if (!webhook && !webhookUrl) {
throw Error('Invalid subActionsParams: [webhook] or [webhookUrl] expected but got none');
}
const response = await this.request({
url: webhookUrl ? webhookUrl : this.urls.getRunWebhookURL(webhook!),
method: 'post',
responseSchema: TinesRunApiResponseSchema,
data: body,
});
return response.data;
}
}

View file

@ -0,0 +1,9 @@
/*
* 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 { useSubAction } from './use_sub_action';
export { useLoadRuleTypes } from './use_load_rule_types';

View file

@ -7,21 +7,21 @@
import { act, renderHook } from '@testing-library/react-hooks';
import { useKibana } from '../../common/lib/kibana';
import { useSubAction } from './use_sub_action';
import { useSubAction, UseSubActionParams } from './use_sub_action';
jest.mock('../../common/lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
describe('useSubAction', () => {
const params = {
const params: UseSubActionParams<unknown> = {
connectorId: 'test-id',
subAction: 'test',
subActionParams: {},
subActionParams: { foo: 'bar' },
};
useKibanaMock().services.http.post = jest
const mockHttpPost = (useKibanaMock().services.http.post = jest
.fn()
.mockImplementation(() => Promise.resolve({ status: 'ok', data: {} }));
.mockImplementation(() => Promise.resolve({ status: 'ok', data: {} })));
let abortSpy = jest.spyOn(window, 'AbortController');
beforeEach(() => {
@ -44,41 +44,38 @@ describe('useSubAction', () => {
const { waitForNextUpdate } = renderHook(() => useSubAction(params));
await waitForNextUpdate();
expect(useKibanaMock().services.http.post).toHaveBeenCalledWith(
'/api/actions/connector/test-id/_execute',
{
body: '{"params":{"subAction":"test","subActionParams":{}}}',
signal: new AbortController().signal,
}
);
expect(mockHttpPost).toHaveBeenCalledWith('/api/actions/connector/test-id/_execute', {
body: '{"params":{"subAction":"test","subActionParams":{"foo":"bar"}}}',
signal: new AbortController().signal,
});
});
it('executes sub action if subAction parameter changes', async () => {
const { rerender, waitForNextUpdate } = renderHook(useSubAction, { initialProps: params });
await waitForNextUpdate();
expect(useKibanaMock().services.http.post).toHaveBeenCalledTimes(1);
expect(mockHttpPost).toHaveBeenCalledTimes(1);
await act(async () => {
rerender({ ...params, subAction: 'test-2' });
await waitForNextUpdate();
});
expect(useKibanaMock().services.http.post).toHaveBeenCalledTimes(2);
expect(mockHttpPost).toHaveBeenCalledTimes(2);
});
it('executes sub action if connectorId parameter changes', async () => {
const { rerender, waitForNextUpdate } = renderHook(useSubAction, { initialProps: params });
await waitForNextUpdate();
expect(useKibanaMock().services.http.post).toHaveBeenCalledTimes(1);
expect(mockHttpPost).toHaveBeenCalledTimes(1);
await act(async () => {
rerender({ ...params, connectorId: 'test-id-2' });
await waitForNextUpdate();
});
expect(useKibanaMock().services.http.post).toHaveBeenCalledTimes(2);
expect(mockHttpPost).toHaveBeenCalledTimes(2);
});
it('returns memoized response if subActionParams changes but values are equal', async () => {
@ -87,7 +84,7 @@ describe('useSubAction', () => {
});
await waitForNextUpdate();
expect(useKibanaMock().services.http.post).toHaveBeenCalledTimes(1);
expect(mockHttpPost).toHaveBeenCalledTimes(1);
const previous = result.current;
await act(async () => {
@ -96,7 +93,7 @@ describe('useSubAction', () => {
});
expect(result.current.response).toBe(previous.response);
expect(useKibanaMock().services.http.post).toHaveBeenCalledTimes(1);
expect(mockHttpPost).toHaveBeenCalledTimes(1);
});
it('executes sub action if subActionParams changes and values are not equal', async () => {
@ -105,7 +102,7 @@ describe('useSubAction', () => {
});
await waitForNextUpdate();
expect(useKibanaMock().services.http.post).toHaveBeenCalledTimes(1);
expect(mockHttpPost).toHaveBeenCalledTimes(1);
const previous = result.current;
await act(async () => {
@ -114,12 +111,12 @@ describe('useSubAction', () => {
});
expect(result.current.response).not.toBe(previous.response);
expect(useKibanaMock().services.http.post).toHaveBeenCalledTimes(2);
expect(mockHttpPost).toHaveBeenCalledTimes(2);
});
it('returns an error correctly', async () => {
const error = new Error('error executing');
useKibanaMock().services.http.post = jest.fn().mockRejectedValueOnce(error);
mockHttpPost.mockRejectedValueOnce(error);
const { result, waitForNextUpdate } = renderHook(() => useSubAction(params));
await waitForNextUpdate();
@ -131,27 +128,37 @@ describe('useSubAction', () => {
});
});
it('should not set error if aborted', async () => {
const firstAbortCtrl = new AbortController();
firstAbortCtrl.abort();
abortSpy = jest.spyOn(window, 'AbortController').mockReturnValueOnce(firstAbortCtrl);
const error = new Error('error executing');
useKibanaMock().services.http.post = jest.fn().mockRejectedValueOnce(error);
const { result } = renderHook(() => useSubAction(params));
expect(result.current.error).toBe(null);
});
it('should abort on unmount', async () => {
const firstAbortCtrl = new AbortController();
abortSpy = jest.spyOn(window, 'AbortController').mockReturnValueOnce(firstAbortCtrl);
const { unmount } = renderHook(useSubAction, { initialProps: params });
const { unmount, result } = renderHook(useSubAction, { initialProps: params });
unmount();
expect(result.current.error).toEqual(null);
expect(firstAbortCtrl.signal.aborted).toEqual(true);
});
it('should abort on disabled change', async () => {
const firstAbortCtrl = new AbortController();
abortSpy = jest.spyOn(window, 'AbortController').mockImplementation(() => {
abortSpy.mockRestore();
return firstAbortCtrl;
});
mockHttpPost.mockImplementationOnce(
() => new Promise((resolve) => setTimeout(() => resolve({ status: 'ok', data: {} }), 0))
);
const { result, rerender } = renderHook(useSubAction, {
initialProps: params,
});
expect(result.current.isLoading).toEqual(true);
rerender({ ...params, disabled: true });
expect(result.current.isLoading).toEqual(false);
expect(result.current.error).toEqual(null);
expect(firstAbortCtrl.signal.aborted).toEqual(true);
});
@ -161,20 +168,30 @@ describe('useSubAction', () => {
abortSpy.mockRestore();
return firstAbortCtrl;
});
const { rerender } = renderHook(useSubAction, { initialProps: params });
await act(async () => {
rerender({ ...params, connectorId: 'test-id-2' });
mockHttpPost.mockImplementationOnce(
() => new Promise((resolve) => setTimeout(() => resolve({ status: 'ok', data: {} }), 1))
);
const { result, rerender } = renderHook(useSubAction, {
initialProps: params,
});
expect(result.current.isLoading).toEqual(true);
expect(result.current.error).toEqual(null);
mockHttpPost.mockImplementationOnce(
() => new Promise((resolve) => setTimeout(() => resolve({ status: 'ok', data: {} }), 1))
);
rerender({ ...params, connectorId: 'test-id-2' });
expect(result.current.isLoading).toEqual(true);
expect(result.current.error).toEqual(null);
expect(firstAbortCtrl.signal.aborted).toEqual(true);
});
it('does not execute if disabled', async () => {
const { result } = renderHook(() => useSubAction({ ...params, disabled: true }));
expect(useKibanaMock().services.http.post).not.toHaveBeenCalled();
expect(mockHttpPost).not.toHaveBeenCalled();
expect(result.current).toEqual({
isLoading: false,
response: undefined,

View file

@ -10,7 +10,7 @@ import { Reducer, useEffect, useReducer, useRef } from 'react';
import { useKibana } from '../../common/lib/kibana';
import { executeAction } from '../lib/action_connector_api';
interface UseSubActionParams<P> {
export interface UseSubActionParams<P> {
connectorId?: string;
subAction?: string;
subActionParams?: P;
@ -25,12 +25,14 @@ interface SubActionsState<R> {
const enum SubActionsActionsList {
START,
STOP,
SUCCESS,
ERROR,
}
type Action<R> =
| { type: SubActionsActionsList.START }
| { type: SubActionsActionsList.STOP }
| { type: SubActionsActionsList.SUCCESS; payload: R | undefined }
| { type: SubActionsActionsList.ERROR; payload: Error | null };
@ -42,6 +44,12 @@ const dataFetchReducer = <R,>(state: SubActionsState<R>, action: Action<R>): Sub
isLoading: true,
error: null,
};
case SubActionsActionsList.STOP:
return {
...state,
isLoading: false,
error: null,
};
case SubActionsActionsList.SUCCESS:
return {
...state,
@ -86,11 +94,12 @@ export const useSubAction = <P, R>({
useEffect(() => {
if (disabled || !connectorId || !subAction) {
dispatch({ type: SubActionsActionsList.STOP });
return;
}
const abortCtrl = new AbortController();
let isMounted = true;
let isActive = true;
const executeSubAction = async () => {
try {
@ -106,7 +115,7 @@ export const useSubAction = <P, R>({
signal: abortCtrl.signal,
});
if (isMounted) {
if (isActive) {
if (res.status && res.status === 'ok') {
dispatch({ type: SubActionsActionsList.SUCCESS, payload: res.data });
} else {
@ -117,11 +126,11 @@ export const useSubAction = <P, R>({
}
}
return res.data;
} catch (e) {
if (isMounted && !abortCtrl.signal.aborted) {
} catch (err) {
if (isActive) {
dispatch({
type: SubActionsActionsList.ERROR,
payload: e,
payload: err,
});
}
}
@ -130,7 +139,7 @@ export const useSubAction = <P, R>({
executeSubAction();
return () => {
isMounted = false;
isActive = false;
abortCtrl.abort();
};
}, [memoParams, disabled, connectorId, subAction, http]);

View file

@ -120,11 +120,14 @@ export {
deprecatedMessage,
} from './common';
export { useLoadRuleTypes, useSubAction } from './application/hooks';
export type {
TriggersAndActionsUIPublicPluginSetup,
TriggersAndActionsUIPublicPluginStart,
} from './plugin';
export { Plugin } from './plugin';
// TODO remove this import when we expose the Rules tables as a component
export { loadRules } from './application/lib/rule_api/rules';
export { loadExecutionLogAggregations } from './application/lib/rule_api/load_execution_log_aggregations';
@ -139,7 +142,6 @@ export { unmuteRule } from './application/lib/rule_api/unmute';
export { snoozeRule } from './application/lib/rule_api/snooze';
export { unsnoozeRule } from './application/lib/rule_api/unsnooze';
export { loadRuleAggregations, loadRuleTags } from './application/lib/rule_api/aggregate';
export { useLoadRuleTypes } from './application/hooks/use_load_rule_types';
export { loadRule } from './application/lib/rule_api/get_rule';
export { loadAllActions } from './application/lib/action_connector_api';
export { suspendedComponentWithProps } from './application/lib/suspended_component_with_props';

View file

@ -44,6 +44,7 @@ const enabledActionTypes = [
'.jira',
'.resilient',
'.slack',
'.tines',
'.webhook',
'.xmatters',
'test.sub-action-connector',

View file

@ -26,6 +26,7 @@ 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 initUnsecuredAction } from './unsecured_actions_simulation';
import { initPlugin as initTines } from './tines_simulation';
export const NAME = 'actions-FTS-external-service-simulators';
@ -40,12 +41,14 @@ export enum ExternalServiceSimulator {
WEBHOOK = 'webhook',
MS_EXCHANGE = 'exchange',
XMATTERS = 'xmatters',
TINES = 'tines',
}
export function getExternalServiceSimulatorPath(service: ExternalServiceSimulator): string {
return `/api/_${NAME}/${service}`;
}
// list all urls for server.xsrf.allowlist config
export function getAllExternalServiceSimulatorPaths(): string[] {
const allPaths = Object.values(ExternalServiceSimulator).map((service) =>
getExternalServiceSimulatorPath(service)
@ -56,6 +59,7 @@ export function getAllExternalServiceSimulatorPaths(): string[] {
allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.MS_EXCHANGE}/users/test@/sendMail`);
allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.MS_EXCHANGE}/1234567/oauth2/v2.0/token`);
allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/oauth_token.do`);
allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.TINES}/webhook/path/secret`);
return allPaths;
}
@ -142,6 +146,7 @@ export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, Fixtu
router,
getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW)
);
initTines(router, getExternalServiceSimulatorPath(ExternalServiceSimulator.TINES));
initUnsecuredAction(router, core);
}

View file

@ -0,0 +1,155 @@
/*
* 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 http from 'http';
import {
RequestHandlerContext,
KibanaRequest,
KibanaResponseFactory,
IKibanaResponse,
IRouter,
} from '@kbn/core/server';
import { ProxyArgs, Simulator } from './simulator';
export const tinesStory1 = { name: 'story 1', id: 1, team: 'team', published: true };
export const tinesStory2 = { name: 'story 2', id: 2, team: 'team', published: true };
export const tinesAgentWebhook = {
name: 'agent 1',
id: 1,
story_id: 1,
options: { secret: 'secret', path: 'path' },
type: 'Agents::WebhookAgent',
};
export const tinesAgentNotWebhook = {
name: 'agent 2',
id: 2,
story_id: 1,
options: { url: 'url' },
type: 'Agents::HttpRequest',
};
export const tinesStoriesResponse = {
stories: [tinesStory1, tinesStory2],
meta: {
pages: 1,
},
};
export const tinesAgentsResponse = {
agents: [tinesAgentWebhook, tinesAgentNotWebhook],
meta: {
pages: 1,
},
};
export const tinesWebhookSuccessResponse = {
status: 'ok',
};
export const tinesFailedResponse = {
result: 'error',
errors: {
message: 'failed',
},
took: 0.107,
requestId: '43a29c5c-3dbf-4fa4-9c26-f4f71023e120',
};
export class TinesSimulator extends Simulator {
private readonly returnError: boolean;
constructor({ returnError = false, proxy }: { returnError?: boolean; proxy?: ProxyArgs }) {
super(proxy);
this.returnError = returnError;
}
public async handler(
request: http.IncomingMessage,
response: http.ServerResponse,
data: Record<string, unknown>
) {
if (this.returnError) {
return TinesSimulator.sendErrorResponse(response);
}
return TinesSimulator.sendResponse(request, response);
}
private static sendResponse(request: http.IncomingMessage, response: http.ServerResponse) {
response.statusCode = 202;
response.setHeader('Content-Type', 'application/json');
let body;
if (request.url?.match('/stories')) {
body = tinesStoriesResponse;
} else if (request.url?.match('/agents')) {
body = tinesAgentsResponse;
} else if (request.url?.match('/webhook')) {
body = tinesWebhookSuccessResponse;
}
response.end(JSON.stringify(body, null, 4));
}
private static sendErrorResponse(response: http.ServerResponse) {
response.statusCode = 422;
response.setHeader('Content-Type', 'application/json;charset=UTF-8');
response.end(JSON.stringify(tinesFailedResponse, null, 4));
}
}
export function initPlugin(router: IRouter, path: string) {
router.get(
{
path: `${path}/api/v1/stories`,
options: {
authRequired: false,
},
validate: {},
},
async function (
context: RequestHandlerContext,
req: KibanaRequest<any, any, any, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
return res.ok({ body: tinesStoriesResponse });
}
);
router.get(
{
path: `${path}/api/v1/agents`,
options: {
authRequired: false,
},
validate: {},
},
async function (
context: RequestHandlerContext,
req: KibanaRequest<any, any, any, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
return res.ok({ body: tinesAgentsResponse });
}
);
router.post(
{
path: `${path}/webhook/path/secret`,
options: {
authRequired: false,
},
validate: {},
},
async function (
context: RequestHandlerContext,
req: KibanaRequest<any, any, any, any>,
res: KibanaResponseFactory
): Promise<IKibanaResponse<any>> {
return res.ok({ body: tinesWebhookSuccessResponse });
}
);
}

View file

@ -0,0 +1,557 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../../../../common/ftr_provider_context';
import {
TinesSimulator,
tinesStory1,
tinesStory2,
tinesAgentWebhook,
tinesWebhookSuccessResponse,
} from '../../../../../../common/fixtures/plugins/actions_simulators/server/tines_simulation';
const connectorTypeId = '.tines';
const name = 'A tines action';
const secrets = {
email: 'some@email.com',
token: 'tinesToken',
};
const webhook = {
name: 'webhook 1',
id: 1,
storyId: 1,
path: 'path',
secret: 'secret',
};
// eslint-disable-next-line import/no-default-export
export default function tinesTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const configService = getService('config');
const createConnector = async (url: string) => {
const { body } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name,
connector_type_id: connectorTypeId,
config: { url },
secrets,
})
.expect(200);
return body.id;
};
describe('Tines', () => {
describe('action creation', () => {
const simulator = new TinesSimulator({
returnError: false,
proxy: {
config: configService.get('kbnTestServer.serverArgs'),
},
});
const config = { url: '' };
before(async () => {
config.url = await simulator.start();
});
after(() => {
simulator.close();
});
it('should return 200 when creating the connector', async () => {
const { body: createdAction } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name,
connector_type_id: connectorTypeId,
config,
secrets,
})
.expect(200);
expect(createdAction).to.eql({
id: createdAction.id,
is_preconfigured: false,
is_deprecated: false,
name,
connector_type_id: connectorTypeId,
is_missing_secrets: false,
config,
});
});
it('should return 400 Bad Request when creating the connector without the url', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name,
connector_type_id: connectorTypeId,
config: {},
secrets,
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type config: [url]: expected value of type [string] but got [undefined]',
});
});
});
it('should return 400 Bad Request when creating the connector with a url that is not allowed', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name,
connector_type_id: connectorTypeId,
config: {
url: 'http://tines.mynonexistent.com',
},
secrets,
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type config: error validating url: target url "http://tines.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts',
});
});
});
it('should return 400 Bad Request when creating the connector without secrets', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name,
connector_type_id: connectorTypeId,
config,
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type secrets: [email]: expected value of type [string] but got [undefined]',
});
});
});
});
describe('executor', () => {
describe('validation', () => {
const simulator = new TinesSimulator({
proxy: {
config: configService.get('kbnTestServer.serverArgs'),
},
});
let tinesActionId: string;
before(async () => {
const url = await simulator.start();
tinesActionId = await createConnector(url);
});
after(() => {
simulator.close();
});
it('should fail when the params is empty', async () => {
const { body } = await supertest
.post(`/api/actions/connector/${tinesActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {},
});
expect(200);
expect(Object.keys(body)).to.eql(['status', 'message', 'retry', 'connector_id']);
expect(body.connector_id).to.eql(tinesActionId);
expect(body.status).to.eql('error');
});
it('should fail when the subAction is invalid', async () => {
const { body } = await supertest
.post(`/api/actions/connector/${tinesActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'invalidAction' },
})
.expect(200);
expect(body).to.eql({
connector_id: tinesActionId,
status: 'error',
retry: true,
message: 'an error occurred while running the action',
service_message: `Sub action "invalidAction" is not registered. Connector id: ${tinesActionId}. Connector name: Tines. Connector type: .tines`,
});
});
it("should fail to get webhooks when the storyId parameter isn't included", async () => {
const { body } = await supertest
.post(`/api/actions/connector/${tinesActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'webhooks', subActionParams: {} },
})
.expect(200);
expect(body).to.eql({
connector_id: tinesActionId,
status: 'error',
retry: true,
message: 'an error occurred while running the action',
service_message:
'Request validation failed (Error: [storyId]: expected value of type [number] but got [undefined])',
});
});
it("should fail to run when the webhook parameter isn't included", async () => {
const { body } = await supertest
.post(`/api/actions/connector/${tinesActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'run', subActionParams: { body: '' } },
})
.expect(200);
expect(body).to.eql({
connector_id: tinesActionId,
status: 'error',
retry: true,
message: 'an error occurred while running the action',
service_message:
'Invalid subActionsParams: [webhook] or [webhookUrl] expected but got none',
});
});
it("should fail to run when the webhook.story_id parameter isn't included", async () => {
const { storyId, ...wrongWebhook } = webhook;
const { body } = await supertest
.post(`/api/actions/connector/${tinesActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'run',
subActionParams: { body: '', webhook: wrongWebhook },
},
})
.expect(200);
expect(body).to.eql({
connector_id: tinesActionId,
status: 'error',
retry: true,
message: 'an error occurred while running the action',
service_message:
'Request validation failed (Error: [webhook.storyId]: expected value of type [number] but got [undefined])',
});
});
it("should fail to run when the webhook.name parameter isn't included", async () => {
const { name: _, ...wrongWebhook } = webhook;
const { body } = await supertest
.post(`/api/actions/connector/${tinesActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'run',
subActionParams: { body: '', webhook: wrongWebhook },
},
})
.expect(200);
expect(body).to.eql({
connector_id: tinesActionId,
status: 'error',
retry: true,
message: 'an error occurred while running the action',
service_message:
'Request validation failed (Error: [webhook.name]: expected value of type [string] but got [undefined])',
});
});
it("should fail to run when the webhook.path parameter isn't included", async () => {
const { path, ...wrongWebhook } = webhook;
const { body } = await supertest
.post(`/api/actions/connector/${tinesActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'run',
subActionParams: { body: '', webhook: wrongWebhook },
},
})
.expect(200);
expect(body).to.eql({
connector_id: tinesActionId,
status: 'error',
retry: true,
message: 'an error occurred while running the action',
service_message:
'Request validation failed (Error: [webhook.path]: expected value of type [string] but got [undefined])',
});
});
it("should fail to run when the webhook.secret parameter isn't included", async () => {
const { secret, ...wrongWebhook } = webhook;
const { body } = await supertest
.post(`/api/actions/connector/${tinesActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'run',
subActionParams: { body: '', webhook: wrongWebhook },
},
})
.expect(200);
expect(body).to.eql({
connector_id: tinesActionId,
status: 'error',
retry: true,
message: 'an error occurred while running the action',
service_message:
'Request validation failed (Error: [webhook.secret]: expected value of type [string] but got [undefined])',
});
});
});
describe('execution', () => {
describe('successful response simulator', () => {
const simulator = new TinesSimulator({
proxy: {
config: configService.get('kbnTestServer.serverArgs'),
},
});
let url: string;
let tinesActionId: string;
before(async () => {
url = await simulator.start();
tinesActionId = await createConnector(url);
});
after(() => {
simulator.close();
});
it('should get stories', async () => {
const { body } = await supertest
.post(`/api/actions/connector/${tinesActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'stories', subActionParams: {} },
})
.expect(200);
expect(simulator.requestUrl).to.eql(getTinesStoriesUrl(url, { per_page: '500' }));
expect(body).to.eql({
status: 'ok',
connector_id: tinesActionId,
data: {
stories: [
{ id: tinesStory1.id, name: tinesStory1.name, published: true },
{ id: tinesStory2.id, name: tinesStory2.name, published: true },
],
incompleteResponse: false,
},
});
});
it('should get webhooks', async () => {
const { body } = await supertest
.post(`/api/actions/connector/${tinesActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'webhooks', subActionParams: { storyId: 1 } },
})
.expect(200);
expect(simulator.requestUrl).to.eql(
getTinesAgentsUrl(url, { story_id: '1', per_page: '500' })
);
expect(body).to.eql({
status: 'ok',
connector_id: tinesActionId,
data: {
webhooks: [
{
id: tinesAgentWebhook.id,
name: tinesAgentWebhook.name,
storyId: tinesAgentWebhook.story_id,
path: tinesAgentWebhook.options.path,
secret: tinesAgentWebhook.options.secret,
},
],
incompleteResponse: false,
},
});
});
it('should run the webhook', async () => {
const { body } = await supertest
.post(`/api/actions/connector/${tinesActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'run', subActionParams: { body: '["test"]', webhook } },
})
.expect(200);
expect(simulator.requestData).to.eql(['test']);
expect(simulator.requestUrl).to.eql(getTinesWebhookPostUrl(url, webhook));
expect(body).to.eql({
status: 'ok',
connector_id: tinesActionId,
data: tinesWebhookSuccessResponse,
});
});
it('should run the webhook url', async () => {
const { body } = await supertest
.post(`/api/actions/connector/${tinesActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'run',
subActionParams: {
body: '["test"]',
webhookUrl: getTinesWebhookPostUrl(url, webhook),
},
},
})
.expect(200);
expect(simulator.requestData).to.eql(['test']);
expect(simulator.requestUrl).to.eql(getTinesWebhookPostUrl(url, webhook));
expect(body).to.eql({
status: 'ok',
connector_id: tinesActionId,
data: tinesWebhookSuccessResponse,
});
});
});
describe('error response simulator', () => {
const simulator = new TinesSimulator({
returnError: true,
proxy: {
config: configService.get('kbnTestServer.serverArgs'),
},
});
let tinesActionId: string;
before(async () => {
const url = await simulator.start();
tinesActionId = await createConnector(url);
});
after(() => {
simulator.close();
});
it('should return a failure when attempting to get stories', async () => {
const { body } = await supertest
.post(`/api/actions/connector/${tinesActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'stories',
subActionParams: {},
},
})
.expect(200);
expect(body).to.eql({
status: 'error',
message: 'an error occurred while running the action',
retry: true,
connector_id: tinesActionId,
service_message: 'Status code: 422. Message: API Error: Unprocessable Entity',
});
});
it('should return a failure when attempting to get webhooks', async () => {
const { body } = await supertest
.post(`/api/actions/connector/${tinesActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'webhooks',
subActionParams: { storyId: 1 },
},
})
.expect(200);
expect(body).to.eql({
status: 'error',
message: 'an error occurred while running the action',
retry: true,
connector_id: tinesActionId,
service_message: 'Status code: 422. Message: API Error: Unprocessable Entity',
});
});
it('should return a failure when attempting to run', async () => {
const { body } = await supertest
.post(`/api/actions/connector/${tinesActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'run', subActionParams: { body: '["test"]', webhook } },
})
.expect(200);
expect(simulator.requestData).to.eql(['test']);
expect(body).to.eql({
status: 'error',
message: 'an error occurred while running the action',
retry: true,
connector_id: tinesActionId,
service_message: 'Status code: 422. Message: API Error: Unprocessable Entity',
});
});
});
});
});
});
}
const createTinesUrlString = (
baseUrl: string,
path: string,
queryParams?: Record<string, string>
) => {
const fullURL = new URL(path, baseUrl);
for (const [key, value] of Object.entries(queryParams ?? {})) {
fullURL.searchParams.set(key, value);
}
return fullURL.toString();
};
const getTinesStoriesUrl = (baseUrl: string, queryParams: Record<string, string>) =>
createTinesUrlString(baseUrl, 'api/v1/stories', queryParams);
const getTinesAgentsUrl = (baseUrl: string, queryParams: Record<string, string>) =>
createTinesUrlString(baseUrl, 'api/v1/agents', queryParams);
const getTinesWebhookPostUrl = (baseUrl: string, { path, secret }: typeof webhook) =>
createTinesUrlString(baseUrl, `webhook/${path}/${secret}`);

View file

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

View file

@ -23,27 +23,30 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr
.then((response) => response.body);
expect(
registeredConnectorTypes.filter(
(connectorType: string) => !connectorType.startsWith('test.')
)
).to.eql([
'.email',
'.index',
'.pagerduty',
'.swimlane',
'.server-log',
'.slack',
'.webhook',
'.cases-webhook',
'.xmatters',
'.servicenow',
'.servicenow-sir',
'.servicenow-itom',
'.jira',
'.resilient',
'.teams',
'.opsgenie',
]);
registeredConnectorTypes
.filter((connectorType: string) => !connectorType.startsWith('test.'))
.sort()
).to.eql(
[
'.email',
'.index',
'.pagerduty',
'.swimlane',
'.server-log',
'.slack',
'.webhook',
'.cases-webhook',
'.xmatters',
'.servicenow',
'.servicenow-sir',
'.servicenow-itom',
'.jira',
'.resilient',
'.teams',
'.tines',
'.opsgenie',
].sort()
);
});
});
}

View file

@ -8,12 +8,14 @@
import { FtrProviderContext } from '../../ftr_provider_context';
import { ActionsCommonServiceProvider } from './common';
import { ActionsOpsgenieServiceProvider } from './opsgenie';
import { ActionsTinesServiceProvider } from './tines';
export function ActionsServiceProvider(context: FtrProviderContext) {
const common = ActionsCommonServiceProvider(context);
return {
opsgenie: ActionsOpsgenieServiceProvider(context, common),
common: ActionsCommonServiceProvider(context),
opsgenie: ActionsOpsgenieServiceProvider(context, common),
tines: ActionsTinesServiceProvider(context, common),
};
}

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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
import type { ActionsCommon } from './common';
export interface ConnectorFormFields {
name: string;
url: string;
email: string;
token: string;
}
export function ActionsTinesServiceProvider(
{ getService, getPageObject }: FtrProviderContext,
common: ActionsCommon
) {
const testSubjects = getService('testSubjects');
const find = getService('find');
return {
async createNewConnector(fields: ConnectorFormFields) {
await common.openNewConnectorForm('tines');
await this.setConnectorFields(fields);
const flyOutSaveButton = await testSubjects.find('create-connector-flyout-save-btn');
expect(await flyOutSaveButton.isEnabled()).to.be(true);
await flyOutSaveButton.click();
},
async setConnectorFields({ name, url, email, token }: ConnectorFormFields) {
await testSubjects.setValue('nameInput', name);
await testSubjects.setValue('config.url-input', url);
await testSubjects.setValue('secrets.email-input', email);
await testSubjects.setValue('secrets.token-input', token);
},
async updateConnectorFields(fields: ConnectorFormFields) {
await this.setConnectorFields(fields);
const editFlyOutSaveButton = await testSubjects.find('edit-connector-flyout-save-btn');
expect(await editFlyOutSaveButton.isEnabled()).to.be(true);
await editFlyOutSaveButton.click();
},
async setJsonEditor(value: object) {
const stringified = JSON.stringify(value);
await find.clickByCssSelector('.monaco-editor');
const input = await find.activeElement();
await input.clearValueWithKeyboard({ charByChar: true });
await input.type(stringified);
},
};
}

View file

@ -11,5 +11,6 @@ export default ({ loadTestFile }: FtrProviderContext) => {
describe('Connectors', function () {
loadTestFile(require.resolve('./general'));
loadTestFile(require.resolve('./opsgenie'));
loadTestFile(require.resolve('./tines'));
});
};

View file

@ -0,0 +1,279 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { ObjectRemover } from '../../../lib/object_remover';
import { generateUniqueKey } from '../../../lib/get_test_data';
import { createConnector, getConnectorByName } from './utils';
import {
tinesAgentWebhook,
tinesStory1,
} from '../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/tines_simulation';
import {
ExternalServiceSimulator,
getExternalServiceSimulatorPath,
} from '../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin';
export default ({ getPageObjects, getService }: FtrProviderContext) => {
const testSubjects = getService('testSubjects');
const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']);
const find = getService('find');
const retry = getService('retry');
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
const actions = getService('actions');
const browser = getService('browser');
const comboBox = getService('comboBox');
let objectRemover: ObjectRemover;
let simulatorUrl: string;
// isEnabled helper uses "disabled" attribute, testSubjects.isEnabled() gives inconsistent results for comboBoxes.
const isEnabled = async (selector: string) =>
testSubjects.getAttribute(selector, 'disabled').then((disabled) => disabled !== 'true');
describe('Tines', () => {
before(async () => {
objectRemover = new ObjectRemover(supertest);
simulatorUrl = kibanaServer.resolveUrl(
getExternalServiceSimulatorPath(ExternalServiceSimulator.TINES)
);
});
after(async () => {
await objectRemover.removeAll();
});
describe('connector page', () => {
beforeEach(async () => {
await pageObjects.common.navigateToApp('triggersActionsConnectors');
});
it('should create the connector', async () => {
const connectorName = generateUniqueKey();
await actions.tines.createNewConnector({
name: connectorName,
url: 'https://test.tines.com',
email: 'test@foo.com',
token: 'apiToken',
});
const toastTitle = await pageObjects.common.closeToast();
expect(toastTitle).to.eql(`Created '${connectorName}'`);
await pageObjects.triggersActionsUI.searchConnectors(connectorName);
const searchResults = await pageObjects.triggersActionsUI.getConnectorsList();
expect(searchResults).to.eql([
{
name: connectorName,
actionType: 'Tines',
},
]);
const connector = await getConnectorByName(connectorName, supertest);
objectRemover.add(connector.id, 'action', 'actions');
});
it('should edit the connector', async () => {
const connectorName = generateUniqueKey();
const updatedConnectorName = `${connectorName}updated`;
const createdAction = await createTinesConnector(connectorName);
objectRemover.add(createdAction.id, 'action', 'actions');
browser.refresh();
await pageObjects.triggersActionsUI.searchConnectors(connectorName);
const searchResultsBeforeEdit = await pageObjects.triggersActionsUI.getConnectorsList();
expect(searchResultsBeforeEdit.length).to.eql(1);
await find.clickByCssSelector('[data-test-subj="connectorsTableCell-name"] button');
await actions.tines.updateConnectorFields({
name: updatedConnectorName,
url: 'https://test.tines.com',
email: 'test@foo.com',
token: 'apiToken',
});
const toastTitle = await pageObjects.common.closeToast();
expect(toastTitle).to.eql(`Updated '${updatedConnectorName}'`);
await testSubjects.click('euiFlyoutCloseButton');
await pageObjects.triggersActionsUI.searchConnectors(updatedConnectorName);
const searchResultsAfterEdit = await pageObjects.triggersActionsUI.getConnectorsList();
expect(searchResultsAfterEdit).to.eql([
{
name: updatedConnectorName,
actionType: 'Tines',
},
]);
});
it('should reset connector when canceling an edit', async () => {
const connectorName = generateUniqueKey();
const createdAction = await createTinesConnector(connectorName);
objectRemover.add(createdAction.id, 'action', 'actions');
browser.refresh();
await pageObjects.triggersActionsUI.searchConnectors(connectorName);
const searchResultsBeforeEdit = await pageObjects.triggersActionsUI.getConnectorsList();
expect(searchResultsBeforeEdit.length).to.eql(1);
await find.clickByCssSelector('[data-test-subj="connectorsTableCell-name"] button');
await testSubjects.setValue('nameInput', 'some test name to cancel');
await testSubjects.click('edit-connector-flyout-close-btn');
await testSubjects.click('confirmModalConfirmButton');
await find.waitForDeletedByCssSelector(
'[data-test-subj="edit-connector-flyout-close-btn"]'
);
await pageObjects.triggersActionsUI.searchConnectors(connectorName);
await find.clickByCssSelector('[data-test-subj="connectorsTableCell-name"] button');
expect(await testSubjects.getAttribute('nameInput', 'value')).to.eql(connectorName);
await testSubjects.click('euiFlyoutCloseButton');
});
it('should disable the run button when the fields are not filled', async () => {
const connectorName = generateUniqueKey();
const createdAction = await createTinesConnector(connectorName);
objectRemover.add(createdAction.id, 'action', 'actions');
browser.refresh();
await pageObjects.triggersActionsUI.searchConnectors(connectorName);
const searchResultsBeforeEdit = await pageObjects.triggersActionsUI.getConnectorsList();
expect(searchResultsBeforeEdit.length).to.eql(1);
await find.clickByCssSelector('[data-test-subj="connectorsTableCell-name"] button');
await find.clickByCssSelector('[data-test-subj="testConnectorTab"]');
expect(await isEnabled('executeActionButton')).to.be(false);
});
describe('test page', () => {
let connectorId = '';
before(async () => {
const connectorName = generateUniqueKey();
const createdAction = await createTinesConnector(connectorName);
connectorId = createdAction.id;
objectRemover.add(createdAction.id, 'action', 'actions');
});
beforeEach(async () => {
await testSubjects.click(`edit${connectorId}`);
await testSubjects.click('testConnectorTab');
});
afterEach(async () => {
await actions.common.cancelConnectorForm();
});
it('should show the selectors and json editor when in test mode', async () => {
await testSubjects.existOrFail('tines-storySelector');
await testSubjects.existOrFail('tines-webhookSelector');
await find.existsByCssSelector('.monaco-editor');
});
it('should enable story selector when it is loaded', async () => {
await retry.waitFor('stories to load values', async () =>
isEnabled('tines-storySelector')
);
expect(await isEnabled('tines-storySelector')).to.be(true);
expect(await isEnabled('tines-webhookSelector')).to.be(false);
});
it('should enable webhook selector when story selected', async () => {
await retry.waitFor('stories to load values', async () =>
isEnabled('tines-storySelector')
);
await comboBox.set('tines-storySelector', tinesStory1.name);
await retry.waitFor('webhooks to load values', async () =>
isEnabled('tines-webhookSelector')
);
});
it('should reset webhook selector when selected story changed', async () => {
await retry.waitFor('stories to load values', async () =>
isEnabled('tines-storySelector')
);
await comboBox.set('tines-storySelector', tinesStory1.name);
await retry.waitFor('webhooks to load values', async () =>
isEnabled('tines-webhookSelector')
);
await comboBox.set('tines-webhookSelector', tinesAgentWebhook.name);
expect(await comboBox.getComboBoxSelectedOptions('tines-webhookSelector')).to.contain(
tinesAgentWebhook.name
);
await comboBox.clear('tines-storySelector');
await retry.waitFor('webhooks to be disabled', async () =>
isEnabled('tines-webhookSelector').then((enabled) => !enabled)
);
expect(await comboBox.getComboBoxSelectedOptions('tines-webhookSelector')).to.be.empty();
});
it('should have run button disabled if selectors have value but JSON missing', async () => {
await retry.waitFor('stories to load values', async () =>
isEnabled('tines-storySelector')
);
await comboBox.set('tines-storySelector', tinesStory1.name);
await retry.waitFor('webhooks to load values', async () =>
isEnabled('tines-webhookSelector')
);
await comboBox.set('tines-webhookSelector', tinesAgentWebhook.name);
expect(await isEnabled('executeActionButton')).to.be(false);
});
it('should run successfully if selectors and JSON have value', async () => {
await retry.waitFor('stories to load values', async () =>
isEnabled('tines-storySelector')
);
await comboBox.set('tines-storySelector', tinesStory1.name);
await retry.waitFor('webhooks to load values', async () =>
testSubjects.isEnabled('tines-webhookSelector')
);
await comboBox.set('tines-webhookSelector', tinesAgentWebhook.name);
await actions.tines.setJsonEditor({
hello: 'tines',
});
expect(await isEnabled('executeActionButton')).to.be(true);
await testSubjects.click('executeActionButton');
await retry.waitFor('success message', async () =>
testSubjects.exists('executionSuccessfulResult')
);
});
});
});
const createTinesConnector = async (name: string) => {
return createConnector({
name,
config: { url: simulatorUrl },
secrets: { email: 'test@foo.com', token: 'apiToken' },
connectorTypeId: '.tines',
supertest,
});
};
});
};

View file

@ -10,6 +10,7 @@ import { resolve, join } from 'path';
import { CA_CERT_PATH } from '@kbn/dev-utils';
import { FtrConfigProviderContext } from '@kbn/test';
import { pageObjects } from './page_objects';
import { getAllExternalServiceSimulatorPaths } from '../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin';
// .server-log is specifically not enabled
const enabledActionTypes = [
@ -23,6 +24,7 @@ const enabledActionTypes = [
'.servicenow',
'.servicenow-sir',
'.slack',
'.tines',
'.webhook',
'test.authorization',
'test.failing',
@ -117,6 +119,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
},
},
})}`,
`--server.xsrf.allowlist=${JSON.stringify(getAllExternalServiceSimulatorPaths())}`,
],
},
security: {

View file

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